Initial commit: 国土空间规划课程智能体 v1.0
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Data directories (will be mounted as volumes)
|
||||||
|
data/
|
||||||
|
vector_store/
|
||||||
|
uploads/
|
||||||
|
generated_images/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
# 环境变量与密钥
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# 运行时数据
|
||||||
|
runtime/
|
||||||
|
vector_store/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 上传与生成文件
|
||||||
|
uploads/
|
||||||
|
generated_images/
|
||||||
|
|
||||||
|
# 数据目录中的运行时文件(保留源文件如 .tex)
|
||||||
|
data/database/
|
||||||
|
data/knowledge_base/
|
||||||
|
|
||||||
|
# LaTeX 中间文件
|
||||||
|
*.aux
|
||||||
|
*.bbl
|
||||||
|
*.blg
|
||||||
|
*.toc
|
||||||
|
*.out
|
||||||
|
*.fdb_latexmk
|
||||||
|
*.fls
|
||||||
|
*.synctex.gz
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
*.coverage
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
国土空间规划课程智能体 (Territorial Spatial Planning Course Agent) — an AI-powered Q&A system for a territorial spatial planning course. Single-container Docker deployment with supervisor managing a FastAPI backend and Next.js frontend.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
**Backend** (from project root):
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync # install Python deps (requires uv >= 0.6.15, Python >= 3.12)
|
||||||
|
uv run main.py # start backend on :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend** (separate terminal):
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm install # install Node deps (pnpm 10.6.5)
|
||||||
|
pnpm dev # start Next.js on :8001 with Turbopack
|
||||||
|
pnpm build # production build
|
||||||
|
pnpm check # lint + typecheck
|
||||||
|
pnpm lint:fix # auto-fix lint issues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d # build & start all services
|
||||||
|
docker-compose logs -f app # follow logs
|
||||||
|
docker-compose exec app bash # shell into container
|
||||||
|
docker-compose exec db psql -U user -d course_agent_db # database shell
|
||||||
|
```
|
||||||
|
|
||||||
|
Ports: backend API `:8000`, frontend `:8001`, PostgreSQL `:5433` (mapped from container's 5432).
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
Copy `env.example` to `.env` and configure. Critical variables:
|
||||||
|
- `SILICONFLOW_API_KEY` — LLM provider API key
|
||||||
|
- `DATABASE_URL` — `sqlite:///...` for local dev, `postgresql+psycopg://...` for Docker
|
||||||
|
- `SECRET_KEY` — JWT signing key
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (`backend/src/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
api/ FastAPI routers: auth, chat, document, image, knowledge_base,
|
||||||
|
course_content, forum, analytics
|
||||||
|
core/ config.py (pydantic-settings), database.py (SQLAlchemy, lazy engine),
|
||||||
|
security.py (JWT)
|
||||||
|
llm/ siliconflow.py — ChatOpenAI wrapper over SiliconFlow API
|
||||||
|
rag/ The RAG pipeline:
|
||||||
|
embeddings.py → SentenceTransformer (text2vec-base-chinese)
|
||||||
|
vector_store.py → Chroma singleton (langchain-chroma)
|
||||||
|
retrievers.py → KnowledgeBaseRetriever (BaseRetriever subclass,
|
||||||
|
filters by knowledge_base_id, distance→score conversion)
|
||||||
|
chains.py → RAGChain (LangChain Runnable API, supports
|
||||||
|
invoke/ainvoke/astream/astream_with_sources)
|
||||||
|
conversation_chains.py → non-RAG conversation chain
|
||||||
|
prompts.py → prompt templates
|
||||||
|
graph/ LangGraph workflow: qa_graph.py defines a linear pipeline
|
||||||
|
analyze → retrieve → generate → format
|
||||||
|
(nodes.py has the node implementations)
|
||||||
|
models/ SQLAlchemy ORM: User, ChatSession, ChatMessage, Document,
|
||||||
|
DocumentChunk, KnowledgeBase, Book/Chapter/Section/Subsection,
|
||||||
|
ForumCategory/Post/Reply, GeneratedImage, CourseModule
|
||||||
|
services/ Business logic: auth, document processing, knowledge base management,
|
||||||
|
LaTeX parser, file watcher, image generation (text-to-image, image-to-image)
|
||||||
|
migrations/ Database migration scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (`web/src/`)
|
||||||
|
|
||||||
|
Next.js 15 App Router + React 19 + TypeScript + TailwindCSS + shadcn/ui.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(auth)/ login, register pages
|
||||||
|
(main)/ chat, knowledge, course-content, forum, analytics,
|
||||||
|
spatial (text-to-image, image-to-image), profile, settings
|
||||||
|
components/
|
||||||
|
chat/ chat-interface, message-list, sidebar, knowledge-selector,
|
||||||
|
model-selector, mode-selector, source-references
|
||||||
|
course-content/ knowledge-graph, node-detail-dialog
|
||||||
|
home/ landing page sections
|
||||||
|
ui/ shadcn/ui primitives
|
||||||
|
store/
|
||||||
|
auth.ts Zustand auth store
|
||||||
|
chat.ts Zustand chat store (session management, streaming SSE handling)
|
||||||
|
lib/
|
||||||
|
api.ts All API client functions (auth, chat, image, knowledgeBase,
|
||||||
|
document, forum, analytics, courseContent, book)
|
||||||
|
auth.ts NextAuth config
|
||||||
|
types/index.ts TypeScript type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Data Flows
|
||||||
|
|
||||||
|
**Chat modes:**
|
||||||
|
- `normal` — ConversationChain (LLM only, with chat history)
|
||||||
|
- `rag` — RAGChain: embed query → Chroma similarity search (filtered by `knowledge_base_ids`) → LLM answer generation
|
||||||
|
|
||||||
|
**Streaming:** Backend sends SSE events (`data: {type: "thinking"|"chunk"|"sources"|"done", ...}`). Frontend's Zustand store accumulates chunks via `requestAnimationFrame` updates.
|
||||||
|
|
||||||
|
**Knowledge base pipeline:** Upload PDF/DOCX/TXT/MD → `document_service` extracts text → `text_splitters` chunks → embeddings → stored in Chroma with `knowledge_base_id` metadata for filtering.
|
||||||
|
|
||||||
|
**LLM integration:** All LLM calls go through `SiliconFlowLLM` which wraps `ChatOpenAI` pointed at SiliconFlow API (`https://api.siliconflow.cn/v1`). Default model is configurable via `SILICONFLOW_MODEL` env var. Supports model selection per-request via `model` parameter.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
SQLAlchemy ORM with dual support:
|
||||||
|
- **SQLite** — local development default
|
||||||
|
- **PostgreSQL** — Docker deployment (lazy engine initialization with retry for DNS resolution)
|
||||||
|
|
||||||
|
Engine creation is deferred (lazy) to handle Docker startup ordering — the engine connects only on first actual use, with 10 retries.
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
Multi-stage `Dockerfile`: (1) builds Next.js frontend, (2) installs Python deps via uv, (3) final slim image with Node.js + supervisor. `supervisord` manages both `uvicorn` (backend) and `next start` (frontend) in a single container.
|
||||||
|
|
||||||
|
Persistent volumes: `./data`, `./runtime` (contains vector_store, logs, generated_images).
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
- Backend Python code uses Chinese docstrings and log messages; variable/function names in English
|
||||||
|
- Ruff for Python linting (line-length 88, target Python 3.12)
|
||||||
|
- Frontend uses pnpm (not npm/yarn), ESLint + Prettier with Tailwind plugin
|
||||||
|
- API responses are JSON; streaming uses SSE (`text/plain` with `data: {...}\n\n` lines)
|
||||||
|
- All API endpoints require JWT auth except login/register; tokens stored in `localStorage` as `auth_token`
|
||||||
|
- Embedding model downloads go through `HF_ENDPOINT=https://hf-mirror.com` (China mirror)
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
# 多阶段构建:整合前端和后端到单个容器
|
||||||
|
|
||||||
|
# ==================== 阶段1: 构建前端 ====================
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
|
||||||
|
# 安装pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app/web
|
||||||
|
|
||||||
|
# 复制前端依赖文件
|
||||||
|
COPY web/package.json web/pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制前端源代码
|
||||||
|
COPY web/src ./src
|
||||||
|
COPY web/public ./public
|
||||||
|
COPY web/next.config.js ./
|
||||||
|
COPY web/tailwind.config.ts ./
|
||||||
|
COPY web/postcss.config.js ./
|
||||||
|
COPY web/tsconfig.json ./
|
||||||
|
COPY web/next-env.d.ts ./
|
||||||
|
|
||||||
|
# 构建前端应用
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ==================== 阶段2: 准备后端 ====================
|
||||||
|
FROM python:3.12-slim AS backend-builder
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
ENV UV_HTTP_TIMEOUT=600
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
libpq-dev \
|
||||||
|
postgresql-client \
|
||||||
|
poppler-utils \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# 配置 pip 使用国内镜像源
|
||||||
|
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
# 安装uv
|
||||||
|
RUN pip install uv
|
||||||
|
|
||||||
|
# 复制后端依赖文件(包括README.md,因为pyproject.toml引用了它)
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock ./
|
||||||
|
COPY backend/README.md* ./
|
||||||
|
|
||||||
|
# 安装Python依赖
|
||||||
|
RUN UV_HTTP_TIMEOUT=600 uv sync --frozen
|
||||||
|
|
||||||
|
# ==================== 阶段3: 最终镜像 ====================
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8001
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# 安装系统依赖(包括 Node.js 用于运行前端)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
libpq-dev \
|
||||||
|
postgresql-client \
|
||||||
|
poppler-utils \
|
||||||
|
libmagic1 \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
# 安装uv
|
||||||
|
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
|
||||||
|
pip install uv
|
||||||
|
|
||||||
|
# 从后端构建阶段复制Python虚拟环境
|
||||||
|
# uv 会在 /app/.venv 创建虚拟环境
|
||||||
|
COPY --from=backend-builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
# 复制后端代码和依赖文件
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock ./
|
||||||
|
COPY backend/src ./src
|
||||||
|
COPY backend/main.py ./
|
||||||
|
COPY backend/migrations ./migrations
|
||||||
|
COPY backend/scripts ./scripts
|
||||||
|
|
||||||
|
# 确保使用虚拟环境中的Python
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# 从前端构建阶段复制构建产物
|
||||||
|
COPY --from=frontend-builder /app/web/.next/standalone ./web
|
||||||
|
COPY --from=frontend-builder /app/web/.next/static ./web/.next/static
|
||||||
|
COPY --from=frontend-builder /app/web/public ./web/public
|
||||||
|
|
||||||
|
# 创建必要的目录
|
||||||
|
RUN mkdir -p /app/data /app/vector_store /app/uploads /app/logs /app/generated_images && \
|
||||||
|
chmod 755 /app/data /app/vector_store /app/uploads /app/logs /app/generated_images
|
||||||
|
|
||||||
|
# 创建supervisor配置文件
|
||||||
|
RUN mkdir -p /etc/supervisor/conf.d
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# 创建启动脚本
|
||||||
|
COPY docker/start.sh /app/start.sh
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000 8001
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health && curl -f http://localhost:8001 || exit 1
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["/app/start.sh"]
|
||||||
|
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# 国土空间规划课程智能体 - Docker单容器版本
|
||||||
|
|
||||||
|
一个基于大模型的智能问答系统,专门为国土空间规划课程设计。本版本将所有服务整合到单个Docker容器中,简化部署和管理。
|
||||||
|
|
||||||
|
## 🎯 项目特色
|
||||||
|
|
||||||
|
- **单容器架构**: 前端和后端服务整合在一个容器中,使用supervisor管理进程
|
||||||
|
- **AI驱动**: 集成硅基流动大模型和LangGraph工作流
|
||||||
|
- **RAG增强**: 基于Chroma向量数据库的智能检索
|
||||||
|
- **响应式设计**: 完美适配手机、平板、桌面设备
|
||||||
|
- **现代化技术栈**: Next.js 15 + React 19 + FastAPI + Python 3.12
|
||||||
|
|
||||||
|
## 🏗️ 技术架构
|
||||||
|
|
||||||
|
### 单容器架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Docker Container │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Backend │ │ Frontend │ │
|
||||||
|
│ │ FastAPI │ │ Next.js │ │
|
||||||
|
│ │ :8000 │ │ :8001 │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────┬─────────┘ │
|
||||||
|
│ Supervisor │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ Database │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- FastAPI + Uvicorn
|
||||||
|
- LangChain + LangGraph
|
||||||
|
- 硅基流动API (Qwen3-30B)
|
||||||
|
- Chroma向量数据库
|
||||||
|
- PostgreSQL
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- Next.js 15 (App Router)
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- TailwindCSS + shadcn/ui
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
course_Agent/
|
||||||
|
├── backend/ # Python后端代码
|
||||||
|
│ ├── src/ # 源代码
|
||||||
|
│ ├── main.py # 应用入口
|
||||||
|
│ ├── pyproject.toml # 依赖配置
|
||||||
|
│ └── migrations/ # 数据库迁移
|
||||||
|
├── web/ # Next.js前端代码
|
||||||
|
│ ├── src/ # 源代码
|
||||||
|
│ ├── public/ # 静态资源
|
||||||
|
│ └── package.json # 依赖配置
|
||||||
|
├── docker/ # Docker相关文件
|
||||||
|
│ ├── supervisord.conf # Supervisor配置
|
||||||
|
│ └── start.sh # 启动脚本
|
||||||
|
├── data/ # 数据目录(挂载卷)
|
||||||
|
├── vector_store/ # 向量数据库(挂载卷)
|
||||||
|
├── uploads/ # 上传文件(挂载卷)
|
||||||
|
├── logs/ # 日志文件(挂载卷)
|
||||||
|
├── generated_images/ # 生成的图像(挂载卷)
|
||||||
|
├── Dockerfile # Docker构建文件
|
||||||
|
├── docker-compose.yml # Docker Compose配置
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd course_Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
复制环境变量模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env` 文件,配置必要的参数(API密钥、数据库密码等)。
|
||||||
|
|
||||||
|
### 3. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 访问应用
|
||||||
|
|
||||||
|
- **前端应用**: http://localhost:8001
|
||||||
|
- **后端API**: http://localhost:8000
|
||||||
|
- **API文档**: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### 5. 停止服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 服务管理
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有服务日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 查看特定服务日志
|
||||||
|
docker-compose logs -f app
|
||||||
|
docker-compose logs -f db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重启服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重启所有服务
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# 重启特定服务
|
||||||
|
docker-compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 进入容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入应用容器
|
||||||
|
docker-compose exec app bash
|
||||||
|
|
||||||
|
# 在容器内查看进程状态
|
||||||
|
supervisorctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入数据库容器
|
||||||
|
docker-compose exec db psql -U user -d course_agent_db
|
||||||
|
|
||||||
|
# 备份数据库
|
||||||
|
docker-compose exec db pg_dump -U user course_agent_db > backup.sql
|
||||||
|
|
||||||
|
# 恢复数据库
|
||||||
|
docker-compose exec -T db psql -U user course_agent_db < backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 数据持久化
|
||||||
|
|
||||||
|
以下目录通过Docker volumes挂载,数据会持久化到宿主机:
|
||||||
|
|
||||||
|
- `./data` → `/app/data` - 知识库数据
|
||||||
|
- `./vector_store` → `/app/vector_store` - 向量数据库
|
||||||
|
- `./uploads` → `/app/uploads` - 用户上传文件
|
||||||
|
- `./logs` → `/app/logs` - 日志文件
|
||||||
|
- `./generated_images` → `/app/generated_images` - 生成的图像
|
||||||
|
|
||||||
|
## 🔍 故障排除
|
||||||
|
|
||||||
|
### 服务无法启动
|
||||||
|
|
||||||
|
1. 检查端口是否被占用:
|
||||||
|
```bash
|
||||||
|
netstat -ano | findstr :8000
|
||||||
|
netstat -ano | findstr :8001
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 查看容器日志:
|
||||||
|
```bash
|
||||||
|
docker-compose logs app
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 检查环境变量配置:
|
||||||
|
```bash
|
||||||
|
docker-compose exec app env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库连接失败
|
||||||
|
|
||||||
|
1. 确保数据库服务已启动:
|
||||||
|
```bash
|
||||||
|
docker-compose ps db
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 检查数据库连接字符串:
|
||||||
|
```bash
|
||||||
|
docker-compose exec app env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端无法访问后端
|
||||||
|
|
||||||
|
1. 检查后端服务是否运行:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 检查CORS配置:
|
||||||
|
```bash
|
||||||
|
docker-compose exec app env | grep ALLOWED_ORIGINS
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 开发指南
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
如果需要本地开发(不使用Docker):
|
||||||
|
|
||||||
|
1. **启动后端**:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync
|
||||||
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动前端** (新终端):
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改代码后重建镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重新构建镜像
|
||||||
|
docker-compose build app
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 环境变量说明
|
||||||
|
|
||||||
|
主要环境变量(完整列表见 `env.example`):
|
||||||
|
|
||||||
|
- `DATABASE_URL`: 数据库连接字符串
|
||||||
|
- `SILICONFLOW_API_KEY`: 硅基流动API密钥
|
||||||
|
- `SECRET_KEY`: JWT密钥
|
||||||
|
- `VECTOR_STORE_PATH`: 向量数据库路径
|
||||||
|
- `KNOWLEDGE_BASE_DIR`: 知识库目录
|
||||||
|
|
||||||
|
## 🔐 安全建议
|
||||||
|
|
||||||
|
1. **生产环境**:
|
||||||
|
- 修改所有默认密码和密钥
|
||||||
|
- 使用强密码
|
||||||
|
- 配置HTTPS(通过Nginx反向代理)
|
||||||
|
- 限制数据库访问
|
||||||
|
|
||||||
|
2. **数据备份**:
|
||||||
|
- 定期备份PostgreSQL数据库
|
||||||
|
- 备份向量数据库和知识库文件
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如有问题或建议,请联系开发团队。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**版本**: v1.0.0 (单容器版本)
|
||||||
|
**最后更新**: 2025年1月
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 国土空间规划课程智能体后端服务
|
||||||
|
|
||||||
|
基于FastAPI和LangGraph的智能问答系统后端服务。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: FastAPI + Uvicorn
|
||||||
|
- **AI框架**: LangChain + LangGraph
|
||||||
|
- **大模型**: 硅基流动API (Qwen3-30B)
|
||||||
|
- **向量数据库**: Chroma
|
||||||
|
- **数据库**: PostgreSQL
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 使用Docker(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API文档
|
||||||
|
|
||||||
|
启动服务后访问:http://localhost:8000/docs
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- PostgreSQL数据库初始化脚本
|
||||||
|
-- 为国土空间规划课程智能体创建扩展和配置
|
||||||
|
|
||||||
|
-- 创建扩展(如果需要)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 设置数据库编码(lc_collate 和 lc_ctype 只能在创建数据库时设置,不能通过 ALTER DATABASE 修改)
|
||||||
|
ALTER DATABASE course_agent_db SET client_encoding TO 'UTF8';
|
||||||
|
|
||||||
|
-- 创建表空间(可选)
|
||||||
|
-- CREATE TABLESPACE course_agent_space LOCATION '/var/lib/postgresql/data';
|
||||||
|
|
||||||
|
-- 设置搜索路径
|
||||||
|
ALTER DATABASE course_agent_db SET search_path TO public;
|
||||||
|
|
||||||
|
-- 创建用于监控的只读用户(可选)
|
||||||
|
-- CREATE USER monitor WITH PASSWORD 'monitor_password';
|
||||||
|
-- GRANT CONNECT ON DATABASE course_agent_db TO monitor;
|
||||||
|
-- GRANT USAGE ON SCHEMA public TO monitor;
|
||||||
|
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO monitor;
|
||||||
|
|
||||||
|
-- 设置连接限制
|
||||||
|
ALTER DATABASE course_agent_db CONNECTION LIMIT 100;
|
||||||
|
|
||||||
|
-- 记录初始化完成
|
||||||
|
COMMENT ON DATABASE course_agent_db IS '国土空间规划课程智能体数据库 - 初始化于 ' || current_timestamp;
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
"""
|
||||||
|
国土空间规划课程智能体后端服务
|
||||||
|
FastAPI应用入口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# 设置系统时区为北京时间
|
||||||
|
os.environ['TZ'] = 'Asia/Shanghai'
|
||||||
|
|
||||||
|
# 添加src目录到Python路径
|
||||||
|
sys.path.append(str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from src.core.config import get_settings, ensure_directories, is_postgresql_database
|
||||||
|
from src.core.database import create_tables, check_database_connection, get_database_info
|
||||||
|
from src.api import auth, chat
|
||||||
|
|
||||||
|
# 获取配置
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 确保必要目录存在
|
||||||
|
ensure_directories()
|
||||||
|
|
||||||
|
# 创建FastAPI应用
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version=settings.app_version,
|
||||||
|
description="基于大模型的国土空间规划课程智能体系统",
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 启动知识库文件监控服务
|
||||||
|
def startup_knowledge_base():
|
||||||
|
"""启动知识库服务"""
|
||||||
|
try:
|
||||||
|
from src.services.file_watcher_service import start_file_watcher
|
||||||
|
start_file_watcher()
|
||||||
|
print("知识库文件监控服务启动成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"启动知识库文件监控服务失败: {str(e)}")
|
||||||
|
|
||||||
|
# 应用启动事件
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""应用启动时执行"""
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("=" * 50, file=sys.stderr)
|
||||||
|
print("应用启动事件开始", file=sys.stderr)
|
||||||
|
print("=" * 50, file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
# 等待数据库可用(重试机制,处理 DNS 解析延迟)
|
||||||
|
max_retries = 10
|
||||||
|
retry_delay = 2
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
print(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries})...", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
# 尝试创建数据库表
|
||||||
|
create_tables()
|
||||||
|
print("数据库表创建成功", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
print(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}", file=sys.stderr)
|
||||||
|
print(f"等待 {retry_delay} 秒后重试...", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
print(f"数据库连接失败,已达到最大重试次数: {e}", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
# 不抛出异常,让应用继续启动,但数据库操作会失败
|
||||||
|
|
||||||
|
try:
|
||||||
|
startup_knowledge_base()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"启动知识库服务失败: {e}", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
print("应用启动事件完成", file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
# 应用关闭事件
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
"""应用关闭时执行"""
|
||||||
|
try:
|
||||||
|
from src.services.file_watcher_service import stop_file_watcher
|
||||||
|
stop_file_watcher()
|
||||||
|
print("知识库文件监控服务已停止")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"停止知识库文件监控服务失败: {str(e)}")
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(chat.router)
|
||||||
|
|
||||||
|
# 导入并注册文档API
|
||||||
|
from src.api import document
|
||||||
|
app.include_router(document.router)
|
||||||
|
|
||||||
|
# 导入并注册图像API
|
||||||
|
from src.api import image
|
||||||
|
app.include_router(image.router)
|
||||||
|
|
||||||
|
# 导入并注册分析API
|
||||||
|
from src.api import analytics
|
||||||
|
app.include_router(analytics.router)
|
||||||
|
|
||||||
|
# 导入并注册知识库API(已合并knowledge.py的功能)
|
||||||
|
from src.api import knowledge_base
|
||||||
|
app.include_router(knowledge_base.router)
|
||||||
|
|
||||||
|
# 导入并注册课程内容API
|
||||||
|
from src.api import course_content, forum
|
||||||
|
app.include_router(course_content.router)
|
||||||
|
app.include_router(forum.router)
|
||||||
|
|
||||||
|
# 静态文件服务
|
||||||
|
if os.path.exists("uploads"):
|
||||||
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|
||||||
|
# 挂载生成图像目录
|
||||||
|
if os.path.exists("generated_images"):
|
||||||
|
app.mount("/generated_images", StaticFiles(directory="generated_images"), name="generated_images")
|
||||||
|
|
||||||
|
# 根路径
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根路径"""
|
||||||
|
return {
|
||||||
|
"message": f"欢迎使用{settings.app_name}",
|
||||||
|
"version": settings.app_version,
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查端点"""
|
||||||
|
# 检查数据库连接
|
||||||
|
db_healthy = check_database_connection()
|
||||||
|
|
||||||
|
status = "healthy" if db_healthy else "unhealthy"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"version": settings.app_version,
|
||||||
|
"database": "connected" if db_healthy else "disconnected",
|
||||||
|
"services": {
|
||||||
|
"api": "running",
|
||||||
|
"database": "connected" if db_healthy else "disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 系统状态端点
|
||||||
|
@app.get("/system/status")
|
||||||
|
async def system_status():
|
||||||
|
"""系统状态端点"""
|
||||||
|
db_info = get_database_info()
|
||||||
|
|
||||||
|
# 获取系统信息
|
||||||
|
import psutil
|
||||||
|
import platform
|
||||||
|
|
||||||
|
system_info = {
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"cpu_count": psutil.cpu_count(),
|
||||||
|
"memory_total": psutil.virtual_memory().total,
|
||||||
|
"memory_available": psutil.virtual_memory().available,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查磁盘使用情况
|
||||||
|
try:
|
||||||
|
disk_usage = psutil.disk_usage("/")._asdict()
|
||||||
|
system_info["disk_usage"] = disk_usage
|
||||||
|
except:
|
||||||
|
system_info["disk_usage"] = None
|
||||||
|
|
||||||
|
# 获取服务状态
|
||||||
|
services_status = {
|
||||||
|
"api": "running",
|
||||||
|
"database": db_info.get("type", "unknown"),
|
||||||
|
"vector_store": "available" if os.path.exists(settings.vector_store_path) else "unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "operational",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"system": system_info,
|
||||||
|
"services": services_status,
|
||||||
|
"database": db_info,
|
||||||
|
"config": {
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"app_version": settings.app_version,
|
||||||
|
"debug": settings.debug,
|
||||||
|
"database_type": "postgresql" if is_postgresql_database() else "sqlite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 服务发现端点
|
||||||
|
@app.get("/services")
|
||||||
|
async def list_services():
|
||||||
|
"""列出所有服务"""
|
||||||
|
services = {
|
||||||
|
"backend": {
|
||||||
|
"name": "FastAPI后端服务",
|
||||||
|
"endpoint": f"http://localhost:{settings.port}",
|
||||||
|
"health": "/health",
|
||||||
|
"docs": "/docs"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"name": "数据库服务",
|
||||||
|
"type": "postgresql" if is_postgresql_database() else "sqlite",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"vectorizer": {
|
||||||
|
"name": "向量化服务",
|
||||||
|
"description": "独立文档向量化处理服务",
|
||||||
|
"status": "available"
|
||||||
|
},
|
||||||
|
"frontend": {
|
||||||
|
"name": "Next.js前端服务",
|
||||||
|
"endpoint": "http://localhost:8001",
|
||||||
|
"status": "external"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"services": services,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
print("=" * 50, file=sys.stderr)
|
||||||
|
print("启动 Uvicorn 服务器...", file=sys.stderr)
|
||||||
|
print(f"Host: {settings.host}, Port: {settings.port}", file=sys.stderr)
|
||||||
|
print("=" * 50, file=sys.stderr)
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
reload=settings.debug,
|
||||||
|
log_level=settings.log_level.lower(),
|
||||||
|
log_config=None # 使用默认日志配置
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "course-agent-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "国土空间规划课程智能体后端服务"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110.0",
|
||||||
|
"uvicorn[standard]>=0.27.1",
|
||||||
|
"langchain>=1.0.0",
|
||||||
|
"langchain-openai>=0.3.0",
|
||||||
|
"langchain-community>=0.3.0",
|
||||||
|
"langchain-chroma>=0.2.0",
|
||||||
|
"langchain-text-splitters>=0.3.0",
|
||||||
|
"langgraph>=0.3.5",
|
||||||
|
"chromadb>=0.4.0",
|
||||||
|
"sentence-transformers>=2.2.2",
|
||||||
|
"pypdf2>=3.0.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"python-multipart>=0.0.6",
|
||||||
|
"sqlalchemy>=2.0.0",
|
||||||
|
"psycopg[binary]>=3.1.0",
|
||||||
|
"sse-starlette>=1.6.5",
|
||||||
|
"pydantic[email]>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"jinja2>=3.1.3",
|
||||||
|
"markdownify>=1.1.0",
|
||||||
|
"watchdog>=3.0.0",
|
||||||
|
"numpy>=2.2.3",
|
||||||
|
"pandas>=2.2.3",
|
||||||
|
"zstandard>=0.25.0",
|
||||||
|
"lxml>=6.0.2",
|
||||||
|
"python-docx>=1.2.0",
|
||||||
|
"bcrypt==4.0.1",
|
||||||
|
"duckduckgo-search>=6.0.0",
|
||||||
|
"docx2txt>=0.9",
|
||||||
|
"pypdf>=6.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=1.0.0",
|
||||||
|
"pytest-cov>=6.0.0",
|
||||||
|
"black>=24.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.8.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-version = ">=0.6.15"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
indent-width = 4
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
indent-style = "space"
|
||||||
|
line-ending = "auto"
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
数据备份脚本 - 备份数据库和向量存储
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DataBackup:
|
||||||
|
"""数据备份类"""
|
||||||
|
|
||||||
|
def __init__(self, backup_dir: str = "./backups"):
|
||||||
|
self.backup_dir = Path(backup_dir)
|
||||||
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 备份配置
|
||||||
|
self.backup_items = {
|
||||||
|
"database": {
|
||||||
|
"name": "数据库",
|
||||||
|
"paths": ["course_agent.db"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"vector_store": {
|
||||||
|
"name": "向量存储",
|
||||||
|
"paths": ["vector_store"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"knowledge_base": {
|
||||||
|
"name": "知识库",
|
||||||
|
"paths": ["data/knowledge_base"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"name": "上传文件",
|
||||||
|
"paths": ["uploads"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"name": "日志文件",
|
||||||
|
"paths": ["logs"],
|
||||||
|
"enabled": False # 默认不备份日志
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_backup(self, backup_name: Optional[str] = None) -> str:
|
||||||
|
"""创建备份"""
|
||||||
|
if not backup_name:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_name = f"backup_{timestamp}"
|
||||||
|
|
||||||
|
backup_path = self.backup_dir / backup_name
|
||||||
|
backup_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"开始创建备份: {backup_name}")
|
||||||
|
|
||||||
|
backup_info = {
|
||||||
|
"name": backup_name,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 备份每个项目
|
||||||
|
for item_id, item_config in self.backup_items.items():
|
||||||
|
if not item_config["enabled"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_backup_path = backup_path / item_id
|
||||||
|
item_backup_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
item_info = self._backup_item(item_id, item_config, item_backup_path)
|
||||||
|
backup_info["items"][item_id] = item_info
|
||||||
|
|
||||||
|
if item_info["status"] == "success":
|
||||||
|
logger.info(f"✅ {item_config['name']}: {item_info['file_count']} 个文件")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ {item_config['name']}: {item_info['error']}")
|
||||||
|
|
||||||
|
# 保存备份信息
|
||||||
|
info_file = backup_path / "backup_info.json"
|
||||||
|
with open(info_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(backup_info, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 创建压缩包
|
||||||
|
tar_path = self._create_tar_archive(backup_path)
|
||||||
|
|
||||||
|
# 清理临时目录
|
||||||
|
shutil.rmtree(backup_path)
|
||||||
|
|
||||||
|
logger.info(f"✅ 备份创建完成: {tar_path}")
|
||||||
|
return str(tar_path)
|
||||||
|
|
||||||
|
def _backup_item(self, item_id: str, item_config: Dict, backup_path: Path) -> Dict:
|
||||||
|
"""备份单个项目"""
|
||||||
|
item_info = {
|
||||||
|
"name": item_config["name"],
|
||||||
|
"paths": item_config["paths"],
|
||||||
|
"status": "pending",
|
||||||
|
"file_count": 0,
|
||||||
|
"total_size": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_count = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for path_str in item_config["paths"]:
|
||||||
|
source_path = Path(path_str)
|
||||||
|
|
||||||
|
if not source_path.exists():
|
||||||
|
logger.warning(f"路径不存在: {source_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if source_path.is_file():
|
||||||
|
# 备份单个文件
|
||||||
|
shutil.copy2(source_path, backup_path / source_path.name)
|
||||||
|
file_count += 1
|
||||||
|
total_size += source_path.stat().st_size
|
||||||
|
else:
|
||||||
|
# 备份目录
|
||||||
|
for root, dirs, files in os.walk(source_path):
|
||||||
|
for file in files:
|
||||||
|
source_file = Path(root) / file
|
||||||
|
rel_path = source_file.relative_to(source_path)
|
||||||
|
target_file = backup_path / rel_path
|
||||||
|
|
||||||
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source_file, target_file)
|
||||||
|
|
||||||
|
file_count += 1
|
||||||
|
total_size += source_file.stat().st_size
|
||||||
|
|
||||||
|
item_info.update({
|
||||||
|
"status": "success",
|
||||||
|
"file_count": file_count,
|
||||||
|
"total_size": total_size
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
item_info.update({
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return item_info
|
||||||
|
|
||||||
|
def _create_tar_archive(self, backup_path: Path) -> Path:
|
||||||
|
"""创建tar.gz压缩包"""
|
||||||
|
tar_filename = f"{backup_path.name}.tar.gz"
|
||||||
|
tar_path = self.backup_dir / tar_filename
|
||||||
|
|
||||||
|
with tarfile.open(tar_path, "w:gz") as tar:
|
||||||
|
# 添加备份信息文件
|
||||||
|
info_file = backup_path / "backup_info.json"
|
||||||
|
if info_file.exists():
|
||||||
|
tar.add(info_file, arcname="backup_info.json")
|
||||||
|
|
||||||
|
# 添加其他文件
|
||||||
|
for item_dir in backup_path.iterdir():
|
||||||
|
if item_dir.is_dir():
|
||||||
|
tar.add(item_dir, arcname=item_dir.name)
|
||||||
|
|
||||||
|
return tar_path
|
||||||
|
|
||||||
|
def list_backups(self) -> List[Dict]:
|
||||||
|
"""列出所有备份"""
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
for item in self.backup_dir.iterdir():
|
||||||
|
if item.is_file() and item.suffix == ".gz":
|
||||||
|
backup_info = self._get_backup_info(item)
|
||||||
|
if backup_info:
|
||||||
|
backups.append(backup_info)
|
||||||
|
|
||||||
|
# 按创建时间排序
|
||||||
|
backups.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def _get_backup_info(self, backup_file: Path) -> Optional[Dict]:
|
||||||
|
"""从备份文件中获取信息"""
|
||||||
|
try:
|
||||||
|
with tarfile.open(backup_file, "r:gz") as tar:
|
||||||
|
# 查找备份信息文件
|
||||||
|
info_member = None
|
||||||
|
for member in tar.getmembers():
|
||||||
|
if member.name == "backup_info.json":
|
||||||
|
info_member = member
|
||||||
|
break
|
||||||
|
|
||||||
|
if info_member:
|
||||||
|
# 提取并读取信息文件
|
||||||
|
info_data = tar.extractfile(info_member)
|
||||||
|
if info_data:
|
||||||
|
info = json.load(info_data)
|
||||||
|
info["filename"] = backup_file.name
|
||||||
|
info["size"] = backup_file.stat().st_size
|
||||||
|
return info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取备份信息失败 {backup_file}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def restore_backup(self, backup_filename: str, restore_dir: str = ".") -> bool:
|
||||||
|
"""恢复备份"""
|
||||||
|
backup_file = self.backup_dir / backup_filename
|
||||||
|
|
||||||
|
if not backup_file.exists():
|
||||||
|
logger.error(f"备份文件不存在: {backup_filename}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
restore_path = Path(restore_dir)
|
||||||
|
restore_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"开始恢复备份: {backup_filename}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tarfile.open(backup_file, "r:gz") as tar:
|
||||||
|
# 提取所有文件
|
||||||
|
tar.extractall(restore_path)
|
||||||
|
logger.info(f"✅ 备份恢复完成到: {restore_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"恢复备份失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_old_backups(self, keep_days: int = 30) -> int:
|
||||||
|
"""清理旧备份"""
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=keep_days)
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
for backup_info in self.list_backups():
|
||||||
|
created_at_str = backup_info.get("created_at")
|
||||||
|
if not created_at_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
||||||
|
if created_at < cutoff_date:
|
||||||
|
backup_file = self.backup_dir / backup_info["filename"]
|
||||||
|
backup_file.unlink()
|
||||||
|
logger.info(f"删除旧备份: {backup_info['filename']}")
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析备份日期失败: {e}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="数据备份工具")
|
||||||
|
parser.add_argument("action", choices=["create", "list", "restore", "cleanup"],
|
||||||
|
help="执行的操作")
|
||||||
|
parser.add_argument("--name", help="备份名称(用于create和restore)")
|
||||||
|
parser.add_argument("--backup-dir", default="./backups", help="备份目录")
|
||||||
|
parser.add_argument("--restore-dir", default=".", help="恢复目录")
|
||||||
|
parser.add_argument("--keep-days", type=int, default=30, help="保留备份的天数")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
backup = DataBackup(args.backup_dir)
|
||||||
|
|
||||||
|
if args.action == "create":
|
||||||
|
backup_path = backup.create_backup(args.name)
|
||||||
|
print(f"备份创建完成: {backup_path}")
|
||||||
|
|
||||||
|
elif args.action == "list":
|
||||||
|
backups = backup.list_backups()
|
||||||
|
print(f"\n找到 {len(backups)} 个备份:\n")
|
||||||
|
for b in backups:
|
||||||
|
print(f"名称: {b['name']}")
|
||||||
|
print(f"文件: {b['filename']}")
|
||||||
|
print(f"大小: {b['size'] / 1024 / 1024:.2f} MB")
|
||||||
|
print(f"时间: {b['created_at']}")
|
||||||
|
print(f"项目: {', '.join(b['items'].keys())}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
elif args.action == "restore":
|
||||||
|
if not args.name:
|
||||||
|
print("错误: 恢复备份需要指定备份文件名")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success = backup.restore_backup(args.name, args.restore_dir)
|
||||||
|
if success:
|
||||||
|
print("✅ 备份恢复成功")
|
||||||
|
else:
|
||||||
|
print("❌ 备份恢复失败")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.action == "cleanup":
|
||||||
|
deleted = backup.cleanup_old_backups(args.keep_days)
|
||||||
|
print(f"清理了 {deleted} 个旧备份")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Docker环境初始化脚本
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 添加src目录到Python路径
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.core.database import SessionLocal, create_tables, check_database_connection, get_database_info
|
||||||
|
from src.core.config import get_settings, is_postgresql_database
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.knowledge_base import KnowledgeBase
|
||||||
|
from src.models.forum import ForumCategory
|
||||||
|
from src.services.auth_service import AuthService
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DockerInitializer:
|
||||||
|
"""Docker环境初始化"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.settings = get_settings()
|
||||||
|
self.session = SessionLocal()
|
||||||
|
self.auth_service = AuthService(self.session)
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
"""执行初始化"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Docker环境初始化")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 1. 检查数据库连接
|
||||||
|
if not self._check_database():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. 创建数据库表
|
||||||
|
self._create_tables()
|
||||||
|
|
||||||
|
# 3. 创建默认管理员用户
|
||||||
|
self._create_admin_user()
|
||||||
|
|
||||||
|
# 4. 创建系统知识库
|
||||||
|
self._create_system_knowledge_bases()
|
||||||
|
|
||||||
|
# 5. 创建论坛分类
|
||||||
|
self._create_forum_categories()
|
||||||
|
|
||||||
|
# 6. 检查数据目录
|
||||||
|
self._check_data_directories()
|
||||||
|
|
||||||
|
logger.info("✅ Docker环境初始化完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _check_database(self):
|
||||||
|
"""检查数据库连接"""
|
||||||
|
logger.info("检查数据库连接...")
|
||||||
|
|
||||||
|
max_retries = 10
|
||||||
|
retry_delay = 5
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
try:
|
||||||
|
if check_database_connection():
|
||||||
|
db_info = get_database_info()
|
||||||
|
logger.info(f"✅ 数据库连接成功: {db_info.get('type', 'Unknown')}")
|
||||||
|
|
||||||
|
if is_postgresql_database():
|
||||||
|
logger.info(f" 数据库: {db_info.get('database', 'Unknown')}")
|
||||||
|
logger.info(f" 版本: {db_info.get('version', 'Unknown')}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"数据库连接失败,重试 {i+1}/{max_retries}...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"数据库连接异常: {e}")
|
||||||
|
|
||||||
|
if i < max_retries - 1:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
|
||||||
|
logger.error("❌ 数据库连接失败,请检查数据库服务")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_tables(self):
|
||||||
|
"""创建数据库表"""
|
||||||
|
logger.info("创建数据库表...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_tables()
|
||||||
|
logger.info("✅ 数据库表创建完成")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 创建数据库表失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _create_admin_user(self):
|
||||||
|
"""创建默认管理员用户"""
|
||||||
|
logger.info("创建默认管理员用户...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查是否已存在管理员用户
|
||||||
|
admin_user = self.session.query(User).filter(
|
||||||
|
User.username == "admin"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if admin_user:
|
||||||
|
logger.info("管理员用户已存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建管理员用户
|
||||||
|
admin_data = {
|
||||||
|
"username": "admin",
|
||||||
|
"email": "admin@course-agent.local",
|
||||||
|
"password": "admin123", # 默认密码,首次登录后应修改
|
||||||
|
"full_name": "系统管理员",
|
||||||
|
"is_superuser": True
|
||||||
|
}
|
||||||
|
|
||||||
|
admin_user = self.auth_service.create_user(**admin_data)
|
||||||
|
|
||||||
|
if admin_user:
|
||||||
|
logger.info("✅ 管理员用户创建成功")
|
||||||
|
logger.info(f" 用户名: {admin_user.username}")
|
||||||
|
logger.info(f" 邮箱: {admin_user.email}")
|
||||||
|
logger.info("⚠️ 请尽快修改默认密码")
|
||||||
|
else:
|
||||||
|
logger.warning("管理员用户创建失败")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建管理员用户失败: {e}")
|
||||||
|
|
||||||
|
def _create_system_knowledge_bases(self):
|
||||||
|
"""创建系统知识库"""
|
||||||
|
logger.info("创建系统知识库...")
|
||||||
|
|
||||||
|
system_kbs = [
|
||||||
|
{
|
||||||
|
"name": "国土空间法律法规集",
|
||||||
|
"description": "国土空间规划相关法律法规、政策文件",
|
||||||
|
"is_system": True,
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "国土空间规划原理集",
|
||||||
|
"description": "国土空间规划理论、原理、方法",
|
||||||
|
"is_system": True,
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "国土空间规划案例集",
|
||||||
|
"description": "各级国土空间规划案例",
|
||||||
|
"is_system": True,
|
||||||
|
"is_active": True
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for kb_data in system_kbs:
|
||||||
|
try:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = self.session.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.name == kb_data["name"],
|
||||||
|
KnowledgeBase.is_system == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建知识库
|
||||||
|
kb = KnowledgeBase(
|
||||||
|
user_id=1, # 管理员用户ID
|
||||||
|
**kb_data
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session.add(kb)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建知识库失败 {kb_data['name']}: {e}")
|
||||||
|
|
||||||
|
if created_count > 0:
|
||||||
|
self.session.commit()
|
||||||
|
logger.info(f"✅ 创建了 {created_count} 个系统知识库")
|
||||||
|
else:
|
||||||
|
logger.info("系统知识库已存在")
|
||||||
|
|
||||||
|
def _create_forum_categories(self):
|
||||||
|
"""创建论坛分类"""
|
||||||
|
logger.info("创建论坛分类...")
|
||||||
|
|
||||||
|
categories = [
|
||||||
|
{
|
||||||
|
"name": "系统使用优化建议",
|
||||||
|
"description": "分享系统使用经验,提出改进建议"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "课程学习反馈",
|
||||||
|
"description": "交流学习心得,讨论课程内容"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for cat_data in categories:
|
||||||
|
try:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = self.session.query(ForumCategory).filter(
|
||||||
|
ForumCategory.name == cat_data["name"]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建分类
|
||||||
|
category = ForumCategory(**cat_data)
|
||||||
|
self.session.add(category)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建论坛分类失败 {cat_data['name']}: {e}")
|
||||||
|
|
||||||
|
if created_count > 0:
|
||||||
|
self.session.commit()
|
||||||
|
logger.info(f"✅ 创建了 {created_count} 个论坛分类")
|
||||||
|
else:
|
||||||
|
logger.info("论坛分类已存在")
|
||||||
|
|
||||||
|
def _check_data_directories(self):
|
||||||
|
"""检查数据目录"""
|
||||||
|
logger.info("检查数据目录...")
|
||||||
|
|
||||||
|
directories = [
|
||||||
|
self.settings.vector_store_path,
|
||||||
|
self.settings.upload_dir,
|
||||||
|
self.settings.knowledge_base_dir,
|
||||||
|
self.settings.generated_images_dir,
|
||||||
|
os.path.dirname(self.settings.log_file),
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir_path in directories:
|
||||||
|
try:
|
||||||
|
path = Path(dir_path)
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 检查权限
|
||||||
|
test_file = path / ".test_write"
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
|
||||||
|
logger.info(f"✅ 目录可访问: {dir_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 目录访问失败 {dir_path}: {e}")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭资源"""
|
||||||
|
if self.session:
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
initializer = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
initializer = DockerInitializer()
|
||||||
|
success = initializer.initialize()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("初始化完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\n访问信息:")
|
||||||
|
print(f" 前端: http://localhost:8001")
|
||||||
|
print(f" 后端API: http://localhost:8000")
|
||||||
|
print(f" API文档: http://localhost:8000/docs")
|
||||||
|
print("\n默认管理员账号:")
|
||||||
|
print(" 用户名: admin")
|
||||||
|
print(" 密码: admin123")
|
||||||
|
print(" ⚠️ 请尽快修改默认密码")
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
else:
|
||||||
|
print("❌ 初始化失败")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 初始化过程中发生错误: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if initializer:
|
||||||
|
initializer.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
导入书籍结构到数据库
|
||||||
|
解析LaTeX文件并导入书籍、章节、节、小节(知识点)
|
||||||
|
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加项目根目录到路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from src.core.database import SessionLocal, create_tables
|
||||||
|
from src.core.config import get_settings
|
||||||
|
from src.models.book_structure import Book, Chapter, Section, Subsection
|
||||||
|
from src.services.latex_parser import LaTeXParser
|
||||||
|
|
||||||
|
|
||||||
|
def import_book_structure():
|
||||||
|
"""导入书籍结构"""
|
||||||
|
settings = get_settings()
|
||||||
|
book_dir = Path(settings.book_dir)
|
||||||
|
|
||||||
|
# 如果相对路径不存在,尝试从项目根目录查找
|
||||||
|
if not book_dir.exists():
|
||||||
|
# 脚本位置: dofile/backend/scripts/import_book_structure.py
|
||||||
|
# 项目根目录: 向上3级
|
||||||
|
script_dir = Path(__file__).parent.parent.parent.parent # 项目根目录
|
||||||
|
book_dir = script_dir / "data" / "book"
|
||||||
|
if not book_dir.exists():
|
||||||
|
print(f"错误: 书籍目录不存在")
|
||||||
|
print(f"尝试的路径1: {settings.book_dir}")
|
||||||
|
print(f"尝试的路径2: {book_dir}")
|
||||||
|
return
|
||||||
|
print(f"使用书籍目录: {book_dir}")
|
||||||
|
|
||||||
|
# 创建数据库表
|
||||||
|
print("创建数据库表...")
|
||||||
|
create_tables()
|
||||||
|
|
||||||
|
# 创建数据库会话
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 清空旧数据(如果存在)
|
||||||
|
db.query(Subsection).delete()
|
||||||
|
db.query(Section).delete()
|
||||||
|
db.query(Chapter).delete()
|
||||||
|
db.query(Book).delete()
|
||||||
|
db.commit()
|
||||||
|
print("已清空旧的书籍结构数据。")
|
||||||
|
|
||||||
|
# 创建书籍
|
||||||
|
book_title = "国土空间规划:概念、原理、方法"
|
||||||
|
book_description = "基于LaTeX书籍内容构建的知识图谱系统"
|
||||||
|
book = Book(title=book_title, description=book_description)
|
||||||
|
db.add(book)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(book)
|
||||||
|
print(f"已创建书籍: {book.title}")
|
||||||
|
|
||||||
|
parser = LaTeXParser(book_dir)
|
||||||
|
|
||||||
|
# 解析主文件获取章节顺序
|
||||||
|
main_tex_path = book_dir / "main.tex"
|
||||||
|
if not main_tex_path.exists():
|
||||||
|
print(f"错误: main.tex 文件不存在于 {book_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
main_content = main_tex_path.read_text(encoding='utf-8')
|
||||||
|
chapter_input_re = re.compile(r'^\\input{(chapter\d+)}', re.MULTILINE)
|
||||||
|
|
||||||
|
chapter_files_in_order = []
|
||||||
|
for match in chapter_input_re.finditer(main_content):
|
||||||
|
chapter_files_in_order.append(f"{match.group(1)}.tex")
|
||||||
|
|
||||||
|
if not chapter_files_in_order:
|
||||||
|
print("未在 main.tex 中找到章节文件引用。")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_chapters = 0
|
||||||
|
total_sections = 0
|
||||||
|
total_subsections = 0
|
||||||
|
|
||||||
|
# 遍历章节文件并导入
|
||||||
|
for chapter_idx, chapter_filename in enumerate(chapter_files_in_order):
|
||||||
|
chapter_file_path = book_dir / chapter_filename
|
||||||
|
if not chapter_file_path.exists():
|
||||||
|
print(f"警告: 章节文件 {chapter_file_path} 不存在,跳过。")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"解析LaTeX文件: {chapter_file_path.name}...")
|
||||||
|
chapter_structure = parser.parse_chapter_file(chapter_file_path)
|
||||||
|
|
||||||
|
if not chapter_structure:
|
||||||
|
print(f"警告: 文件 {chapter_file_path.name} 未解析出任何结构。")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 每个文件只包含一个 \chapter
|
||||||
|
parsed_chapter_data = chapter_structure[0]
|
||||||
|
|
||||||
|
chapter_obj = Chapter(
|
||||||
|
book_id=book.id,
|
||||||
|
chapter_number=parsed_chapter_data["chapter_number"],
|
||||||
|
title=parsed_chapter_data["title"],
|
||||||
|
file_path=chapter_file_path.name,
|
||||||
|
start_line=parsed_chapter_data["start_line"],
|
||||||
|
end_line=parsed_chapter_data["end_line"],
|
||||||
|
display_order=parsed_chapter_data["chapter_number"]
|
||||||
|
)
|
||||||
|
db.add(chapter_obj)
|
||||||
|
db.flush() # Flush to get chapter_obj.id
|
||||||
|
total_chapters += 1
|
||||||
|
|
||||||
|
# 导入节(Section)
|
||||||
|
for section_data in parsed_chapter_data.get("sections", []):
|
||||||
|
section_obj = Section(
|
||||||
|
chapter_id=chapter_obj.id,
|
||||||
|
section_number=section_data["section_number"],
|
||||||
|
title=section_data["title"],
|
||||||
|
file_path=chapter_file_path.name,
|
||||||
|
start_line=section_data["start_line"],
|
||||||
|
end_line=section_data["end_line"],
|
||||||
|
display_order=section_data["section_number"]
|
||||||
|
)
|
||||||
|
db.add(section_obj)
|
||||||
|
db.flush() # Flush to get section_obj.id
|
||||||
|
total_sections += 1
|
||||||
|
|
||||||
|
# 导入小节(Subsection,作为知识点)
|
||||||
|
for subsection_data in section_data.get("subsections", []):
|
||||||
|
subsection_obj = Subsection(
|
||||||
|
section_id=section_obj.id,
|
||||||
|
subsection_number=subsection_data["subsection_number"],
|
||||||
|
title=subsection_data["title"],
|
||||||
|
file_path=chapter_file_path.name,
|
||||||
|
start_line=subsection_data["start_line"],
|
||||||
|
end_line=subsection_data["end_line"],
|
||||||
|
display_order=subsection_data["subsection_number"]
|
||||||
|
)
|
||||||
|
db.add(subsection_obj)
|
||||||
|
total_subsections += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"\n[SUCCESS] 书籍结构导入成功!")
|
||||||
|
print(f" - 书籍: {book.title}")
|
||||||
|
print(f" - 章节: {total_chapters} 个")
|
||||||
|
print(f" - 节: {total_sections} 个")
|
||||||
|
print(f" - 小节(知识点): {total_subsections} 个")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"[ERROR] 导入失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import_book_structure()
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
服务监控脚本 - 监控多服务架构的健康状态
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('service_monitor.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ServiceMonitor:
|
||||||
|
"""服务监控器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.services = {
|
||||||
|
"backend": {
|
||||||
|
"name": "FastAPI后端服务",
|
||||||
|
"url": "http://localhost:8000/health",
|
||||||
|
"timeout": 5,
|
||||||
|
"required": True
|
||||||
|
},
|
||||||
|
"frontend": {
|
||||||
|
"name": "Next.js前端服务",
|
||||||
|
"url": "http://localhost:8001",
|
||||||
|
"timeout": 5,
|
||||||
|
"required": True
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"name": "PostgreSQL数据库",
|
||||||
|
"type": "internal", # 内部服务,通过后端检查
|
||||||
|
"required": True
|
||||||
|
},
|
||||||
|
"vectorizer": {
|
||||||
|
"name": "向量化服务",
|
||||||
|
"type": "process", # 进程检查
|
||||||
|
"process_name": "vectorizer_service.py",
|
||||||
|
"required": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.check_interval = 30 # 检查间隔(秒)
|
||||||
|
self.alert_threshold = 3 # 报警阈值(连续失败次数)
|
||||||
|
self.status_history = []
|
||||||
|
self.max_history = 100
|
||||||
|
|
||||||
|
def check_service(self, service_id: str, service_config: Dict) -> Dict:
|
||||||
|
"""检查单个服务"""
|
||||||
|
service_status = {
|
||||||
|
"service_id": service_id,
|
||||||
|
"name": service_config["name"],
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"status": "unknown",
|
||||||
|
"response_time": None,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
if service_config.get("type") == "internal":
|
||||||
|
# 内部服务,通过后端检查
|
||||||
|
backend_status = self._check_backend_service(service_id)
|
||||||
|
service_status.update(backend_status)
|
||||||
|
|
||||||
|
elif service_config.get("type") == "process":
|
||||||
|
# 进程检查
|
||||||
|
process_status = self._check_process_service(service_config)
|
||||||
|
service_status.update(process_status)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# HTTP服务检查
|
||||||
|
response = requests.get(
|
||||||
|
service_config["url"],
|
||||||
|
timeout=service_config.get("timeout", 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
response_time = (time.time() - start_time) * 1000 # 毫秒
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
service_status["status"] = "healthy"
|
||||||
|
service_status["response_time"] = response_time
|
||||||
|
|
||||||
|
# 解析响应内容
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
service_status["details"] = data
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
service_status["status"] = "unhealthy"
|
||||||
|
service_status["error"] = f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
service_status["status"] = "timeout"
|
||||||
|
service_status["error"] = "请求超时"
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
service_status["status"] = "unreachable"
|
||||||
|
service_status["error"] = "连接失败"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
service_status["status"] = "error"
|
||||||
|
service_status["error"] = str(e)
|
||||||
|
|
||||||
|
return service_status
|
||||||
|
|
||||||
|
def _check_backend_service(self, service_id: str) -> Dict:
|
||||||
|
"""检查后端内部服务"""
|
||||||
|
try:
|
||||||
|
# 通过后端API检查数据库状态
|
||||||
|
response = requests.get(
|
||||||
|
"http://localhost:8000/system/status",
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if service_id == "database":
|
||||||
|
db_status = data.get("database", {})
|
||||||
|
db_type = db_status.get("type", "unknown")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if db_type != "unknown" else "unhealthy",
|
||||||
|
"details": db_status
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": "unhealthy", "error": "后端检查失败"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
def _check_process_service(self, service_config: Dict) -> Dict:
|
||||||
|
"""检查进程服务"""
|
||||||
|
process_name = service_config.get("process_name")
|
||||||
|
|
||||||
|
if not process_name:
|
||||||
|
return {"status": "unknown", "error": "未配置进程名"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查进程是否存在(Linux/Mac)
|
||||||
|
if sys.platform != "win32":
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", process_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {"status": "healthy", "details": {"processes": result.stdout.strip().split()}}
|
||||||
|
else:
|
||||||
|
return {"status": "stopped", "error": "进程未运行"}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Windows平台检查
|
||||||
|
import psutil
|
||||||
|
process_count = 0
|
||||||
|
|
||||||
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||||
|
try:
|
||||||
|
cmdline = proc.info.get('cmdline')
|
||||||
|
if cmdline and process_name in ' '.join(cmdline):
|
||||||
|
process_count += 1
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if process_count > 0:
|
||||||
|
return {"status": "healthy", "details": {"process_count": process_count}}
|
||||||
|
else:
|
||||||
|
return {"status": "stopped", "error": "进程未运行"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
def check_all_services(self) -> Dict:
|
||||||
|
"""检查所有服务"""
|
||||||
|
logger.info("开始检查所有服务...")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
overall_status = "healthy"
|
||||||
|
failed_services = []
|
||||||
|
|
||||||
|
for service_id, service_config in self.services.items():
|
||||||
|
status = self.check_service(service_id, service_config)
|
||||||
|
results[service_id] = status
|
||||||
|
|
||||||
|
if status["status"] != "healthy":
|
||||||
|
if service_config.get("required", False):
|
||||||
|
overall_status = "unhealthy"
|
||||||
|
failed_services.append(service_id)
|
||||||
|
|
||||||
|
logger.warning(f"❌ {service_config['name']}: {status['status']} - {status.get('error', '')}")
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ {service_config['name']}: 健康")
|
||||||
|
|
||||||
|
# 保存到历史记录
|
||||||
|
check_result = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"overall_status": overall_status,
|
||||||
|
"services": results,
|
||||||
|
"failed_services": failed_services
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status_history.append(check_result)
|
||||||
|
if len(self.status_history) > self.max_history:
|
||||||
|
self.status_history = self.status_history[-self.max_history:]
|
||||||
|
|
||||||
|
# 保存到文件
|
||||||
|
self._save_status_history()
|
||||||
|
|
||||||
|
logger.info(f"检查完成: 总体状态 - {overall_status}")
|
||||||
|
|
||||||
|
return check_result
|
||||||
|
|
||||||
|
def _save_status_history(self):
|
||||||
|
"""保存状态历史"""
|
||||||
|
try:
|
||||||
|
history_file = Path("service_status_history.json")
|
||||||
|
|
||||||
|
# 只保存最近24小时的数据
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||||
|
recent_history = [
|
||||||
|
h for h in self.status_history
|
||||||
|
if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")) > cutoff_time
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(history_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(recent_history, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"保存状态历史失败: {e}")
|
||||||
|
|
||||||
|
def load_status_history(self) -> List[Dict]:
|
||||||
|
"""加载状态历史"""
|
||||||
|
try:
|
||||||
|
history_file = Path("service_status_history.json")
|
||||||
|
if history_file.exists():
|
||||||
|
with open(history_file, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"加载状态历史失败: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_service_stats(self, hours: int = 24) -> Dict:
|
||||||
|
"""获取服务统计信息"""
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
relevant_history = [
|
||||||
|
h for h in self.status_history
|
||||||
|
if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")) > cutoff_time
|
||||||
|
]
|
||||||
|
|
||||||
|
if not relevant_history:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_checks": len(relevant_history),
|
||||||
|
"healthy_checks": sum(1 for h in relevant_history if h["overall_status"] == "healthy"),
|
||||||
|
"uptime_percentage": 0,
|
||||||
|
"service_stats": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats["total_checks"] > 0:
|
||||||
|
stats["uptime_percentage"] = (stats["healthy_checks"] / stats["total_checks"]) * 100
|
||||||
|
|
||||||
|
# 计算每个服务的统计
|
||||||
|
for service_id in self.services.keys():
|
||||||
|
service_checks = []
|
||||||
|
for check in relevant_history:
|
||||||
|
if service_id in check["services"]:
|
||||||
|
service_checks.append(check["services"][service_id])
|
||||||
|
|
||||||
|
if service_checks:
|
||||||
|
healthy_count = sum(1 for s in service_checks if s["status"] == "healthy")
|
||||||
|
total_count = len(service_checks)
|
||||||
|
|
||||||
|
stats["service_stats"][service_id] = {
|
||||||
|
"name": self.services[service_id]["name"],
|
||||||
|
"total_checks": total_count,
|
||||||
|
"healthy_checks": healthy_count,
|
||||||
|
"availability": (healthy_count / total_count * 100) if total_count > 0 else 0,
|
||||||
|
"last_status": service_checks[-1]["status"] if service_checks else "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def run_monitor(self, duration_minutes: Optional[int] = None):
|
||||||
|
"""运行监控器"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("服务监控器启动")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 检查是否达到运行时长限制
|
||||||
|
if duration_minutes:
|
||||||
|
elapsed_minutes = (time.time() - start_time) / 60
|
||||||
|
if elapsed_minutes >= duration_minutes:
|
||||||
|
logger.info(f"达到运行时长限制 ({duration_minutes} 分钟),停止监控")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 执行检查
|
||||||
|
self.check_all_services()
|
||||||
|
|
||||||
|
# 显示统计信息
|
||||||
|
stats = self.get_service_stats(hours=1)
|
||||||
|
if stats:
|
||||||
|
logger.info(f"最近1小时可用性: {stats.get('uptime_percentage', 0):.1f}%")
|
||||||
|
|
||||||
|
# 等待下一次检查
|
||||||
|
logger.info(f"等待 {self.check_interval} 秒...")
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("收到中断信号,停止监控")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"监控器运行异常: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info("服务监控器停止")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="服务监控工具")
|
||||||
|
parser.add_argument("action", choices=["check", "monitor", "stats"],
|
||||||
|
help="执行的操作")
|
||||||
|
parser.add_argument("--duration", type=int, default=60,
|
||||||
|
help="监控持续时间(分钟),仅用于monitor模式")
|
||||||
|
parser.add_argument("--hours", type=int, default=24,
|
||||||
|
help="统计小时数,用于stats模式")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
monitor = ServiceMonitor()
|
||||||
|
|
||||||
|
if args.action == "check":
|
||||||
|
# 单次检查
|
||||||
|
result = monitor.check_all_services()
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
elif args.action == "monitor":
|
||||||
|
# 持续监控
|
||||||
|
monitor.run_monitor(args.duration)
|
||||||
|
|
||||||
|
elif args.action == "stats":
|
||||||
|
# 显示统计信息
|
||||||
|
stats = monitor.get_service_stats(args.hours)
|
||||||
|
print(json.dumps(stats, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
向量化服务 - 独立处理文档向量化
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# 添加src目录到Python路径
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.core.database import SessionLocal, get_database_info
|
||||||
|
from src.models.document import Document
|
||||||
|
from src.services.document_service import DocumentService
|
||||||
|
from src.core.config import get_settings
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('vectorizer_service.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 减少不必要的日志
|
||||||
|
logging.getLogger("pypdf").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("PIL").setLevel(logging.ERROR)
|
||||||
|
logging.getLogger("chromadb").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
class VectorizerService:
|
||||||
|
"""向量化服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.settings = get_settings()
|
||||||
|
self.batch_size = 5 # 每次处理的文档数量
|
||||||
|
self.check_interval = 60 # 检查间隔(秒)
|
||||||
|
self.max_retries = 3 # 最大重试次数
|
||||||
|
self.retry_delay = 30 # 重试延迟(秒)
|
||||||
|
|
||||||
|
def get_unprocessed_documents(self, session):
|
||||||
|
"""获取未处理的文档"""
|
||||||
|
try:
|
||||||
|
# 查找未处理的文档
|
||||||
|
documents = session.query(Document).filter(
|
||||||
|
Document.is_processed == False,
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).order_by(Document.id).limit(self.batch_size).all()
|
||||||
|
|
||||||
|
# 过滤掉文件不存在的文档
|
||||||
|
valid_documents = []
|
||||||
|
for doc in documents:
|
||||||
|
if Path(doc.file_path).exists():
|
||||||
|
valid_documents.append(doc)
|
||||||
|
else:
|
||||||
|
logger.warning(f"文档文件不存在: {doc.file_path}")
|
||||||
|
# 标记为已处理以避免重复检查
|
||||||
|
doc.is_processed = True
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return valid_documents
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取未处理文档失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def process_document(self, session, document_service, document):
|
||||||
|
"""处理单个文档"""
|
||||||
|
logger.info(f"开始处理文档: {document.file_path} (ID: {document.id})")
|
||||||
|
|
||||||
|
for retry in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
# 使用异步处理
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 创建新的事件循环
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# 处理文档
|
||||||
|
success = loop.run_until_complete(
|
||||||
|
document_service.process_document(document.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"✅ 文档处理成功: {document.file_path}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"❌ 文档处理失败: {document.file_path}")
|
||||||
|
if retry < self.max_retries - 1:
|
||||||
|
logger.info(f"等待 {self.retry_delay} 秒后重试...")
|
||||||
|
time.sleep(self.retry_delay)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"文档处理异常 (尝试 {retry + 1}/{self.max_retries}): {e}")
|
||||||
|
if retry < self.max_retries - 1:
|
||||||
|
logger.info(f"等待 {self.retry_delay} 秒后重试...")
|
||||||
|
time.sleep(self.retry_delay)
|
||||||
|
|
||||||
|
logger.error(f"文档处理最终失败: {document.file_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""运行向量化服务"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("向量化服务启动")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 显示数据库信息
|
||||||
|
db_info = get_database_info()
|
||||||
|
logger.info(f"数据库: {db_info.get('type', 'Unknown')}")
|
||||||
|
logger.info(f"批量大小: {self.batch_size}")
|
||||||
|
logger.info(f"检查间隔: {self.check_interval}秒")
|
||||||
|
|
||||||
|
session = SessionLocal()
|
||||||
|
document_service = DocumentService(session)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 获取未处理的文档
|
||||||
|
documents = self.get_unprocessed_documents(session)
|
||||||
|
|
||||||
|
if documents:
|
||||||
|
logger.info(f"找到 {len(documents)} 个待处理文档")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for doc in documents:
|
||||||
|
if self.process_document(session, document_service, doc):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
logger.info(f"批量处理完成: {success_count}/{len(documents)} 成功")
|
||||||
|
else:
|
||||||
|
logger.info("没有待处理的文档,等待中...")
|
||||||
|
|
||||||
|
# 等待下一次检查
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("收到中断信号,停止服务")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"服务运行异常: {e}")
|
||||||
|
logger.info(f"等待 {self.check_interval} 秒后继续...")
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
logger.info("向量化服务停止")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
# 检查环境
|
||||||
|
if not os.path.exists("vectorizer_service.log"):
|
||||||
|
with open("vectorizer_service.log", "w") as f:
|
||||||
|
f.write(f"向量化服务日志 - 启动时间: {datetime.now()}\n")
|
||||||
|
|
||||||
|
# 运行服务
|
||||||
|
service = VectorizerService()
|
||||||
|
service.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 国土空间规划课程智能体后端服务
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# API路由模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
学习分析API
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.chat import ChatSession, ChatMessage
|
||||||
|
from ..models.document import Document
|
||||||
|
from ..services.analytics_service import AnalyticsService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analytics", tags=["学习分析"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class StatisticsResponse(BaseModel):
|
||||||
|
"""统计响应模型"""
|
||||||
|
total_sessions: int
|
||||||
|
total_messages: int
|
||||||
|
total_documents: int
|
||||||
|
active_days: int
|
||||||
|
user_since: Optional[str]
|
||||||
|
last_login: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class LearningReportResponse(BaseModel):
|
||||||
|
"""学习报告响应模型"""
|
||||||
|
user_id: int
|
||||||
|
total_questions: int
|
||||||
|
topics_covered: List[str]
|
||||||
|
learning_progress: float
|
||||||
|
recommendations: List[str]
|
||||||
|
study_time: int
|
||||||
|
knowledge_gaps: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformStatsResponse(BaseModel):
|
||||||
|
"""平台统计数据响应模型"""
|
||||||
|
active_users: int
|
||||||
|
knowledge_documents: int
|
||||||
|
qa_dialogues: int
|
||||||
|
generated_images: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics", response_model=StatisticsResponse)
|
||||||
|
async def get_statistics(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取学习统计信息"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
statistics = analytics_service.get_user_statistics(user.id)
|
||||||
|
|
||||||
|
return StatisticsResponse(**statistics)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取统计信息失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/learning-report", response_model=LearningReportResponse)
|
||||||
|
async def get_learning_report(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取个人学习报告"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
report = analytics_service.get_learning_report(user.id)
|
||||||
|
|
||||||
|
return LearningReportResponse(**report)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取学习报告失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trends")
|
||||||
|
async def get_learning_trends(
|
||||||
|
days: int = 30,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取学习趋势数据"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
trends = analytics_service.get_user_learning_trends(user.id, days)
|
||||||
|
|
||||||
|
return {"success": True, "data": trends}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取学习趋势失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/popular-questions")
|
||||||
|
async def get_popular_questions(
|
||||||
|
limit: int = 10,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取热门问题"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
questions = analytics_service.get_popular_questions(user.id, limit)
|
||||||
|
|
||||||
|
return {"success": True, "data": questions}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取热门问题失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-coverage")
|
||||||
|
async def get_knowledge_coverage(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取知识覆盖度"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
coverage = analytics_service.get_knowledge_coverage(user.id)
|
||||||
|
|
||||||
|
return {"success": True, "data": coverage}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取知识覆盖度失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/full-analytics")
|
||||||
|
async def get_full_analytics(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取完整的学习分析数据"""
|
||||||
|
try:
|
||||||
|
# 获取用户对象
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics_service = AnalyticsService(db)
|
||||||
|
|
||||||
|
# 获取所有统计数据
|
||||||
|
statistics = analytics_service.get_user_statistics(user.id)
|
||||||
|
trends = analytics_service.get_user_learning_trends(user.id, 30)
|
||||||
|
popular_questions = analytics_service.get_popular_questions(user.id, 5)
|
||||||
|
knowledge_coverage = analytics_service.get_knowledge_coverage(user.id)
|
||||||
|
learning_report = analytics_service.get_learning_report(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"statistics": statistics,
|
||||||
|
"learning_trends": trends,
|
||||||
|
"popular_questions": popular_questions,
|
||||||
|
"knowledge_coverage": knowledge_coverage,
|
||||||
|
"learning_report": learning_report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取完整分析数据失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/platform-stats", response_model=PlatformStatsResponse)
|
||||||
|
async def get_platform_stats(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取平台统计数据(公开接口,无需登录)"""
|
||||||
|
try:
|
||||||
|
# 活跃用户数(有聊天会话或文档的用户)
|
||||||
|
users_with_sessions = db.query(func.count(func.distinct(ChatSession.user_id))).scalar() or 0
|
||||||
|
users_with_documents = db.query(func.count(func.distinct(Document.user_id))).filter(
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).scalar() or 0
|
||||||
|
# 取两者中的较大值,或者使用总用户数
|
||||||
|
total_users = db.query(func.count(User.id)).scalar() or 0
|
||||||
|
active_users = max(users_with_sessions, users_with_documents, total_users)
|
||||||
|
|
||||||
|
# 知识文档数(知识库中的文档)
|
||||||
|
knowledge_documents = db.query(func.count(Document.id)).filter(
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 问答对话数(所有用户消息)
|
||||||
|
qa_dialogues = db.query(func.count(ChatMessage.id)).filter(
|
||||||
|
ChatMessage.role == "user"
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# 生成图像数(统计generated_images目录中的图像文件)
|
||||||
|
generated_images_dir = Path(settings.generated_images_dir)
|
||||||
|
generated_images = 0
|
||||||
|
if generated_images_dir.exists():
|
||||||
|
# 统计目录中的所有图像文件(.png, .jpg, .jpeg等)
|
||||||
|
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||||
|
generated_images = sum(
|
||||||
|
1 for file in generated_images_dir.iterdir()
|
||||||
|
if file.is_file() and file.suffix.lower() in image_extensions
|
||||||
|
)
|
||||||
|
|
||||||
|
return PlatformStatsResponse(
|
||||||
|
active_users=active_users,
|
||||||
|
knowledge_documents=knowledge_documents,
|
||||||
|
qa_dialogues=qa_dialogues,
|
||||||
|
generated_images=generated_images
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取平台统计数据失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
用户认证API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..services.auth_service import AuthService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""用户创建模型"""
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""用户登录模型"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
"""密码修改模型"""
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""用户信息更新模型"""
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""Token响应模型"""
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""用户响应模型"""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse)
|
||||||
|
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""用户注册"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
|
||||||
|
# 创建用户
|
||||||
|
user = auth_service.create_user(
|
||||||
|
username=user_data.username,
|
||||||
|
email=user_data.email,
|
||||||
|
password=user_data.password,
|
||||||
|
full_name=user_data.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="用户名或邮箱已存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
"""用户登录"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
|
||||||
|
# 验证用户
|
||||||
|
user = auth_service.authenticate_user(login_data.username, login_data.password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建访问令牌
|
||||||
|
token_data = auth_service.create_access_token_for_user(user)
|
||||||
|
|
||||||
|
return Token(
|
||||||
|
access_token=token_data["access_token"],
|
||||||
|
token_type=token_data["token_type"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_current_user_info(current_user: str = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""获取当前用户信息"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
user = auth_service.get_user_by_username(current_user)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me", response_model=UserResponse)
|
||||||
|
async def update_user_info(
|
||||||
|
user_data: UserUpdate,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新用户信息"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
|
||||||
|
# 准备更新数据
|
||||||
|
update_data = {}
|
||||||
|
if user_data.full_name is not None:
|
||||||
|
update_data["full_name"] = user_data.full_name
|
||||||
|
if user_data.email is not None:
|
||||||
|
# 检查邮箱是否已被其他用户使用
|
||||||
|
existing_user = auth_service.get_user_by_email(user_data.email)
|
||||||
|
if existing_user and existing_user.username != current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="邮箱已被其他用户使用"
|
||||||
|
)
|
||||||
|
update_data["email"] = user_data.email
|
||||||
|
|
||||||
|
# 更新用户信息
|
||||||
|
user = auth_service.update_user(
|
||||||
|
auth_service.get_user_by_username(current_user).id,
|
||||||
|
**update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="更新用户信息失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
password_data: PasswordChange,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""修改密码"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
user = auth_service.get_user_by_username(current_user)
|
||||||
|
|
||||||
|
success = auth_service.change_password(
|
||||||
|
user.id,
|
||||||
|
password_data.old_password,
|
||||||
|
password_data.new_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="原密码错误或修改失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "密码修改成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_user_stats(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户统计信息(管理员功能)"""
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
user = auth_service.get_user_by_username(current_user)
|
||||||
|
|
||||||
|
# 检查是否为管理员(这里简化处理,实际应该有更复杂的权限系统)
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="权限不足"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = auth_service.get_user_statistics()
|
||||||
|
return stats
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
"""
|
||||||
|
聊天对话API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from ..core.database import get_db, SessionLocal
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.chat import ChatSession, ChatMessage
|
||||||
|
from ..models.user import User
|
||||||
|
from ..rag.chains import create_rag_chain
|
||||||
|
from ..rag.conversation_chains import create_conversation_chain
|
||||||
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chat", tags=["聊天"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id_by_username(db: Session, username: str) -> int:
|
||||||
|
"""根据用户名获取用户ID"""
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
"""聊天请求模型"""
|
||||||
|
message: str
|
||||||
|
session_id: Optional[int] = None
|
||||||
|
mode: str = "normal" # normal, rag
|
||||||
|
knowledge_base_ids: Optional[List[int]] = None
|
||||||
|
model: Optional[str] = None # 模型ID,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
"""聊天响应模型"""
|
||||||
|
answer: str
|
||||||
|
sources: List[Dict[str, Any]]
|
||||||
|
session_id: int
|
||||||
|
message_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionResponse(BaseModel):
|
||||||
|
"""聊天会话响应模型"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
created_at: str
|
||||||
|
message_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send", response_model=ChatResponse)
|
||||||
|
async def send_message(
|
||||||
|
request: ChatRequest,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""发送消息"""
|
||||||
|
try:
|
||||||
|
# 获取或创建会话
|
||||||
|
if request.session_id:
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == request.session_id,
|
||||||
|
ChatSession.user_id == db.query(User).filter(User.username == current_user).first().id
|
||||||
|
).first()
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="会话不存在"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 创建新会话
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
session = ChatSession(
|
||||||
|
user_id=user.id,
|
||||||
|
title=request.message[:50] + "..." if len(request.message) > 50 else request.message
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
|
||||||
|
# 保存用户消息
|
||||||
|
user_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
role="user",
|
||||||
|
content=request.message
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user_message)
|
||||||
|
|
||||||
|
# 自动更新会话标题(如果是第一条消息且标题为"新对话")
|
||||||
|
message_count = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session.id
|
||||||
|
).count()
|
||||||
|
if message_count == 1 and session.title == "新对话":
|
||||||
|
# 生成标题:取消息前30个字符
|
||||||
|
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||||
|
session.title = new_title
|
||||||
|
db.commit()
|
||||||
|
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||||
|
|
||||||
|
# 根据模式运行不同的问答工作流
|
||||||
|
if request.mode == "rag":
|
||||||
|
print(f"[DEBUG-RAG] 非流式RAG模式")
|
||||||
|
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||||
|
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||||
|
if request.knowledge_base_ids:
|
||||||
|
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||||
|
|
||||||
|
# 使用LangChain 1.0 RAG链
|
||||||
|
print(f"[DEBUG-RAG] 模型: {request.model}")
|
||||||
|
rag_chain = create_rag_chain(knowledge_base_ids=request.knowledge_base_ids, model=request.model)
|
||||||
|
result = rag_chain.invoke(request.message)
|
||||||
|
else:
|
||||||
|
# 普通模式:使用LangChain 1.0对话链
|
||||||
|
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain对话链")
|
||||||
|
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||||
|
conversation_chain = create_conversation_chain(model=request.model)
|
||||||
|
|
||||||
|
# 获取聊天历史
|
||||||
|
history_messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session.id
|
||||||
|
).order_by(ChatMessage.created_at).limit(10).all()
|
||||||
|
|
||||||
|
chat_history = []
|
||||||
|
for msg in history_messages:
|
||||||
|
chat_history.append({
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content
|
||||||
|
})
|
||||||
|
|
||||||
|
result = conversation_chain.invoke(request.message, chat_history=chat_history)
|
||||||
|
|
||||||
|
# 保存助手回复
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
role="assistant",
|
||||||
|
content=result["answer"],
|
||||||
|
message_metadata=json.dumps(result["sources"], ensure_ascii=False)
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(assistant_message)
|
||||||
|
|
||||||
|
return ChatResponse(
|
||||||
|
answer=result["answer"],
|
||||||
|
sources=result["sources"],
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=assistant_message.id
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"处理消息失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream")
|
||||||
|
async def stream_message(
|
||||||
|
request: ChatRequest,
|
||||||
|
current_user: str = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""流式发送消息"""
|
||||||
|
async def generate_response():
|
||||||
|
# 在生成器内部创建新的数据库会话
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 打印请求参数调试信息
|
||||||
|
print(f"[DEBUG-CHAT] 接收流式请求 - mode: {request.mode}, knowledge_base_ids: {request.knowledge_base_ids}, model: {request.model}, message: {request.message[:50]}...")
|
||||||
|
|
||||||
|
# 获取或创建会话
|
||||||
|
if request.session_id:
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == request.session_id,
|
||||||
|
ChatSession.user_id == db.query(User).filter(User.username == current_user).first().id
|
||||||
|
).first()
|
||||||
|
if not session:
|
||||||
|
yield f"data: {json.dumps({'error': '会话不存在'})}\n\n"
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# 创建新会话
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
session = ChatSession(
|
||||||
|
user_id=user.id,
|
||||||
|
title=request.message[:50] + "..." if len(request.message) > 50 else request.message
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
|
||||||
|
# 保存用户消息
|
||||||
|
user_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
role="user",
|
||||||
|
content=request.message
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 自动更新会话标题(如果是第一条消息且标题为"新对话")
|
||||||
|
message_count = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session.id
|
||||||
|
).count()
|
||||||
|
if message_count == 1 and session.title == "新对话":
|
||||||
|
# 生成标题:取消息前30个字符
|
||||||
|
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||||
|
session.title = new_title
|
||||||
|
db.commit()
|
||||||
|
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||||
|
|
||||||
|
# 获取聊天历史
|
||||||
|
chat_history = []
|
||||||
|
previous_messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session.id
|
||||||
|
).order_by(ChatMessage.created_at).limit(10).all()
|
||||||
|
|
||||||
|
for msg in previous_messages:
|
||||||
|
chat_history.append({
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content
|
||||||
|
})
|
||||||
|
|
||||||
|
# 根据模式选择不同的处理方式
|
||||||
|
if request.mode == "rag":
|
||||||
|
print(f"[DEBUG-RAG] 使用LangChain 1.0 RAG链")
|
||||||
|
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||||
|
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||||
|
print(f"[DEBUG-RAG] 模型: {request.model}")
|
||||||
|
if request.knowledge_base_ids:
|
||||||
|
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||||
|
|
||||||
|
# 创建RAG链
|
||||||
|
rag_chain = create_rag_chain(
|
||||||
|
knowledge_base_ids=request.knowledge_base_ids,
|
||||||
|
search_type="similarity",
|
||||||
|
k=5,
|
||||||
|
model=request.model
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用带思考过程的流式输出
|
||||||
|
full_answer = ""
|
||||||
|
sources = []
|
||||||
|
thinking_steps = []
|
||||||
|
async for result in rag_chain.astream_with_sources(request.message):
|
||||||
|
if result["type"] == "thinking":
|
||||||
|
# 收集思考过程
|
||||||
|
thinking_steps.append({
|
||||||
|
"stage": result["stage"],
|
||||||
|
"message": result["message"],
|
||||||
|
"doc_count": result.get("doc_count"),
|
||||||
|
"time": result.get("time")
|
||||||
|
})
|
||||||
|
# 发送思考过程
|
||||||
|
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||||
|
elif result["type"] == "chunk":
|
||||||
|
full_answer += result["content"]
|
||||||
|
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||||
|
elif result["type"] == "sources":
|
||||||
|
sources = result["sources"]
|
||||||
|
if sources:
|
||||||
|
yield f"data: {json.dumps({'type': 'sources', 'sources': sources}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
role="assistant",
|
||||||
|
content=full_answer,
|
||||||
|
message_metadata=json.dumps({
|
||||||
|
"sources": sources,
|
||||||
|
"thinking": thinking_steps,
|
||||||
|
"mode": "rag"
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 普通模式:使用LangChain 1.0对话链
|
||||||
|
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain流式对话链")
|
||||||
|
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||||
|
conversation_chain = create_conversation_chain(model=request.model)
|
||||||
|
|
||||||
|
# 使用带思考过程的流式输出
|
||||||
|
full_answer = ""
|
||||||
|
thinking_steps = []
|
||||||
|
async for result in conversation_chain.astream_with_thinking(request.message, chat_history=chat_history):
|
||||||
|
if result["type"] == "thinking":
|
||||||
|
# 收集思考过程
|
||||||
|
thinking_steps.append({
|
||||||
|
"stage": result["stage"],
|
||||||
|
"message": result["message"],
|
||||||
|
"time": result.get("time")
|
||||||
|
})
|
||||||
|
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||||
|
elif result["type"] == "chunk":
|
||||||
|
full_answer += result["content"]
|
||||||
|
yield f"data: {json.dumps({'type': 'chunk', 'content': result['content']}, ensure_ascii=False)}\n\n"
|
||||||
|
elif result["type"] == "complete":
|
||||||
|
pass # 完成标记
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
role="assistant",
|
||||||
|
content=full_answer,
|
||||||
|
message_metadata=json.dumps({
|
||||||
|
"thinking": thinking_steps,
|
||||||
|
"mode": "normal"
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||||
|
finally:
|
||||||
|
# 确保会话在生成器结束时关闭
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_response(),
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=ChatSessionResponse)
|
||||||
|
async def create_session(
|
||||||
|
title: str = "新对话",
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""创建新会话"""
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
|
||||||
|
# 创建新会话
|
||||||
|
session = ChatSession(
|
||||||
|
user_id=user.id,
|
||||||
|
title=title,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
|
||||||
|
return ChatSessionResponse(
|
||||||
|
id=session.id,
|
||||||
|
title=session.title,
|
||||||
|
created_at=session.created_at.isoformat(),
|
||||||
|
message_count=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=List[ChatSessionResponse])
|
||||||
|
async def get_sessions(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取聊天会话列表"""
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
sessions = db.query(ChatSession).filter(
|
||||||
|
ChatSession.user_id == user.id,
|
||||||
|
ChatSession.is_active == True
|
||||||
|
).order_by(ChatSession.created_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for session in sessions:
|
||||||
|
message_count = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session.id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
result.append(ChatSessionResponse(
|
||||||
|
id=session.id,
|
||||||
|
title=session.title,
|
||||||
|
created_at=session.created_at.isoformat(),
|
||||||
|
message_count=message_count
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/messages")
|
||||||
|
async def get_session_messages(
|
||||||
|
session_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取会话消息"""
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == session_id,
|
||||||
|
ChatSession.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="会话不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session_id
|
||||||
|
).order_by(ChatMessage.created_at).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": msg.id,
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content,
|
||||||
|
"created_at": msg.created_at.isoformat(),
|
||||||
|
"thinking": metadata.get("thinking") if metadata else None,
|
||||||
|
"metadata": metadata
|
||||||
|
}
|
||||||
|
for msg in messages
|
||||||
|
for metadata in [json.loads(msg.message_metadata) if msg.message_metadata else None]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 会话管理 API
|
||||||
|
class SessionUpdateRequest(BaseModel):
|
||||||
|
"""会话更新请求模型"""
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionExportResponse(BaseModel):
|
||||||
|
"""会话导出响应模型"""
|
||||||
|
session_id: int
|
||||||
|
title: str
|
||||||
|
messages: List[Dict[str, Any]]
|
||||||
|
export_format: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sessions/{session_id}")
|
||||||
|
async def update_session(
|
||||||
|
session_id: int,
|
||||||
|
request: SessionUpdateRequest,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""重命名会话"""
|
||||||
|
# 获取会话
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == session_id,
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="会话不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新标题
|
||||||
|
session.title = request.title
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "会话重命名成功", "title": session.title}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
async def delete_session(
|
||||||
|
session_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除会话"""
|
||||||
|
# 获取会话
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == session_id,
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="会话不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除会话(级联删除消息)
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "会话删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/export", response_model=SessionExportResponse)
|
||||||
|
async def export_session(
|
||||||
|
session_id: int,
|
||||||
|
format: str = "json",
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""导出会话"""
|
||||||
|
# 获取会话
|
||||||
|
session = db.query(ChatSession).filter(
|
||||||
|
ChatSession.id == session_id,
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="会话不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取消息
|
||||||
|
messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session_id
|
||||||
|
).order_by(ChatMessage.created_at).all()
|
||||||
|
|
||||||
|
# 格式化消息
|
||||||
|
formatted_messages = []
|
||||||
|
for msg in messages:
|
||||||
|
formatted_messages.append({
|
||||||
|
"id": msg.id,
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content,
|
||||||
|
"created_at": msg.created_at.isoformat(),
|
||||||
|
"feedback": msg.feedback,
|
||||||
|
"edited": msg.edited
|
||||||
|
})
|
||||||
|
|
||||||
|
return SessionExportResponse(
|
||||||
|
session_id=session.id,
|
||||||
|
title=session.title or f"会话 {session.id}",
|
||||||
|
messages=formatted_messages,
|
||||||
|
export_format=format,
|
||||||
|
created_at=session.created_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 消息操作 API
|
||||||
|
class MessageEditRequest(BaseModel):
|
||||||
|
"""消息编辑请求模型"""
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFeedbackRequest(BaseModel):
|
||||||
|
"""消息反馈请求模型"""
|
||||||
|
feedback: str # like, dislike
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/messages/{message_id}")
|
||||||
|
async def edit_message(
|
||||||
|
message_id: int,
|
||||||
|
request: MessageEditRequest,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""编辑用户消息"""
|
||||||
|
# 获取消息
|
||||||
|
message = db.query(ChatMessage).join(ChatSession).filter(
|
||||||
|
ChatMessage.id == message_id,
|
||||||
|
ChatMessage.role == "user",
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="消息不存在或无权编辑"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新消息内容
|
||||||
|
message.content = request.content
|
||||||
|
message.edited = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "消息编辑成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/{message_id}/regenerate")
|
||||||
|
async def regenerate_message(
|
||||||
|
message_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""重新生成AI回复"""
|
||||||
|
# 获取原始消息
|
||||||
|
original_message = db.query(ChatMessage).join(ChatSession).filter(
|
||||||
|
ChatMessage.id == message_id,
|
||||||
|
ChatMessage.role == "user",
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not original_message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="消息不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除该消息之后的所有消息
|
||||||
|
later_messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == original_message.session_id,
|
||||||
|
ChatMessage.created_at > original_message.created_at
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for msg in later_messages:
|
||||||
|
db.delete(msg)
|
||||||
|
|
||||||
|
# 重新生成回复
|
||||||
|
try:
|
||||||
|
# 使用RAG工作流重新生成
|
||||||
|
response_data = await run_rag_workflow_with_context(
|
||||||
|
original_message.content,
|
||||||
|
original_message.session_id,
|
||||||
|
db,
|
||||||
|
None # No knowledge base filtering for regeneration
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建新的AI回复
|
||||||
|
new_message = ChatMessage(
|
||||||
|
session_id=original_message.session_id,
|
||||||
|
role="assistant",
|
||||||
|
content=response_data["answer"],
|
||||||
|
message_metadata=json.dumps({
|
||||||
|
"sources": response_data["sources"],
|
||||||
|
"metadata": response_data.get("metadata", {})
|
||||||
|
}),
|
||||||
|
regenerated_from=message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "消息重新生成成功",
|
||||||
|
"new_message_id": new_message.id,
|
||||||
|
"content": new_message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"重新生成失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/{message_id}/feedback")
|
||||||
|
async def feedback_message(
|
||||||
|
message_id: int,
|
||||||
|
request: MessageFeedbackRequest,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""给消息点赞/踩"""
|
||||||
|
# 获取消息
|
||||||
|
message = db.query(ChatMessage).join(ChatSession).filter(
|
||||||
|
ChatMessage.id == message_id,
|
||||||
|
ChatMessage.role == "assistant",
|
||||||
|
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="消息不存在或无权操作"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新反馈
|
||||||
|
message.feedback = request.feedback
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "反馈提交成功", "feedback": request.feedback}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_rag_workflow_with_context(question: str, session_id: int, db: Session, knowledge_base_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||||
|
"""运行带上下文的RAG工作流"""
|
||||||
|
try:
|
||||||
|
# 获取会话历史消息作为上下文
|
||||||
|
session_messages = db.query(ChatMessage).filter(
|
||||||
|
ChatMessage.session_id == session_id
|
||||||
|
).order_by(ChatMessage.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
# 构建上下文
|
||||||
|
context_messages = []
|
||||||
|
for msg in reversed(session_messages):
|
||||||
|
if msg.role == "user":
|
||||||
|
context_messages.append({"role": "user", "content": msg.content})
|
||||||
|
elif msg.role == "assistant":
|
||||||
|
context_messages.append({"role": "assistant", "content": msg.content})
|
||||||
|
|
||||||
|
# 运行RAG工作流
|
||||||
|
rag_chain = create_rag_chain(knowledge_base_ids=knowledge_base_ids)
|
||||||
|
result = rag_chain.invoke(question)
|
||||||
|
|
||||||
|
# 如果有上下文,增强回答
|
||||||
|
if context_messages:
|
||||||
|
# 这里可以添加基于上下文的增强逻辑
|
||||||
|
# 例如:检查是否与之前的问题相关,提供更个性化的回答
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"RAG工作流执行失败: {str(e)}")
|
||||||
|
# 降级到基础问答
|
||||||
|
rag_chain = create_rag_chain()
|
||||||
|
return rag_chain.invoke(question)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
课程内容API
|
||||||
|
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..models.course_content import CourseModule
|
||||||
|
from ..models.book_structure import Book, Chapter, Section, Subsection
|
||||||
|
from ..services.book_content_service import BookContentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/course-content", tags=["课程内容"])
|
||||||
|
|
||||||
|
# 初始化书籍内容服务
|
||||||
|
settings = get_settings()
|
||||||
|
book_content_service = BookContentService(settings.book_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# 旧的响应模型(向后兼容)
|
||||||
|
class CourseModuleResponse(BaseModel):
|
||||||
|
"""课程模块响应模型"""
|
||||||
|
id: int
|
||||||
|
module_name: str
|
||||||
|
core_knowledge_points: str
|
||||||
|
digitalization_necessity: str
|
||||||
|
expanded_knowledge_points: List[str]
|
||||||
|
display_order: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# 新的书籍结构响应模型
|
||||||
|
class SubsectionResponse(BaseModel):
|
||||||
|
"""小节响应模型(知识点)"""
|
||||||
|
id: int
|
||||||
|
subsection_number: int
|
||||||
|
title: str
|
||||||
|
display_order: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SectionResponse(BaseModel):
|
||||||
|
"""节响应模型"""
|
||||||
|
id: int
|
||||||
|
section_number: int
|
||||||
|
title: str
|
||||||
|
display_order: int
|
||||||
|
subsections: List[SubsectionResponse] # 小节(知识点)列表
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterResponse(BaseModel):
|
||||||
|
"""章节响应模型"""
|
||||||
|
id: int
|
||||||
|
chapter_number: int
|
||||||
|
title: str
|
||||||
|
display_order: int
|
||||||
|
sections: List[SectionResponse] # 节列表
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BookStructureResponse(BaseModel):
|
||||||
|
"""书籍结构响应模型"""
|
||||||
|
book: dict
|
||||||
|
chapters: List[ChapterResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ContentResponse(BaseModel):
|
||||||
|
"""内容响应模型"""
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=BookStructureResponse)
|
||||||
|
async def get_course_content(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取书籍层级结构(章节、节、小节/知识点)"""
|
||||||
|
try:
|
||||||
|
# 获取书籍(假设只有一个书籍)
|
||||||
|
book = db.query(Book).first()
|
||||||
|
if not book:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="未找到书籍数据,请先运行导入脚本"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取所有章节及其子节点
|
||||||
|
chapters = db.query(Chapter).filter(
|
||||||
|
Chapter.book_id == book.id
|
||||||
|
).order_by(Chapter.display_order.asc()).all()
|
||||||
|
|
||||||
|
chapter_responses = []
|
||||||
|
for chapter in chapters:
|
||||||
|
# 获取章节下的节(Section)
|
||||||
|
sections = db.query(Section).filter(
|
||||||
|
Section.chapter_id == chapter.id
|
||||||
|
).order_by(Section.display_order.asc()).all()
|
||||||
|
|
||||||
|
section_responses = []
|
||||||
|
for section in sections:
|
||||||
|
# 获取节下的小节(Subsection,作为知识点)
|
||||||
|
subsections = db.query(Subsection).filter(
|
||||||
|
Subsection.section_id == section.id
|
||||||
|
).order_by(Subsection.display_order.asc()).all()
|
||||||
|
|
||||||
|
subsection_responses = [
|
||||||
|
SubsectionResponse(
|
||||||
|
id=sub.id,
|
||||||
|
subsection_number=sub.subsection_number,
|
||||||
|
title=sub.title,
|
||||||
|
display_order=sub.display_order
|
||||||
|
)
|
||||||
|
for sub in subsections
|
||||||
|
]
|
||||||
|
|
||||||
|
section_responses.append(SectionResponse(
|
||||||
|
id=section.id,
|
||||||
|
section_number=section.section_number,
|
||||||
|
title=section.title,
|
||||||
|
display_order=section.display_order,
|
||||||
|
subsections=subsection_responses
|
||||||
|
))
|
||||||
|
|
||||||
|
chapter_responses.append(ChapterResponse(
|
||||||
|
id=chapter.id,
|
||||||
|
chapter_number=chapter.chapter_number,
|
||||||
|
title=chapter.title,
|
||||||
|
display_order=chapter.display_order,
|
||||||
|
sections=section_responses
|
||||||
|
))
|
||||||
|
|
||||||
|
return BookStructureResponse(
|
||||||
|
book={
|
||||||
|
"id": book.id,
|
||||||
|
"title": book.title,
|
||||||
|
"description": book.description
|
||||||
|
},
|
||||||
|
chapters=chapter_responses
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取课程内容失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chapters/{chapter_id}/content", response_model=ContentResponse)
|
||||||
|
async def get_chapter_content(
|
||||||
|
chapter_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取章节完整内容"""
|
||||||
|
try:
|
||||||
|
content = book_content_service.get_chapter_content(db, chapter_id)
|
||||||
|
if content is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="章节不存在或内容读取失败"
|
||||||
|
)
|
||||||
|
return ContentResponse(content=content)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取章节内容失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sections/{section_id}/content", response_model=ContentResponse)
|
||||||
|
async def get_section_content(
|
||||||
|
section_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取节完整内容"""
|
||||||
|
try:
|
||||||
|
content = book_content_service.get_section_content(db, section_id)
|
||||||
|
if content is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="节不存在或内容读取失败"
|
||||||
|
)
|
||||||
|
return ContentResponse(content=content)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取节内容失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subsections/{subsection_id}/content", response_model=ContentResponse)
|
||||||
|
async def get_subsection_content(
|
||||||
|
subsection_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取小节完整内容(知识点,包含其下所有subsubsection内容)"""
|
||||||
|
try:
|
||||||
|
content = book_content_service.get_subsection_content(db, subsection_id)
|
||||||
|
if content is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="小节不存在或内容读取失败"
|
||||||
|
)
|
||||||
|
return ContentResponse(content=content)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取小节内容失败: {str(e)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
文档管理API
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.document import Document, DocumentChunk
|
||||||
|
from ..services.document_service import DocumentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/documents", tags=["文档管理"])
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentResponse(BaseModel):
|
||||||
|
"""文档响应模型"""
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
title: str
|
||||||
|
file_size: int
|
||||||
|
file_type: str
|
||||||
|
is_processed: bool
|
||||||
|
is_public: bool
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUploadResponse(BaseModel):
|
||||||
|
"""文档上传响应模型"""
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStats(BaseModel):
|
||||||
|
"""文档统计模型"""
|
||||||
|
total_documents: int
|
||||||
|
processed_documents: int
|
||||||
|
total_size: int
|
||||||
|
file_types: dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=DocumentUploadResponse, deprecated=True)
|
||||||
|
async def upload_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""上传文档(已废弃,请使用知识库上传接口)"""
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_410_GONE,
|
||||||
|
detail="此接口已废弃,请使用知识库上传接口:POST /knowledge-bases/{knowledge_base_id}/documents"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[DocumentResponse])
|
||||||
|
async def get_documents(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取文档列表"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户的文档
|
||||||
|
documents = db.query(Document).filter(
|
||||||
|
Document.user_id == user.id
|
||||||
|
).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DocumentResponse(
|
||||||
|
id=doc.id,
|
||||||
|
filename=doc.filename,
|
||||||
|
title=doc.title,
|
||||||
|
file_size=doc.file_size,
|
||||||
|
file_type=doc.file_type,
|
||||||
|
is_processed=doc.is_processed,
|
||||||
|
is_public=doc.is_public,
|
||||||
|
created_at=doc.created_at.isoformat()
|
||||||
|
)
|
||||||
|
for doc in documents
|
||||||
|
]
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取文档列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||||
|
async def get_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取单个文档信息"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取文档
|
||||||
|
document = db.query(Document).filter(
|
||||||
|
Document.id == document_id,
|
||||||
|
Document.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文档不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DocumentResponse(
|
||||||
|
id=document.id,
|
||||||
|
filename=document.filename,
|
||||||
|
title=document.title,
|
||||||
|
file_size=document.file_size,
|
||||||
|
file_type=document.file_type,
|
||||||
|
is_processed=document.is_processed,
|
||||||
|
is_public=document.is_public,
|
||||||
|
created_at=document.created_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取文档信息失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{document_id}")
|
||||||
|
async def delete_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除文档"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取文档
|
||||||
|
document = db.query(Document).filter(
|
||||||
|
Document.id == document_id,
|
||||||
|
Document.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文档不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 先删除向量数据和文档块
|
||||||
|
try:
|
||||||
|
print(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||||
|
if vector_deleted:
|
||||||
|
print(f"成功删除文档向量数据: {document.filename}")
|
||||||
|
else:
|
||||||
|
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除向量数据时发生错误: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
print(f"详细错误信息: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
# 2. 删除物理文件
|
||||||
|
try:
|
||||||
|
if os.path.exists(document.file_path):
|
||||||
|
os.remove(document.file_path)
|
||||||
|
print(f"成功删除物理文件: {document.file_path}")
|
||||||
|
else:
|
||||||
|
print(f"物理文件不存在: {document.file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除物理文件时发生错误: {str(e)}")
|
||||||
|
|
||||||
|
# 3. 删除数据库记录
|
||||||
|
db.delete(document)
|
||||||
|
db.commit()
|
||||||
|
print(f"成功删除文档数据库记录: {document.filename}")
|
||||||
|
|
||||||
|
return {"message": "文档删除成功"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"删除文档失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/process")
|
||||||
|
async def process_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""处理文档(向量化)"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取文档
|
||||||
|
document = db.query(Document).filter(
|
||||||
|
Document.id == document_id,
|
||||||
|
Document.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文档不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
if document.is_processed:
|
||||||
|
return {"message": "文档已经处理过了"}
|
||||||
|
|
||||||
|
# 处理文档
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
success = await asyncio.to_thread(document_service.process_document, document.id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return {"message": "文档处理成功"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="文档处理失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"处理文档失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/overview", response_model=DocumentStats)
|
||||||
|
async def get_document_stats(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取文档统计信息"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
from ..models.user import User
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
total_documents = db.query(Document).filter(Document.user_id == user.id).count()
|
||||||
|
processed_documents = db.query(Document).filter(
|
||||||
|
Document.user_id == user.id,
|
||||||
|
Document.is_processed == True
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 计算总大小
|
||||||
|
documents = db.query(Document).filter(Document.user_id == user.id).all()
|
||||||
|
total_size = sum(doc.file_size for doc in documents)
|
||||||
|
|
||||||
|
# 文件类型统计
|
||||||
|
file_types = {}
|
||||||
|
for doc in documents:
|
||||||
|
file_type = doc.file_type
|
||||||
|
file_types[file_type] = file_types.get(file_type, 0) + 1
|
||||||
|
|
||||||
|
return DocumentStats(
|
||||||
|
total_documents=total_documents,
|
||||||
|
processed_documents=processed_documents,
|
||||||
|
total_size=total_size,
|
||||||
|
file_types=file_types
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取统计信息失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
论坛功能相关 API
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.forum import ForumCategory, ForumPost, ForumReply
|
||||||
|
from ..models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/forum", tags=["论坛"])
|
||||||
|
|
||||||
|
|
||||||
|
class ForumCategoryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
post_count: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ForumPostSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
author_name: str
|
||||||
|
created_at: str
|
||||||
|
reply_count: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ForumReplyResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
content: str
|
||||||
|
author_name: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ForumPostDetail(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
author_name: str
|
||||||
|
created_at: str
|
||||||
|
replies: List[ForumReplyResponse]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
ForumPostDetail.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePostRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReplyRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=List[ForumCategoryResponse])
|
||||||
|
def list_categories(db: Session = Depends(get_db)):
|
||||||
|
categories = db.query(ForumCategory).all()
|
||||||
|
results: List[ForumCategoryResponse] = []
|
||||||
|
for category in categories:
|
||||||
|
post_count = db.query(ForumPost).filter(ForumPost.category_id == category.id).count()
|
||||||
|
results.append(
|
||||||
|
ForumCategoryResponse(
|
||||||
|
id=category.id,
|
||||||
|
slug=category.slug,
|
||||||
|
name=category.name,
|
||||||
|
description=category.description,
|
||||||
|
post_count=post_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories/{category_id}/posts", response_model=List[ForumPostSummary])
|
||||||
|
def list_posts(category_id: int, db: Session = Depends(get_db), limit: int = 20):
|
||||||
|
posts = (
|
||||||
|
db.query(ForumPost)
|
||||||
|
.filter(ForumPost.category_id == category_id)
|
||||||
|
.order_by(ForumPost.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
results: List[ForumPostSummary] = []
|
||||||
|
for post in posts:
|
||||||
|
reply_count = db.query(ForumReply).filter(ForumReply.post_id == post.id).count()
|
||||||
|
author_name = post.author.full_name or post.author.username if post.author else "匿名"
|
||||||
|
results.append(
|
||||||
|
ForumPostSummary(
|
||||||
|
id=post.id,
|
||||||
|
title=post.title,
|
||||||
|
author_name=author_name,
|
||||||
|
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||||
|
reply_count=reply_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/categories/{category_id}/posts",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
response_model=ForumPostDetail,
|
||||||
|
)
|
||||||
|
def create_post(
|
||||||
|
category_id: int,
|
||||||
|
payload: CreatePostRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
|
||||||
|
|
||||||
|
author = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not author:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||||
|
|
||||||
|
post = ForumPost(
|
||||||
|
category_id=category.id,
|
||||||
|
user_id=author.id,
|
||||||
|
title=payload.title.strip(),
|
||||||
|
content=payload.content.strip(),
|
||||||
|
)
|
||||||
|
db.add(post)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
|
||||||
|
return ForumPostDetail(
|
||||||
|
id=post.id,
|
||||||
|
title=post.title,
|
||||||
|
content=post.content,
|
||||||
|
author_name=author.full_name or author.username,
|
||||||
|
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||||
|
replies=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}", response_model=ForumPostDetail)
|
||||||
|
def get_post_detail(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
post = (
|
||||||
|
db.query(ForumPost)
|
||||||
|
.filter(ForumPost.id == post_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="帖子不存在")
|
||||||
|
|
||||||
|
replies = (
|
||||||
|
db.query(ForumReply)
|
||||||
|
.filter(ForumReply.post_id == post.id)
|
||||||
|
.order_by(ForumReply.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ForumPostDetail(
|
||||||
|
id=post.id,
|
||||||
|
title=post.title,
|
||||||
|
content=post.content,
|
||||||
|
author_name=post.author.full_name or post.author.username if post.author else "匿名",
|
||||||
|
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||||
|
replies=[
|
||||||
|
ForumReplyResponse(
|
||||||
|
id=reply.id,
|
||||||
|
content=reply.content,
|
||||||
|
author_name=reply.author.full_name or reply.author.username if reply.author else "匿名",
|
||||||
|
created_at=reply.created_at.isoformat() if reply.created_at else "",
|
||||||
|
)
|
||||||
|
for reply in replies
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/posts/{post_id}/replies",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
response_model=ForumReplyResponse,
|
||||||
|
)
|
||||||
|
def create_reply(
|
||||||
|
post_id: int,
|
||||||
|
payload: CreateReplyRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
post = db.query(ForumPost).filter(ForumPost.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="帖子不存在")
|
||||||
|
|
||||||
|
author = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not author:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||||
|
|
||||||
|
reply = ForumReply(
|
||||||
|
post_id=post.id,
|
||||||
|
user_id=author.id,
|
||||||
|
content=payload.content.strip(),
|
||||||
|
)
|
||||||
|
db.add(reply)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(reply)
|
||||||
|
|
||||||
|
return ForumReplyResponse(
|
||||||
|
id=reply.id,
|
||||||
|
content=reply.content,
|
||||||
|
author_name=author.full_name or author.username,
|
||||||
|
created_at=reply.created_at.isoformat() if reply.created_at else "",
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
图像生成和分析API
|
||||||
|
支持文生图和图生图功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.generated_image import GeneratedImageRecord
|
||||||
|
from ..models.image_models import (
|
||||||
|
TextToImageRequest, TextToImageResponse, GeneratedImage,
|
||||||
|
ImageEditRequest, ImageEditResponse, ImageEditResult,
|
||||||
|
ImageVariationRequest, ImageVariationResponse, ImageVariationResult,
|
||||||
|
ModelInfo, TemplateInfo, StyleInfo, SizeInfo, EditModeInfo
|
||||||
|
)
|
||||||
|
from ..services.text_to_image_service import TextToImageService
|
||||||
|
from ..services.image_to_image_service import ImageToImageService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/image", tags=["图像生成"])
|
||||||
|
|
||||||
|
# 服务实例
|
||||||
|
text_to_image_service = TextToImageService()
|
||||||
|
image_to_image_service = ImageToImageService()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_record(db: Session, user_id: int, image_id: str, image_type: str,
|
||||||
|
image_url: str, prompt: str = None, model: str = None,
|
||||||
|
style: str = None, size: str = None, template: str = None,
|
||||||
|
original_filename: str = None, mode: str = None,
|
||||||
|
strength: float = None, metadata: dict = None):
|
||||||
|
"""保存图像生成记录到数据库"""
|
||||||
|
record = GeneratedImageRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
image_id=image_id,
|
||||||
|
image_type=image_type,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
style=style,
|
||||||
|
size=size,
|
||||||
|
template=template,
|
||||||
|
image_url=image_url,
|
||||||
|
original_filename=original_filename,
|
||||||
|
mode=mode,
|
||||||
|
strength=str(strength) if strength else None,
|
||||||
|
generation_meta=json.dumps(metadata or {}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/text-to-image", response_model=TextToImageResponse)
|
||||||
|
async def text_to_image(
|
||||||
|
request: TextToImageRequest,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""文生图 - 从文字描述生成图像"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
images_data = await text_to_image_service.generate(
|
||||||
|
prompt=request.prompt, model=request.model, template=request.template,
|
||||||
|
style=request.style, size=request.size, num_images=request.num_images
|
||||||
|
)
|
||||||
|
|
||||||
|
images = [
|
||||||
|
GeneratedImage(id=img["id"], url=img["url"], prompt=img["prompt"],
|
||||||
|
model=img["model"], metadata=img["metadata"])
|
||||||
|
for img in images_data
|
||||||
|
]
|
||||||
|
|
||||||
|
for img in images_data:
|
||||||
|
_save_record(db, user.id, img["id"], "text_to_image", img["url"],
|
||||||
|
prompt=request.prompt, model=request.model,
|
||||||
|
style=request.style, size=request.size,
|
||||||
|
template=request.template, metadata=img.get("metadata"))
|
||||||
|
|
||||||
|
return TextToImageResponse(images=images, total=len(images))
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文生图失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image-to-image/edit", response_model=ImageEditResponse)
|
||||||
|
async def image_edit(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
prompt: str = Form(...),
|
||||||
|
mode: str = Form("optimize"),
|
||||||
|
mask: Optional[UploadFile] = File(None),
|
||||||
|
strength: float = Form(0.8),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""图生图 - 图像编辑"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
result_data = await image_to_image_service.edit_image(
|
||||||
|
image_file=image, prompt=prompt, mode=mode, mask_file=mask, strength=strength
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ImageEditResult(
|
||||||
|
id=result_data["id"], url=result_data["url"],
|
||||||
|
original_filename=result_data["original_filename"],
|
||||||
|
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||||
|
metadata=result_data["metadata"]
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_record(db, user.id, result_data["id"], "image_edit", result_data["url"],
|
||||||
|
prompt=prompt, model="Qwen-Image-Edit",
|
||||||
|
original_filename=result_data.get("original_filename"),
|
||||||
|
mode=mode, strength=strength, metadata=result_data.get("metadata"))
|
||||||
|
|
||||||
|
return ImageEditResponse(result=result)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像编辑失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image-to-image/variations", response_model=ImageVariationResponse)
|
||||||
|
async def image_variations(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
num_variations: int = Form(3),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""图生图 - 生成图像变体"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
variations_data = await image_to_image_service.create_variation(
|
||||||
|
image_file=image, num_variations=num_variations
|
||||||
|
)
|
||||||
|
|
||||||
|
variations = [
|
||||||
|
ImageVariationResult(id=var["id"], url=var["url"], type=var["type"], metadata=var["metadata"])
|
||||||
|
for var in variations_data
|
||||||
|
]
|
||||||
|
|
||||||
|
for var in variations_data:
|
||||||
|
_save_record(db, user.id, var["id"], "variation", var["url"],
|
||||||
|
model="Qwen-Image-Edit", metadata=var.get("metadata"))
|
||||||
|
|
||||||
|
return ImageVariationResponse(variations=variations, total=len(variations))
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"生成图像变体失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image-to-image/style-transfer", response_model=ImageEditResponse)
|
||||||
|
async def style_transfer(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
style_prompt: str = Form(...),
|
||||||
|
strength: float = Form(0.8),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""图生图 - 风格转换"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
result_data = await image_to_image_service.style_transfer(
|
||||||
|
image_file=image, style_prompt=style_prompt, strength=strength
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ImageEditResult(
|
||||||
|
id=result_data["id"], url=result_data["url"],
|
||||||
|
original_filename=result_data["original_filename"],
|
||||||
|
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||||
|
metadata=result_data["metadata"]
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_record(db, user.id, result_data["id"], "style_transfer", result_data["url"],
|
||||||
|
prompt=style_prompt, model="Qwen-Image-Edit",
|
||||||
|
original_filename=result_data.get("original_filename"),
|
||||||
|
mode="style_transfer", strength=strength, metadata=result_data.get("metadata"))
|
||||||
|
|
||||||
|
return ImageEditResponse(result=result)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"风格转换失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image-to-image/optimize", response_model=ImageEditResponse)
|
||||||
|
async def optimize_image(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
optimization_prompt: str = Form("优化图像质量,增强细节,提高清晰度"),
|
||||||
|
strength: float = Form(0.6),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""图生图 - 图像优化"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
result_data = await image_to_image_service.optimize_image(
|
||||||
|
image_file=image, optimization_prompt=optimization_prompt, strength=strength
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ImageEditResult(
|
||||||
|
id=result_data["id"], url=result_data["url"],
|
||||||
|
original_filename=result_data["original_filename"],
|
||||||
|
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||||
|
metadata=result_data["metadata"]
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_record(db, user.id, result_data["id"], "optimize", result_data["url"],
|
||||||
|
prompt=optimization_prompt, model="Qwen-Image-Edit",
|
||||||
|
original_filename=result_data.get("original_filename"),
|
||||||
|
mode="optimize", strength=strength, metadata=result_data.get("metadata"))
|
||||||
|
|
||||||
|
return ImageEditResponse(result=result)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像优化失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image-to-image/outpaint", response_model=ImageEditResponse)
|
||||||
|
async def outpaint_image(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
expansion_prompt: str = Form(...),
|
||||||
|
strength: float = Form(0.7),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""图生图 - 图像智能扩展"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
result_data = await image_to_image_service.outpaint_image(
|
||||||
|
image_file=image, expansion_prompt=expansion_prompt, strength=strength
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ImageEditResult(
|
||||||
|
id=result_data["id"], url=result_data["url"],
|
||||||
|
original_filename=result_data["original_filename"],
|
||||||
|
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||||
|
metadata=result_data["metadata"]
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_record(db, user.id, result_data["id"], "outpaint", result_data["url"],
|
||||||
|
prompt=expansion_prompt, model="Qwen-Image-Edit",
|
||||||
|
original_filename=result_data.get("original_filename"),
|
||||||
|
mode="outpaint", strength=strength, metadata=result_data.get("metadata"))
|
||||||
|
|
||||||
|
return ImageEditResponse(result=result)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像扩展失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# 配置信息端点
|
||||||
|
@router.get("/models")
|
||||||
|
async def get_available_models():
|
||||||
|
"""获取可用的模型列表"""
|
||||||
|
models_data = text_to_image_service.get_available_models()
|
||||||
|
return {
|
||||||
|
"models": [
|
||||||
|
ModelInfo(id=model["id"], name=model["name"], description=model["description"])
|
||||||
|
for model in models_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
async def get_available_templates():
|
||||||
|
"""获取可用的提示词模板"""
|
||||||
|
templates_data = text_to_image_service.get_available_templates()
|
||||||
|
return {
|
||||||
|
"templates": [
|
||||||
|
TemplateInfo(id=template["id"], name=template["name"], description=template["description"])
|
||||||
|
for template in templates_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/styles")
|
||||||
|
async def get_available_styles():
|
||||||
|
"""获取可用的风格选项"""
|
||||||
|
styles_data = text_to_image_service.get_available_styles()
|
||||||
|
return {
|
||||||
|
"styles": [
|
||||||
|
StyleInfo(id=style["id"], name=style["name"], description=style["description"])
|
||||||
|
for style in styles_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sizes")
|
||||||
|
async def get_available_sizes():
|
||||||
|
"""获取可用的尺寸选项"""
|
||||||
|
sizes_data = text_to_image_service.get_available_sizes()
|
||||||
|
return {
|
||||||
|
"sizes": [
|
||||||
|
SizeInfo(id=size["id"], name=size["name"], description=size["description"])
|
||||||
|
for size in sizes_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/edit-modes")
|
||||||
|
async def get_edit_modes():
|
||||||
|
"""获取可用的编辑模式"""
|
||||||
|
modes_data = image_to_image_service.get_available_modes()
|
||||||
|
return {
|
||||||
|
"modes": [
|
||||||
|
EditModeInfo(id=mode["id"], name=mode["name"], description=mode["description"])
|
||||||
|
for mode in modes_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/style-presets")
|
||||||
|
async def get_style_presets():
|
||||||
|
"""获取预设风格选项"""
|
||||||
|
presets_data = image_to_image_service.get_style_presets()
|
||||||
|
return {
|
||||||
|
"presets": [
|
||||||
|
StyleInfo(id=preset["id"], name=preset["name"], description=preset["description"])
|
||||||
|
for preset in presets_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_image_history(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户的图像生成历史"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
total = db.query(GeneratedImageRecord).filter(GeneratedImageRecord.user_id == user.id).count()
|
||||||
|
records = (
|
||||||
|
db.query(GeneratedImageRecord)
|
||||||
|
.filter(GeneratedImageRecord.user_id == user.id)
|
||||||
|
.order_by(GeneratedImageRecord.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for r in records:
|
||||||
|
img = {
|
||||||
|
"id": r.image_id,
|
||||||
|
"url": r.image_url,
|
||||||
|
"image_type": r.image_type,
|
||||||
|
"prompt": r.prompt,
|
||||||
|
"model": r.model,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
if r.style:
|
||||||
|
img["style"] = r.style
|
||||||
|
if r.size:
|
||||||
|
img["size"] = r.size
|
||||||
|
if r.template:
|
||||||
|
img["template"] = r.template
|
||||||
|
if r.mode:
|
||||||
|
img["mode"] = r.mode
|
||||||
|
if r.original_filename:
|
||||||
|
img["original_filename"] = r.original_filename
|
||||||
|
images.append(img)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"images": images,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"has_next": page * page_size < total,
|
||||||
|
"has_prev": page > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取图像历史失败: {str(e)}")
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
"""
|
||||||
|
知识库CRUD API
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..core.security import get_current_user
|
||||||
|
from ..core.config import settings
|
||||||
|
from ..models.knowledge_base import KnowledgeBase
|
||||||
|
from ..models.document import Document
|
||||||
|
from ..models.user import User
|
||||||
|
from ..services.document_service import DocumentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/knowledge-bases", tags=["知识库管理"])
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseResponse(BaseModel):
|
||||||
|
"""知识库响应模型"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
user_id: int
|
||||||
|
document_count: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
is_system: bool
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCreate(BaseModel):
|
||||||
|
"""创建知识库请求模型"""
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseUpdate(BaseModel):
|
||||||
|
"""更新知识库请求模型"""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseDetailResponse(KnowledgeBaseResponse):
|
||||||
|
"""知识库详情响应模型"""
|
||||||
|
documents: List[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentUploadResponse(BaseModel):
|
||||||
|
"""文档上传响应模型"""
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[KnowledgeBaseResponse])
|
||||||
|
async def get_knowledge_bases(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户的所有知识库"""
|
||||||
|
print(f"DEBUG: get_knowledge_bases called for user: {current_user}")
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户的知识库:包括用户自己的知识库 + 所有系统知识库
|
||||||
|
knowledge_bases = db.query(KnowledgeBase).filter(
|
||||||
|
or_(
|
||||||
|
KnowledgeBase.user_id == user.id, # 用户自己的知识库
|
||||||
|
KnowledgeBase.is_system == True # 所有系统知识库(所有用户可见)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for kb in knowledge_bases:
|
||||||
|
# 计算文档数量
|
||||||
|
document_count = db.query(Document).filter(
|
||||||
|
Document.knowledge_base_id == kb.id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
result.append(KnowledgeBaseResponse(
|
||||||
|
id=kb.id,
|
||||||
|
name=kb.name,
|
||||||
|
description=kb.description,
|
||||||
|
user_id=kb.user_id,
|
||||||
|
document_count=document_count,
|
||||||
|
created_at=kb.created_at.isoformat(),
|
||||||
|
updated_at=kb.updated_at.isoformat() if kb.updated_at else kb.created_at.isoformat(),
|
||||||
|
is_system=kb.is_system
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取知识库列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=KnowledgeBaseResponse)
|
||||||
|
async def create_knowledge_base(
|
||||||
|
data: KnowledgeBaseCreate,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""创建新知识库"""
|
||||||
|
print(f"DEBUG: create_knowledge_base called for user: {current_user}, data: {data}")
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查知识库名称是否已存在
|
||||||
|
existing_kb = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.name == data.name,
|
||||||
|
KnowledgeBase.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_kb:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="知识库名称已存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建知识库
|
||||||
|
knowledge_base = KnowledgeBase(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
user_id=user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(knowledge_base)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(knowledge_base)
|
||||||
|
|
||||||
|
return KnowledgeBaseResponse(
|
||||||
|
id=knowledge_base.id,
|
||||||
|
name=knowledge_base.name,
|
||||||
|
description=knowledge_base.description,
|
||||||
|
user_id=knowledge_base.user_id,
|
||||||
|
document_count=0,
|
||||||
|
created_at=knowledge_base.created_at.isoformat(),
|
||||||
|
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||||
|
is_system=knowledge_base.is_system
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"创建知识库失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{knowledge_base_id}", response_model=KnowledgeBaseDetailResponse)
|
||||||
|
async def get_knowledge_base(
|
||||||
|
knowledge_base_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取知识库详情"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取知识库:用户自己的知识库或系统知识库(所有用户可查看)
|
||||||
|
knowledge_base = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == knowledge_base_id,
|
||||||
|
or_(
|
||||||
|
KnowledgeBase.user_id == user.id, # 用户自己的知识库
|
||||||
|
KnowledgeBase.is_system == True # 系统知识库(所有用户可见)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not knowledge_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="知识库不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取知识库的文档
|
||||||
|
documents = db.query(Document).filter(
|
||||||
|
Document.knowledge_base_id == knowledge_base_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
document_list = []
|
||||||
|
for doc in documents:
|
||||||
|
document_list.append({
|
||||||
|
"id": doc.id,
|
||||||
|
"filename": doc.filename,
|
||||||
|
"title": doc.title,
|
||||||
|
"file_size": doc.file_size,
|
||||||
|
"file_type": doc.file_type,
|
||||||
|
"is_processed": doc.is_processed,
|
||||||
|
"is_public": doc.is_public,
|
||||||
|
"knowledge_base_id": doc.knowledge_base_id,
|
||||||
|
"created_at": doc.created_at.isoformat(),
|
||||||
|
"updated_at": doc.updated_at.isoformat() if doc.updated_at else doc.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return KnowledgeBaseDetailResponse(
|
||||||
|
id=knowledge_base.id,
|
||||||
|
name=knowledge_base.name,
|
||||||
|
description=knowledge_base.description,
|
||||||
|
user_id=knowledge_base.user_id,
|
||||||
|
document_count=len(documents),
|
||||||
|
created_at=knowledge_base.created_at.isoformat(),
|
||||||
|
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||||
|
is_system=knowledge_base.is_system,
|
||||||
|
documents=document_list
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取知识库详情失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{knowledge_base_id}", response_model=KnowledgeBaseResponse)
|
||||||
|
async def update_knowledge_base(
|
||||||
|
knowledge_base_id: int,
|
||||||
|
data: KnowledgeBaseUpdate,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新知识库"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取知识库
|
||||||
|
knowledge_base = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == knowledge_base_id,
|
||||||
|
KnowledgeBase.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not knowledge_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="知识库不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
|
if data.name is not None:
|
||||||
|
# 检查新名称是否已存在
|
||||||
|
existing_kb = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.name == data.name,
|
||||||
|
KnowledgeBase.user_id == user.id,
|
||||||
|
KnowledgeBase.id != knowledge_base_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_kb:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="知识库名称已存在"
|
||||||
|
)
|
||||||
|
knowledge_base.name = data.name
|
||||||
|
|
||||||
|
if data.description is not None:
|
||||||
|
knowledge_base.description = data.description
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(knowledge_base)
|
||||||
|
|
||||||
|
# 计算文档数量
|
||||||
|
document_count = db.query(Document).filter(
|
||||||
|
Document.knowledge_base_id == knowledge_base_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return KnowledgeBaseResponse(
|
||||||
|
id=knowledge_base.id,
|
||||||
|
name=knowledge_base.name,
|
||||||
|
description=knowledge_base.description,
|
||||||
|
user_id=knowledge_base.user_id,
|
||||||
|
document_count=document_count,
|
||||||
|
created_at=knowledge_base.created_at.isoformat(),
|
||||||
|
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||||
|
is_system=knowledge_base.is_system
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"更新知识库失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{knowledge_base_id}")
|
||||||
|
async def delete_knowledge_base(
|
||||||
|
knowledge_base_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除知识库"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取知识库
|
||||||
|
knowledge_base = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == knowledge_base_id,
|
||||||
|
KnowledgeBase.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not knowledge_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="知识库不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 获取知识库下的所有文档
|
||||||
|
documents = db.query(Document).filter(
|
||||||
|
Document.knowledge_base_id == knowledge_base_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
print(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
||||||
|
|
||||||
|
# 2. 逐个删除文档的向量数据和物理文件
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
success_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for document in documents:
|
||||||
|
try:
|
||||||
|
# 删除向量数据
|
||||||
|
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||||
|
if vector_deleted:
|
||||||
|
print(f"成功删除文档向量数据: {document.filename}")
|
||||||
|
else:
|
||||||
|
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
# 删除物理文件
|
||||||
|
if os.path.exists(document.file_path):
|
||||||
|
os.remove(document.file_path)
|
||||||
|
print(f"成功删除物理文件: {document.file_path}")
|
||||||
|
else:
|
||||||
|
print(f"物理文件不存在: {document.file_path}")
|
||||||
|
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除文档 {document.filename} 的资源时出错: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
print(f"详细错误信息: {traceback.format_exc()}")
|
||||||
|
error_count += 1
|
||||||
|
# 继续处理其他文档
|
||||||
|
|
||||||
|
print(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||||
|
|
||||||
|
# 3. 删除知识库(级联删除文档记录)
|
||||||
|
db.delete(knowledge_base)
|
||||||
|
db.commit()
|
||||||
|
print(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
||||||
|
|
||||||
|
return {"message": "知识库删除成功"}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"删除知识库失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{knowledge_base_id}/documents", response_model=DocumentUploadResponse)
|
||||||
|
async def upload_document_to_knowledge_base(
|
||||||
|
knowledge_base_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""上传文档到知识库"""
|
||||||
|
try:
|
||||||
|
# 检查文件类型
|
||||||
|
allowed_extensions = [".pdf", ".docx", ".txt", ".md"]
|
||||||
|
file_extension = Path(file.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_extension not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"不支持的文件类型。支持的类型: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查文件大小
|
||||||
|
file_size = 0
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
max_size = 10 * 1024 * 1024 # 10MB
|
||||||
|
if file_size > max_size:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="文件大小超过限制(10MB)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证知识库存在
|
||||||
|
knowledge_base = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == knowledge_base_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not knowledge_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="知识库不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否为系统知识库,系统知识库不允许任何用户上传文档
|
||||||
|
if knowledge_base.is_system:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="系统知识库不允许上传文档,请使用您自己的知识库"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证知识库属于当前用户(非系统知识库必须属于用户)
|
||||||
|
if knowledge_base.user_id != user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="您没有权限向此知识库上传文档"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
filename = f"{file_id}{file_extension}"
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
upload_dir = Path(settings.upload_dir)
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = upload_dir / filename
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 创建文档记录
|
||||||
|
document = Document(
|
||||||
|
user_id=user.id,
|
||||||
|
knowledge_base_id=knowledge_base_id,
|
||||||
|
filename=filename,
|
||||||
|
original_filename=file.filename,
|
||||||
|
file_path=str(file_path),
|
||||||
|
file_size=file_size,
|
||||||
|
file_type=file_extension,
|
||||||
|
title=title or Path(file.filename).stem,
|
||||||
|
description=description,
|
||||||
|
is_processed=False
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(document)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
|
||||||
|
# 自动处理文档向量化
|
||||||
|
try:
|
||||||
|
print(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
success = await document_service.process_document(document.id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"文档向量化处理成功: {document.filename}")
|
||||||
|
message = "文档上传并处理成功"
|
||||||
|
else:
|
||||||
|
print(f"文档向量化处理失败: {document.filename}")
|
||||||
|
message = "文档上传成功,但向量化处理失败"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
print(f"详细错误信息: {traceback.format_exc()}")
|
||||||
|
message = "文档上传成功,但向量化处理失败"
|
||||||
|
|
||||||
|
return DocumentUploadResponse(
|
||||||
|
id=document.id,
|
||||||
|
filename=document.filename,
|
||||||
|
title=document.title,
|
||||||
|
message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"文档上传失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{knowledge_base_id}/documents", response_model=List[dict])
|
||||||
|
async def get_knowledge_base_documents(
|
||||||
|
knowledge_base_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取知识库的文档列表"""
|
||||||
|
try:
|
||||||
|
# 获取用户ID
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证知识库存在且属于用户
|
||||||
|
knowledge_base = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == knowledge_base_id,
|
||||||
|
KnowledgeBase.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not knowledge_base:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="知识库不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取知识库的文档
|
||||||
|
documents = db.query(Document).filter(
|
||||||
|
Document.knowledge_base_id == knowledge_base_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for doc in documents:
|
||||||
|
result.append({
|
||||||
|
"id": doc.id,
|
||||||
|
"filename": doc.filename,
|
||||||
|
"title": doc.title,
|
||||||
|
"file_size": doc.file_size,
|
||||||
|
"file_type": doc.file_type,
|
||||||
|
"is_processed": doc.is_processed,
|
||||||
|
"is_public": doc.is_public,
|
||||||
|
"knowledge_base_id": doc.knowledge_base_id,
|
||||||
|
"created_at": doc.created_at.isoformat(),
|
||||||
|
"updated_at": doc.updated_at.isoformat() if doc.updated_at else doc.created_at.isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取文档列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 知识库管理操作API(从knowledge.py合并) ==========
|
||||||
|
|
||||||
|
from ..services.knowledge_base_service import KnowledgeBaseService
|
||||||
|
from ..services.file_watcher_service import get_file_watcher_service
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResponse(BaseModel):
|
||||||
|
"""扫描响应模型"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
results: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
"""状态响应模型"""
|
||||||
|
success: bool
|
||||||
|
status: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexResponse(BaseModel):
|
||||||
|
"""重新索引响应模型"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan", response_model=ScanResponse)
|
||||||
|
async def scan_knowledge_base(
|
||||||
|
directory: Optional[str] = None,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""手动触发知识库扫描和入库"""
|
||||||
|
try:
|
||||||
|
kb_service = KnowledgeBaseService(db)
|
||||||
|
results = kb_service.scan_directory(directory)
|
||||||
|
|
||||||
|
if results["success"]:
|
||||||
|
return ScanResponse(
|
||||||
|
success=True,
|
||||||
|
message="知识库扫描完成",
|
||||||
|
results=results
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ScanResponse(
|
||||||
|
success=False,
|
||||||
|
message="知识库扫描失败",
|
||||||
|
results=results
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"扫描知识库失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=StatusResponse)
|
||||||
|
async def get_knowledge_base_status(
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取知识库状态"""
|
||||||
|
try:
|
||||||
|
kb_service = KnowledgeBaseService(db)
|
||||||
|
watcher_service = get_file_watcher_service()
|
||||||
|
|
||||||
|
# 获取知识库状态
|
||||||
|
kb_status = kb_service.get_knowledge_base_status()
|
||||||
|
|
||||||
|
# 获取文件监控状态
|
||||||
|
watcher_status = watcher_service.get_status()
|
||||||
|
|
||||||
|
# 合并状态信息
|
||||||
|
status_info = {
|
||||||
|
"knowledge_base": kb_status,
|
||||||
|
"file_watcher": watcher_status
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusResponse(
|
||||||
|
success=True,
|
||||||
|
status=status_info
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取知识库状态失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/{document_id}/reindex", response_model=ReindexResponse)
|
||||||
|
async def reindex_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""重新索引指定文档"""
|
||||||
|
try:
|
||||||
|
kb_service = KnowledgeBaseService(db)
|
||||||
|
result = kb_service.reindex_document(document_id)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return ReindexResponse(
|
||||||
|
success=True,
|
||||||
|
message=result["message"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ReindexResponse(
|
||||||
|
success=False,
|
||||||
|
message=result["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"重新索引文档失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/documents/{document_id}", response_model=ReindexResponse)
|
||||||
|
async def delete_knowledge_base_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""删除知识库文档"""
|
||||||
|
try:
|
||||||
|
kb_service = KnowledgeBaseService(db)
|
||||||
|
result = kb_service.delete_document(document_id)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return ReindexResponse(
|
||||||
|
success=True,
|
||||||
|
message=result["message"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ReindexResponse(
|
||||||
|
success=False,
|
||||||
|
message=result["message"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"删除文档失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/watcher/start", response_model=ReindexResponse)
|
||||||
|
async def start_file_watcher(
|
||||||
|
current_user: str = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""启动文件监控服务"""
|
||||||
|
try:
|
||||||
|
watcher_service = get_file_watcher_service()
|
||||||
|
watcher_service.start()
|
||||||
|
|
||||||
|
if watcher_service.is_active():
|
||||||
|
return ReindexResponse(
|
||||||
|
success=True,
|
||||||
|
message="文件监控服务启动成功"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ReindexResponse(
|
||||||
|
success=False,
|
||||||
|
message="文件监控服务启动失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"启动文件监控服务失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/watcher/stop", response_model=ReindexResponse)
|
||||||
|
async def stop_file_watcher(
|
||||||
|
current_user: str = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""停止文件监控服务"""
|
||||||
|
try:
|
||||||
|
watcher_service = get_file_watcher_service()
|
||||||
|
watcher_service.stop()
|
||||||
|
|
||||||
|
return ReindexResponse(
|
||||||
|
success=True,
|
||||||
|
message="文件监控服务停止成功"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"停止文件监控服务失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/watcher/status", response_model=StatusResponse)
|
||||||
|
async def get_watcher_status(
|
||||||
|
current_user: str = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""获取文件监控服务状态"""
|
||||||
|
try:
|
||||||
|
watcher_service = get_file_watcher_service()
|
||||||
|
status_info = watcher_service.get_status()
|
||||||
|
|
||||||
|
return StatusResponse(
|
||||||
|
success=True,
|
||||||
|
status=status_info
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"获取文件监控状态失败: {str(e)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 核心配置模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
应用配置管理
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import validator, field_validator, Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
|
||||||
|
# 应用基础配置
|
||||||
|
app_name: str = "国土空间规划课程智能体"
|
||||||
|
app_version: str = "0.1.0"
|
||||||
|
debug: bool = True
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
# 硅基流动API配置
|
||||||
|
siliconflow_api_key: str
|
||||||
|
siliconflow_base_url: str = "https://api.siliconflow.cn/v1"
|
||||||
|
siliconflow_model: str = "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
database_url: str = "sqlite:///../data/database/course_agent.db"
|
||||||
|
|
||||||
|
# JWT配置
|
||||||
|
secret_key: str
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 30
|
||||||
|
|
||||||
|
# 向量数据库配置
|
||||||
|
vector_store_path: str = os.getenv("VECTOR_STORE_PATH", "./vector_store")
|
||||||
|
embedding_model: str = "shibing624/text2vec-base-chinese"
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
allowed_origins: List[str] = [
|
||||||
|
"http://localhost:8001",
|
||||||
|
"http://127.0.0.1:8001"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
upload_dir: str = os.getenv("UPLOAD_DIR", "./uploads")
|
||||||
|
max_file_size: int = 10485760 # 10MB
|
||||||
|
allowed_extensions: List[str] = [".pdf", ".docx", ".txt", ".md"]
|
||||||
|
|
||||||
|
# 知识库配置
|
||||||
|
knowledge_base_dir: str = os.getenv("KNOWLEDGE_BASE_DIR", "./data/knowledge_base")
|
||||||
|
enable_file_watcher: bool = True
|
||||||
|
|
||||||
|
# 书籍配置
|
||||||
|
book_dir: str = os.getenv("BOOK_DIR", "./data/book") # LaTeX书籍目录
|
||||||
|
|
||||||
|
# 图像生成配置
|
||||||
|
generated_images_dir: str = os.getenv("GENERATED_IMAGES_DIR", "./generated_images")
|
||||||
|
image_generation_timeout: int = 120 # 图像生成超时时间(秒)
|
||||||
|
max_image_size: int = 10485760 # 最大图像文件大小(10MB)
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
log_file: str = os.getenv("LOG_FILE", "./logs/app.log")
|
||||||
|
|
||||||
|
# Hugging Face镜像配置
|
||||||
|
hf_endpoint: Optional[str] = "https://hf-mirror.com"
|
||||||
|
|
||||||
|
@field_validator("allowed_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_cors_origins(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
# 移除可能的引号和空格
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
if not v:
|
||||||
|
return []
|
||||||
|
# 按逗号分割并清理
|
||||||
|
origins = [origin.strip().strip('"').strip("'") for origin in v.split(",") if origin.strip()]
|
||||||
|
return origins
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
return []
|
||||||
|
|
||||||
|
@field_validator("allowed_extensions", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_extensions(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
# 尝试解析 JSON 格式
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
parsed = json.loads(v)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
# 如果不是 JSON,按逗号分割
|
||||||
|
return [ext.strip() for ext in v.split(",")]
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("database_url", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_database_url(cls, v):
|
||||||
|
"""验证数据库URL,支持SQLite和PostgreSQL"""
|
||||||
|
if not v:
|
||||||
|
# 默认使用SQLite
|
||||||
|
return "sqlite:///./course_agent.db"
|
||||||
|
|
||||||
|
# 如果是PostgreSQL URL,确保包含必要的组件
|
||||||
|
if v.startswith("postgresql://") or v.startswith("postgresql+psycopg://"):
|
||||||
|
# 检查是否包含必要的认证信息
|
||||||
|
if "@" not in v:
|
||||||
|
raise ValueError("PostgreSQL URL必须包含认证信息")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "..", ".env")
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = False
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
# 全局配置实例
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""获取配置实例"""
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_directories():
|
||||||
|
"""确保必要的目录存在"""
|
||||||
|
directories = [
|
||||||
|
settings.vector_store_path,
|
||||||
|
settings.upload_dir,
|
||||||
|
settings.knowledge_base_dir,
|
||||||
|
settings.generated_images_dir,
|
||||||
|
os.path.dirname(settings.log_file),
|
||||||
|
]
|
||||||
|
|
||||||
|
for directory in directories:
|
||||||
|
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def is_postgresql_database() -> bool:
|
||||||
|
"""检查是否使用PostgreSQL数据库"""
|
||||||
|
db_url = get_settings().database_url
|
||||||
|
return db_url.startswith("postgresql://") or db_url.startswith("postgresql+psycopg://")
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_type() -> str:
|
||||||
|
"""获取数据库类型"""
|
||||||
|
db_url = get_settings().database_url
|
||||||
|
if "sqlite" in db_url:
|
||||||
|
return "sqlite"
|
||||||
|
elif "postgresql" in db_url:
|
||||||
|
return "postgresql"
|
||||||
|
else:
|
||||||
|
return "unknown"
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
数据库连接和会话管理
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine, MetaData, text
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from typing import Generator
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .config import get_settings, is_postgresql_database
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 创建数据库引擎
|
||||||
|
def create_database_engine():
|
||||||
|
"""创建数据库引擎,根据数据库类型配置不同的参数(带重试机制)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
db_url = settings.database_url
|
||||||
|
|
||||||
|
# 重试机制:等待 DNS 解析就绪
|
||||||
|
max_retries = 10
|
||||||
|
retry_delay = 2
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
if is_postgresql_database():
|
||||||
|
# PostgreSQL配置
|
||||||
|
engine = create_engine(
|
||||||
|
db_url,
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=30,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
echo=settings.debug,
|
||||||
|
echo_pool=settings.debug,
|
||||||
|
connect_args={"connect_timeout": 10} # 连接超时
|
||||||
|
)
|
||||||
|
# 测试连接
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
logger.info("使用PostgreSQL数据库引擎")
|
||||||
|
else:
|
||||||
|
# SQLite配置
|
||||||
|
engine = create_engine(
|
||||||
|
db_url,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
echo=settings.debug
|
||||||
|
)
|
||||||
|
logger.info("使用SQLite数据库引擎")
|
||||||
|
|
||||||
|
return engine
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning(f"数据库引擎创建失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||||
|
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
logger.error(f"数据库引擎创建失败,已达到最大重试次数: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 延迟创建引擎(避免在导入时就连接数据库,等待 DNS 解析就绪)
|
||||||
|
_engine = None
|
||||||
|
_SessionLocal = None
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
"""获取数据库引擎(懒加载)"""
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_database_engine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
def get_session_local():
|
||||||
|
"""获取会话工厂(懒加载)"""
|
||||||
|
global _SessionLocal
|
||||||
|
if _SessionLocal is None:
|
||||||
|
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
|
||||||
|
return _SessionLocal
|
||||||
|
|
||||||
|
# 为了向后兼容,提供这些变量(延迟初始化)
|
||||||
|
# 注意:这些变量在首次访问时会触发数据库连接
|
||||||
|
# 使用类来模拟属性访问,延迟到实际使用时才连接
|
||||||
|
class _LazyEngine:
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(get_engine(), name)
|
||||||
|
def connect(self, *args, **kwargs):
|
||||||
|
return get_engine().connect(*args, **kwargs)
|
||||||
|
def execute(self, *args, **kwargs):
|
||||||
|
return get_engine().execute(*args, **kwargs)
|
||||||
|
|
||||||
|
class _LazySessionLocal:
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
return get_session_local()(*args, **kwargs)
|
||||||
|
|
||||||
|
# 为了向后兼容,提供这些变量(延迟初始化)
|
||||||
|
engine = _LazyEngine()
|
||||||
|
SessionLocal = _LazySessionLocal()
|
||||||
|
|
||||||
|
# 创建基础模型类
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
"""获取数据库会话"""
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_tables():
|
||||||
|
"""创建数据库表(带重试机制,处理 DNS 解析延迟)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 导入所有模型以确保它们被注册到 Base.metadata
|
||||||
|
from ..models import User, ChatSession, ChatMessage, Document, DocumentChunk, KnowledgeBase, CourseModule
|
||||||
|
from ..models.book_structure import Book, Chapter, Section, Subsection
|
||||||
|
from ..models.forum import ForumCategory, ForumPost, ForumReply
|
||||||
|
|
||||||
|
# 重试机制:等待 DNS 解析和数据库就绪
|
||||||
|
max_retries = 10
|
||||||
|
retry_delay = 2
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
# 创建所有表
|
||||||
|
engine = get_engine()
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("数据库表创建完成")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||||
|
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
else:
|
||||||
|
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def check_database_connection():
|
||||||
|
"""检查数据库连接"""
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as conn:
|
||||||
|
if is_postgresql_database():
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
logger.info("PostgreSQL数据库连接正常")
|
||||||
|
else:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
logger.info("SQLite数据库连接正常")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"数据库连接失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_info():
|
||||||
|
"""获取数据库信息"""
|
||||||
|
db_type = "PostgreSQL" if is_postgresql_database() else "SQLite"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as conn:
|
||||||
|
if is_postgresql_database():
|
||||||
|
# PostgreSQL信息
|
||||||
|
result = conn.execute(text("SELECT version()"))
|
||||||
|
version = result.scalar()
|
||||||
|
result = conn.execute(text("SELECT current_database()"))
|
||||||
|
db_name = result.scalar()
|
||||||
|
return {
|
||||||
|
"type": db_type,
|
||||||
|
"version": version,
|
||||||
|
"database": db_name,
|
||||||
|
"url": settings.database_url.split("@")[-1] if "@" in settings.database_url else settings.database_url
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# SQLite信息
|
||||||
|
return {
|
||||||
|
"type": db_type,
|
||||||
|
"version": "SQLite",
|
||||||
|
"database": settings.database_url.split("/")[-1],
|
||||||
|
"url": settings.database_url
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取数据库信息失败: {e}")
|
||||||
|
return {
|
||||||
|
"type": db_type,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vacuum_database():
|
||||||
|
"""清理数据库(仅SQLite)"""
|
||||||
|
if not is_postgresql_database():
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as conn:
|
||||||
|
conn.execute(text("VACUUM"))
|
||||||
|
logger.info("SQLite数据库清理完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"数据库清理失败: {e}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.info("PostgreSQL不需要VACUUM操作")
|
||||||
|
return True
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
安全相关功能
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Union
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import HTTPException, status, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 密码加密上下文
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# JWT Bearer认证
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""Token响应模型"""
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
"""Token数据模型"""
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""验证密码"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""生成密码哈希"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""创建访问令牌"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> Optional[str]:
|
||||||
|
"""验证令牌并返回用户名"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
return None
|
||||||
|
return username
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
|
"""获取当前用户"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = credentials.credentials
|
||||||
|
username = verify_token(token)
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return username
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# LangGraph工作流模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
LangGraph节点定义
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any, List, TypedDict
|
||||||
|
from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
||||||
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
from ..rag.retrievers import KnowledgeBaseRetriever
|
||||||
|
from ..rag.vector_store import get_vector_store
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_from_response(text: str) -> dict:
|
||||||
|
"""从LLM响应中提取JSON,兼容markdown代码块包裹的情况"""
|
||||||
|
# 尝试提取 ```json ... ``` 中的内容
|
||||||
|
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
||||||
|
if match:
|
||||||
|
text = match.group(1).strip()
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
class GraphState(TypedDict):
|
||||||
|
"""图状态定义"""
|
||||||
|
messages: List[Dict[str, str]]
|
||||||
|
current_question: str
|
||||||
|
retrieved_docs: List[Dict[str, Any]]
|
||||||
|
answer: str
|
||||||
|
sources: List[Dict[str, Any]]
|
||||||
|
metadata: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_question_node(state: GraphState) -> GraphState:
|
||||||
|
"""问题分析节点"""
|
||||||
|
try:
|
||||||
|
llm = get_llm_client()
|
||||||
|
|
||||||
|
# 构建分析提示
|
||||||
|
system_prompt = """你是一个专业的国土空间规划知识问答助手。请分析用户的问题,确定问题的类型和需要检索的知识领域。
|
||||||
|
|
||||||
|
问题类型包括:
|
||||||
|
1. 概念解释类:询问基本概念、定义
|
||||||
|
2. 方法技术类:询问规划方法、技术手段
|
||||||
|
3. 案例分析类:询问具体案例、实践应用
|
||||||
|
4. 政策法规类:询问相关政策、法规条文
|
||||||
|
5. 技术标准类:询问技术标准、规范要求
|
||||||
|
|
||||||
|
请以JSON格式返回分析结果:
|
||||||
|
{
|
||||||
|
"question_type": "问题类型",
|
||||||
|
"keywords": ["关键词1", "关键词2"],
|
||||||
|
"domain": "知识领域",
|
||||||
|
"intent": "用户意图"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
SystemMessage(content=system_prompt),
|
||||||
|
HumanMessage(content=f"请分析这个问题:{state['current_question']}")
|
||||||
|
]
|
||||||
|
|
||||||
|
response = llm.chat(messages)
|
||||||
|
|
||||||
|
# 解析LLM返回的JSON
|
||||||
|
analysis = _parse_json_from_response(response)
|
||||||
|
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||||
|
"question_type": analysis.get("question_type", "通用"),
|
||||||
|
"keywords": analysis.get("keywords", []),
|
||||||
|
"domain": analysis.get("domain", "国土空间规划"),
|
||||||
|
"intent": analysis.get("intent", "获取知识")
|
||||||
|
}}
|
||||||
|
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"问题分析失败: {str(e)}")
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||||
|
"question_type": "通用",
|
||||||
|
"keywords": [],
|
||||||
|
"domain": "国土空间规划",
|
||||||
|
"intent": "获取知识"
|
||||||
|
}}
|
||||||
|
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_knowledge_node(state: GraphState) -> GraphState:
|
||||||
|
"""知识检索节点"""
|
||||||
|
try:
|
||||||
|
# 获取知识库过滤条件
|
||||||
|
knowledge_base_ids = state.get("metadata", {}).get("knowledge_base_ids")
|
||||||
|
|
||||||
|
# 使用LangChain兼容的检索器
|
||||||
|
vector_store = get_vector_store()
|
||||||
|
retriever = KnowledgeBaseRetriever(
|
||||||
|
vectorstore=vector_store.vectorstore,
|
||||||
|
knowledge_base_ids=knowledge_base_ids,
|
||||||
|
search_type="similarity",
|
||||||
|
search_kwargs={"k": 5},
|
||||||
|
score_threshold=0.4 # 转换为相似度阈值
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行检索(LangChain格式)
|
||||||
|
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||||
|
from langchain_core.callbacks import CallbackManager
|
||||||
|
|
||||||
|
class DummyRunManager:
|
||||||
|
pass
|
||||||
|
|
||||||
|
run_manager = DummyRunManager()
|
||||||
|
langchain_docs = retriever._get_relevant_documents(
|
||||||
|
state["current_question"],
|
||||||
|
run_manager=run_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换为旧格式
|
||||||
|
retrieved_docs = []
|
||||||
|
sources = []
|
||||||
|
for doc in langchain_docs:
|
||||||
|
retrieved_docs.append({
|
||||||
|
"content": doc.page_content,
|
||||||
|
"metadata": doc.metadata,
|
||||||
|
"distance": 0.0 # LangChain不直接提供distance
|
||||||
|
})
|
||||||
|
sources.append({
|
||||||
|
"title": doc.metadata.get("title", "未知标题"),
|
||||||
|
"filename": doc.metadata.get("filename", "未知文件"),
|
||||||
|
"page": doc.metadata.get("page", 0),
|
||||||
|
"score": 0.8, # 默认分数
|
||||||
|
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
||||||
|
})
|
||||||
|
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["retrieved_docs"] = retrieved_docs
|
||||||
|
new_state["sources"] = sources
|
||||||
|
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"知识检索失败: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
print(traceback.format_exc())
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["retrieved_docs"] = []
|
||||||
|
new_state["sources"] = []
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
|
def generate_answer_node(state: GraphState) -> GraphState:
|
||||||
|
"""答案生成节点"""
|
||||||
|
try:
|
||||||
|
llm = get_llm_client()
|
||||||
|
|
||||||
|
# 构建上下文
|
||||||
|
context = ""
|
||||||
|
if state["retrieved_docs"]:
|
||||||
|
context_parts = [doc.get("content", "") for doc in state["retrieved_docs"]]
|
||||||
|
context = "\n\n".join(context_parts)
|
||||||
|
|
||||||
|
# 构建系统提示
|
||||||
|
system_prompt = """你是一个专业的国土空间规划知识问答助手。请基于提供的上下文信息回答用户的问题。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 回答要准确、专业、详细
|
||||||
|
2. 如果上下文中没有相关信息,请诚实说明
|
||||||
|
3. 回答要结构清晰,逻辑性强
|
||||||
|
4. 适当引用相关概念和术语
|
||||||
|
5. 回答长度控制在500-1000字之间
|
||||||
|
|
||||||
|
上下文信息:
|
||||||
|
{context}"""
|
||||||
|
|
||||||
|
# 构建消息
|
||||||
|
messages = [
|
||||||
|
SystemMessage(content=system_prompt.format(context=context)),
|
||||||
|
HumanMessage(content=f"问题:{state['current_question']}")
|
||||||
|
]
|
||||||
|
|
||||||
|
# 生成回答
|
||||||
|
answer = llm.chat(messages)
|
||||||
|
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["answer"] = answer
|
||||||
|
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"答案生成失败: {str(e)}")
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["answer"] = "抱歉,我无法生成合适的回答。请稍后重试。"
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
|
||||||
|
def format_response_node(state: GraphState) -> GraphState:
|
||||||
|
"""响应格式化节点"""
|
||||||
|
try:
|
||||||
|
# 格式化最终响应
|
||||||
|
response = {
|
||||||
|
"answer": state["answer"],
|
||||||
|
"sources": state["sources"],
|
||||||
|
"metadata": state["metadata"]
|
||||||
|
}
|
||||||
|
|
||||||
|
new_state = state.copy()
|
||||||
|
new_state["metadata"] = {**state["metadata"], "formatted_response": response}
|
||||||
|
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"响应格式化失败: {str(e)}")
|
||||||
|
return state
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
问答LangGraph工作流
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from langgraph.graph import StateGraph, END
|
||||||
|
from .nodes import GraphState, analyze_question_node, retrieve_knowledge_node, generate_answer_node, format_response_node
|
||||||
|
|
||||||
|
|
||||||
|
def create_qa_graph() -> StateGraph:
|
||||||
|
"""创建问答图"""
|
||||||
|
# 创建状态图
|
||||||
|
builder = StateGraph(GraphState)
|
||||||
|
|
||||||
|
# 添加节点
|
||||||
|
builder.add_node("analyze", analyze_question_node)
|
||||||
|
builder.add_node("retrieve", retrieve_knowledge_node)
|
||||||
|
builder.add_node("generate", generate_answer_node)
|
||||||
|
builder.add_node("format", format_response_node)
|
||||||
|
|
||||||
|
# 添加边
|
||||||
|
builder.add_edge("analyze", "retrieve")
|
||||||
|
builder.add_edge("retrieve", "generate")
|
||||||
|
builder.add_edge("generate", "format")
|
||||||
|
builder.add_edge("format", END)
|
||||||
|
|
||||||
|
# 设置入口点
|
||||||
|
builder.set_entry_point("analyze")
|
||||||
|
|
||||||
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def run_qa_workflow(question: str, knowledge_base_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||||
|
"""运行问答工作流"""
|
||||||
|
try:
|
||||||
|
# 创建图
|
||||||
|
graph_builder = create_qa_graph()
|
||||||
|
graph = graph_builder.compile()
|
||||||
|
|
||||||
|
# 初始化状态
|
||||||
|
initial_state: GraphState = {
|
||||||
|
"messages": [],
|
||||||
|
"current_question": question,
|
||||||
|
"retrieved_docs": [],
|
||||||
|
"answer": "",
|
||||||
|
"sources": [],
|
||||||
|
"metadata": {"knowledge_base_ids": knowledge_base_ids}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行图
|
||||||
|
result = graph.invoke(initial_state)
|
||||||
|
|
||||||
|
# 返回结果
|
||||||
|
return {
|
||||||
|
"answer": result["answer"],
|
||||||
|
"sources": result["sources"],
|
||||||
|
"metadata": result["metadata"]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"问答工作流执行失败: {str(e)}")
|
||||||
|
return {
|
||||||
|
"answer": "抱歉,处理您的问题时出现了错误。请稍后重试。",
|
||||||
|
"sources": [],
|
||||||
|
"metadata": {"error": str(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 全局图实例
|
||||||
|
qa_graph = create_qa_graph().compile()
|
||||||
|
|
||||||
|
|
||||||
|
def get_qa_graph():
|
||||||
|
"""获取问答图实例"""
|
||||||
|
return qa_graph
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 大模型集成模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
硅基流动大模型API集成
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||||||
|
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||||
|
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SiliconFlowLLM:
|
||||||
|
"""硅基流动大模型客户端"""
|
||||||
|
|
||||||
|
def __init__(self, model: Optional[str] = None):
|
||||||
|
"""初始化LLM客户端"""
|
||||||
|
# 设置环境变量
|
||||||
|
os.environ["OPENAI_API_KEY"] = settings.siliconflow_api_key
|
||||||
|
os.environ["OPENAI_API_BASE"] = settings.siliconflow_base_url
|
||||||
|
|
||||||
|
# 使用传入的模型或默认模型
|
||||||
|
self.model_name = model or settings.siliconflow_model
|
||||||
|
print(f"[DEBUG-LLM] 初始化LLM客户端,使用模型: {self.model_name} (传入参数: {model}, 默认配置: {settings.siliconflow_model})")
|
||||||
|
|
||||||
|
# 创建LLM实例
|
||||||
|
self.llm = ChatOpenAI(
|
||||||
|
model=self.model_name,
|
||||||
|
api_key=settings.siliconflow_api_key,
|
||||||
|
base_url=settings.siliconflow_base_url,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=2000,
|
||||||
|
streaming=True
|
||||||
|
)
|
||||||
|
# 检查实际使用的模型名称
|
||||||
|
actual_model = getattr(self.llm, 'model_name', None) or getattr(self.llm, 'model', None) or str(self.llm)
|
||||||
|
print(f"[DEBUG-LLM] ChatOpenAI实例创建完成,实际模型: {actual_model}")
|
||||||
|
|
||||||
|
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||||||
|
"""同步聊天"""
|
||||||
|
try:
|
||||||
|
response = self.llm.invoke(messages, **kwargs)
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"LLM调用失败: {str(e)}")
|
||||||
|
|
||||||
|
async def achat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||||||
|
"""异步聊天"""
|
||||||
|
try:
|
||||||
|
response = await self.llm.ainvoke(messages, **kwargs)
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"LLM异步调用失败: {str(e)}")
|
||||||
|
|
||||||
|
async def stream_chat(self, messages: List[BaseMessage], **kwargs) -> AsyncGenerator[str, None]:
|
||||||
|
"""流式聊天"""
|
||||||
|
try:
|
||||||
|
async for chunk in self.llm.astream(messages, **kwargs):
|
||||||
|
if hasattr(chunk, 'content') and chunk.content:
|
||||||
|
yield chunk.content
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"LLM流式调用失败: {str(e)}")
|
||||||
|
|
||||||
|
def create_messages(
|
||||||
|
self,
|
||||||
|
user_message: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
chat_history: Optional[List[Dict[str, str]]] = None
|
||||||
|
) -> List[BaseMessage]:
|
||||||
|
"""创建消息列表"""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# 添加系统提示
|
||||||
|
if system_prompt:
|
||||||
|
messages.append(SystemMessage(content=system_prompt))
|
||||||
|
|
||||||
|
# 添加聊天历史
|
||||||
|
if chat_history:
|
||||||
|
for msg in chat_history:
|
||||||
|
if msg["role"] == "user":
|
||||||
|
messages.append(HumanMessage(content=msg["content"]))
|
||||||
|
elif msg["role"] == "assistant":
|
||||||
|
messages.append(AIMessage(content=msg["content"]))
|
||||||
|
|
||||||
|
# 添加当前用户消息
|
||||||
|
messages.append(HumanMessage(content=user_message))
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
# 全局LLM实例(使用默认模型)
|
||||||
|
llm_client = SiliconFlowLLM()
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM:
|
||||||
|
"""获取LLM客户端实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: 可选的模型名称,如果提供则创建新的实例,否则返回默认实例
|
||||||
|
"""
|
||||||
|
if model is None:
|
||||||
|
return llm_client
|
||||||
|
else:
|
||||||
|
# 为指定模型创建新实例
|
||||||
|
return SiliconFlowLLM(model=model)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
迁移孤立文档到默认知识库
|
||||||
|
"""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..core.database import get_db
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.knowledge_base import KnowledgeBase
|
||||||
|
from ..models.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_orphaned_documents():
|
||||||
|
"""将没有知识库的文档迁移到用户的默认知识库"""
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取所有用户
|
||||||
|
users = db.query(User).all()
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# 检查用户是否有孤立文档
|
||||||
|
orphaned_docs = db.query(Document).filter(
|
||||||
|
Document.user_id == user.id,
|
||||||
|
Document.knowledge_base_id.is_(None)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if orphaned_docs:
|
||||||
|
# 创建默认知识库
|
||||||
|
default_kb = KnowledgeBase(
|
||||||
|
name="默认知识库",
|
||||||
|
description="系统自动创建的默认知识库,包含之前上传的文档",
|
||||||
|
user_id=user.id,
|
||||||
|
is_system=True
|
||||||
|
)
|
||||||
|
db.add(default_kb)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(default_kb)
|
||||||
|
|
||||||
|
# 将孤立文档迁移到默认知识库
|
||||||
|
for doc in orphaned_docs:
|
||||||
|
doc.knowledge_base_id = default_kb.id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
||||||
|
|
||||||
|
print("孤立文档迁移完成")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"迁移失败: {str(e)}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_orphaned_documents()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 数据模型模块
|
||||||
|
# 导入所有模型以确保它们被注册到 Base.metadata
|
||||||
|
from .user import User
|
||||||
|
from .chat import ChatSession, ChatMessage
|
||||||
|
from .document import Document, DocumentChunk
|
||||||
|
from .knowledge_base import KnowledgeBase
|
||||||
|
from .course_content import CourseModule
|
||||||
|
from .book_structure import Book, Chapter, Section, Subsection
|
||||||
|
from .forum import ForumCategory, ForumPost, ForumReply
|
||||||
|
from .generated_image import GeneratedImageRecord
|
||||||
|
|
||||||
|
# 确保所有模型都被导入
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"ChatSession",
|
||||||
|
"ChatMessage",
|
||||||
|
"Document",
|
||||||
|
"DocumentChunk",
|
||||||
|
"KnowledgeBase",
|
||||||
|
"CourseModule",
|
||||||
|
"Book",
|
||||||
|
"Chapter",
|
||||||
|
"Section",
|
||||||
|
"Subsection",
|
||||||
|
"ForumCategory",
|
||||||
|
"ForumPost",
|
||||||
|
"ForumReply",
|
||||||
|
"GeneratedImageRecord",
|
||||||
|
]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
书籍结构数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Book(Base):
|
||||||
|
"""书籍模型"""
|
||||||
|
__tablename__ = "books"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
chapters = relationship("Chapter", back_populates="book", cascade="all, delete-orphan", order_by="Chapter.display_order")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Book(id={self.id}, title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Chapter(Base):
|
||||||
|
"""章节模型"""
|
||||||
|
__tablename__ = "chapters"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
book_id = Column(Integer, ForeignKey("books.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
chapter_number = Column(Integer, nullable=False) # 章节编号(1, 2, 3...)
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
display_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# LaTeX文件信息(用于动态读取内容)
|
||||||
|
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||||
|
start_line = Column(Integer, nullable=False) # 起始行号
|
||||||
|
end_line = Column(Integer, nullable=False) # 结束行号
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
book = relationship("Book", back_populates="chapters")
|
||||||
|
sections = relationship("Section", back_populates="chapter", cascade="all, delete-orphan", order_by="Section.display_order")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Section(Base):
|
||||||
|
"""节模型(对应LaTeX的\section)"""
|
||||||
|
__tablename__ = "sections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
chapter_id = Column(Integer, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
section_number = Column(Integer, nullable=False) # 节编号
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
display_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# LaTeX文件信息(用于动态读取内容)
|
||||||
|
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||||
|
start_line = Column(Integer, nullable=False) # 起始行号
|
||||||
|
end_line = Column(Integer, nullable=False) # 结束行号
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
chapter = relationship("Chapter", back_populates="sections")
|
||||||
|
subsections = relationship("Subsection", back_populates="section", cascade="all, delete-orphan", order_by="Subsection.display_order")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Section(id={self.id}, section_number={self.section_number}, title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Subsection(Base):
|
||||||
|
"""小节模型(对应LaTeX的\subsection,作为知识点)"""
|
||||||
|
__tablename__ = "subsections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
section_id = Column(Integer, ForeignKey("sections.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
subsection_number = Column(Integer, nullable=False) # 小节编号
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
display_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# LaTeX文件信息(用于动态读取内容,包含subsubsection的内容)
|
||||||
|
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||||
|
start_line = Column(Integer, nullable=False) # 起始行号(包含subsection及其下所有subsubsection)
|
||||||
|
end_line = Column(Integer, nullable=False) # 结束行号
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
section = relationship("Section", back_populates="subsections")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Subsection(id={self.id}, subsection_number={self.subsection_number}, title='{self.title}')>"
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
聊天相关数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base):
|
||||||
|
"""聊天会话模型"""
|
||||||
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
title = Column(String(200), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user = relationship("User", back_populates="chat_sessions")
|
||||||
|
messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ChatSession(id={self.id}, user_id={self.user_id}, title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
"""聊天消息模型"""
|
||||||
|
__tablename__ = "chat_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
session_id = Column(Integer, ForeignKey("chat_sessions.id"), nullable=False)
|
||||||
|
role = Column(String(20), nullable=False) # user, assistant, system
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
message_metadata = Column(Text, nullable=True) # JSON格式的元数据
|
||||||
|
feedback = Column(String(20), nullable=True) # like, dislike
|
||||||
|
edited = Column(Boolean, default=False) # 是否被编辑过
|
||||||
|
regenerated_from = Column(Integer, nullable=True) # 从哪条消息重新生成
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
session = relationship("ChatSession", back_populates="messages")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ChatMessage(id={self.id}, session_id={self.session_id}, role='{self.role}')>"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
课程内容数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CourseModule(Base):
|
||||||
|
"""课程模块模型"""
|
||||||
|
__tablename__ = "course_modules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
module_name = Column(String(200), nullable=False)
|
||||||
|
core_knowledge_points = Column(Text, nullable=False)
|
||||||
|
digitalization_necessity = Column(Text, nullable=False)
|
||||||
|
expanded_knowledge_points = Column(Text, nullable=False) # JSON格式存储数组
|
||||||
|
display_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
def get_expanded_knowledge_points(self) -> list[str]:
|
||||||
|
"""获取展开知识点列表"""
|
||||||
|
if not self.expanded_knowledge_points:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(self.expanded_knowledge_points)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_expanded_knowledge_points(self, points: list[str]):
|
||||||
|
"""设置展开知识点列表"""
|
||||||
|
self.expanded_knowledge_points = json.dumps(points, ensure_ascii=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<CourseModule(id={self.id}, module_name='{self.module_name}', display_order={self.display_order})>"
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
文档数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
"""文档模型"""
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # None表示系统文档
|
||||||
|
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True) # 所属知识库
|
||||||
|
filename = Column(String(255), nullable=False)
|
||||||
|
original_filename = Column(String(255), nullable=False)
|
||||||
|
file_path = Column(String(500), nullable=False)
|
||||||
|
file_size = Column(Integer, nullable=False)
|
||||||
|
file_type = Column(String(50), nullable=False)
|
||||||
|
title = Column(String(200), nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
is_processed = Column(Boolean, default=False)
|
||||||
|
is_public = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# 新增字段:知识库管理
|
||||||
|
source_type = Column(String(50), default="upload") # upload, knowledge_base
|
||||||
|
last_modified = Column(DateTime(timezone=True), nullable=True) # 文件最后修改时间
|
||||||
|
file_hash = Column(String(64), nullable=True) # 文件内容哈希
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user = relationship("User", back_populates="documents")
|
||||||
|
knowledge_base = relationship("KnowledgeBase", back_populates="documents")
|
||||||
|
chunks = relationship("DocumentChunk", back_populates="document", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Document(id={self.id}, filename='{self.filename}', title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChunk(Base):
|
||||||
|
"""文档分块模型"""
|
||||||
|
__tablename__ = "document_chunks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||||
|
chunk_index = Column(Integer, nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
content_hash = Column(String(64), nullable=False) # 内容哈希
|
||||||
|
embedding_vector = Column(Text, nullable=True) # 向量嵌入(JSON格式)
|
||||||
|
chunk_metadata = Column(Text, nullable=True) # 分块元数据(JSON格式)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
document = relationship("Document", back_populates="chunks")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DocumentChunk(id={self.id}, document_id={self.document_id}, chunk_index={self.chunk_index})>"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
论坛相关模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ForumCategory(Base):
|
||||||
|
__tablename__ = "forum_categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
slug = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
posts = relationship("ForumPost", back_populates="category", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ForumPost(Base):
|
||||||
|
__tablename__ = "forum_posts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
category_id = Column(Integer, ForeignKey("forum_categories.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
category = relationship("ForumCategory", back_populates="posts")
|
||||||
|
replies = relationship("ForumReply", back_populates="post", cascade="all, delete-orphan")
|
||||||
|
author = relationship("User", back_populates="forum_posts")
|
||||||
|
|
||||||
|
|
||||||
|
class ForumReply(Base):
|
||||||
|
__tablename__ = "forum_replies"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
post_id = Column(Integer, ForeignKey("forum_posts.id"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
post = relationship("ForumPost", back_populates="replies")
|
||||||
|
author = relationship("User", back_populates="forum_replies")
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
图像生成记录数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedImageRecord(Base):
|
||||||
|
"""图像生成记录"""
|
||||||
|
__tablename__ = "generated_images"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
image_id = Column(String(36), nullable=False, unique=True) # UUID
|
||||||
|
image_type = Column(String(30), nullable=False) # text_to_image, image_edit, variation, style_transfer, optimize, outpaint
|
||||||
|
prompt = Column(Text, nullable=True)
|
||||||
|
model = Column(String(50), nullable=True)
|
||||||
|
style = Column(String(50), nullable=True)
|
||||||
|
size = Column(String(20), nullable=True)
|
||||||
|
template = Column(String(50), nullable=True)
|
||||||
|
image_url = Column(String(500), nullable=False)
|
||||||
|
original_filename = Column(String(255), nullable=True) # 图生图的原始文件名
|
||||||
|
mode = Column(String(50), nullable=True) # 编辑模式
|
||||||
|
strength = Column(String(10), nullable=True)
|
||||||
|
generation_meta = Column(Text, nullable=True) # JSON格式的额外元数据
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="generated_images")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<GeneratedImageRecord(id={self.id}, image_type='{self.image_type}')>"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
图像生成相关的 Pydantic 模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TextToImageRequest(BaseModel):
|
||||||
|
"""文生图请求模型"""
|
||||||
|
prompt: str = Field(..., description="图像描述提示词", min_length=1, max_length=1000)
|
||||||
|
model: str = Field(default="kolors", description="使用的模型", pattern="^(kolors|qwen)$")
|
||||||
|
template: str = Field(default="custom", description="提示词模板")
|
||||||
|
style: str = Field(default="realistic", description="图像风格")
|
||||||
|
size: str = Field(default="1024x1024", description="图像尺寸")
|
||||||
|
num_images: int = Field(default=1, description="生成图像数量", ge=1, le=4)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageEditRequest(BaseModel):
|
||||||
|
"""图像编辑请求模型"""
|
||||||
|
prompt: str = Field(..., description="编辑描述", min_length=1, max_length=500)
|
||||||
|
mode: str = Field(default="optimize", description="编辑模式")
|
||||||
|
strength: float = Field(default=0.8, description="编辑强度", ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageVariationRequest(BaseModel):
|
||||||
|
"""图像变体生成请求模型"""
|
||||||
|
num_variations: int = Field(default=3, description="变体数量", ge=1, le=6)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedImage(BaseModel):
|
||||||
|
"""生成的图像模型"""
|
||||||
|
id: str = Field(..., description="图像唯一ID")
|
||||||
|
url: str = Field(..., description="图像访问URL")
|
||||||
|
prompt: str = Field(..., description="使用的提示词")
|
||||||
|
model: str = Field(..., description="使用的模型")
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageEditResult(BaseModel):
|
||||||
|
"""图像编辑结果模型"""
|
||||||
|
id: str = Field(..., description="编辑后图像ID")
|
||||||
|
url: str = Field(..., description="图像访问URL")
|
||||||
|
original_filename: Optional[str] = Field(None, description="原始文件名")
|
||||||
|
edit_prompt: str = Field(..., description="编辑提示词")
|
||||||
|
mode: str = Field(..., description="编辑模式")
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageVariationResult(BaseModel):
|
||||||
|
"""图像变体结果模型"""
|
||||||
|
id: str = Field(..., description="变体图像ID")
|
||||||
|
url: str = Field(..., description="图像访问URL")
|
||||||
|
type: str = Field(default="variation", description="图像类型")
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||||
|
|
||||||
|
|
||||||
|
class TextToImageResponse(BaseModel):
|
||||||
|
"""文生图响应模型"""
|
||||||
|
images: List[GeneratedImage] = Field(..., description="生成的图像列表")
|
||||||
|
total: int = Field(..., description="图像总数")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageEditResponse(BaseModel):
|
||||||
|
"""图像编辑响应模型"""
|
||||||
|
result: ImageEditResult = Field(..., description="编辑结果")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageVariationResponse(BaseModel):
|
||||||
|
"""图像变体响应模型"""
|
||||||
|
variations: List[ImageVariationResult] = Field(..., description="变体图像列表")
|
||||||
|
total: int = Field(..., description="变体总数")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelInfo(BaseModel):
|
||||||
|
"""模型信息"""
|
||||||
|
id: str = Field(..., description="模型ID")
|
||||||
|
name: str = Field(..., description="模型名称")
|
||||||
|
description: str = Field(..., description="模型描述")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateInfo(BaseModel):
|
||||||
|
"""模板信息"""
|
||||||
|
id: str = Field(..., description="模板ID")
|
||||||
|
name: str = Field(..., description="模板名称")
|
||||||
|
description: str = Field(..., description="模板描述")
|
||||||
|
|
||||||
|
|
||||||
|
class StyleInfo(BaseModel):
|
||||||
|
"""风格信息"""
|
||||||
|
id: str = Field(..., description="风格ID")
|
||||||
|
name: str = Field(..., description="风格名称")
|
||||||
|
description: str = Field(..., description="风格描述")
|
||||||
|
|
||||||
|
|
||||||
|
class SizeInfo(BaseModel):
|
||||||
|
"""尺寸信息"""
|
||||||
|
id: str = Field(..., description="尺寸ID")
|
||||||
|
name: str = Field(..., description="尺寸名称")
|
||||||
|
description: str = Field(..., description="尺寸描述")
|
||||||
|
|
||||||
|
|
||||||
|
class EditModeInfo(BaseModel):
|
||||||
|
"""编辑模式信息"""
|
||||||
|
id: str = Field(..., description="模式ID")
|
||||||
|
name: str = Field(..., description="模式名称")
|
||||||
|
description: str = Field(..., description="模式描述")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
知识库数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase(Base):
|
||||||
|
"""知识库模型"""
|
||||||
|
__tablename__ = "knowledge_bases"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
is_system = Column(Boolean, default=False) # 是否为系统知识库
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user = relationship("User", back_populates="knowledge_bases")
|
||||||
|
documents = relationship("Document", back_populates="knowledge_base", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<KnowledgeBase(id={self.id}, name='{self.name}', user_id={self.user_id})>"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
用户数据模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""用户模型"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
full_name = Column(String(100), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_superuser = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
chat_sessions = relationship("ChatSession", back_populates="user")
|
||||||
|
documents = relationship("Document", back_populates="user")
|
||||||
|
knowledge_bases = relationship("KnowledgeBase", back_populates="user")
|
||||||
|
forum_posts = relationship("ForumPost", back_populates="author")
|
||||||
|
forum_replies = relationship("ForumReply", back_populates="author")
|
||||||
|
generated_images = relationship("GeneratedImageRecord", back_populates="user")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# RAG系统模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
RAG检索链(LangChain 1.0)
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
||||||
|
from langchain_core.prompts import ChatPromptTemplate
|
||||||
|
from langchain_core.output_parsers import StrOutputParser
|
||||||
|
|
||||||
|
from .prompts import create_rag_prompt
|
||||||
|
from .retrievers import KnowledgeBaseRetriever
|
||||||
|
from .vector_store import get_vector_store
|
||||||
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
class RAGChain:
|
||||||
|
"""RAG问答链(LangChain 1.0标准API)"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
knowledge_base_ids: Optional[List[int]] = None,
|
||||||
|
search_type: str = "similarity",
|
||||||
|
k: int = 5,
|
||||||
|
score_threshold: float = 0.1,
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""初始化RAG链
|
||||||
|
|
||||||
|
Args:
|
||||||
|
knowledge_base_ids: 知识库ID列表
|
||||||
|
search_type: 搜索类型
|
||||||
|
k: 返回文档数量
|
||||||
|
score_threshold: 相似度阈值
|
||||||
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
|
"""
|
||||||
|
print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
|
||||||
|
self.llm = get_llm_client(model=model).llm
|
||||||
|
self.vector_store = get_vector_store()
|
||||||
|
self.knowledge_base_ids = knowledge_base_ids
|
||||||
|
|
||||||
|
# 创建自定义检索器
|
||||||
|
self.retriever = KnowledgeBaseRetriever(
|
||||||
|
vectorstore=self.vector_store.vectorstore,
|
||||||
|
knowledge_base_ids=knowledge_base_ids,
|
||||||
|
search_type=search_type,
|
||||||
|
search_kwargs={"k": k},
|
||||||
|
score_threshold=score_threshold
|
||||||
|
)
|
||||||
|
print(f"[DEBUG-RAGChain] 检索器创建完成,knowledge_base_ids: {self.retriever.knowledge_base_ids}")
|
||||||
|
|
||||||
|
# 创建Prompt
|
||||||
|
self.prompt = create_rag_prompt()
|
||||||
|
|
||||||
|
# 创建RAG链
|
||||||
|
self.chain = self._create_chain()
|
||||||
|
|
||||||
|
def _create_chain(self):
|
||||||
|
"""创建检索链(LangChain 1.0 Runnable API)"""
|
||||||
|
# 使用LangChain 1.0的runnable API构建RAG链
|
||||||
|
def format_docs(docs):
|
||||||
|
"""格式化文档"""
|
||||||
|
return "\n\n".join(doc.page_content for doc in docs)
|
||||||
|
|
||||||
|
# 构建RAG链 - 只检索一次,返回完整结果
|
||||||
|
def rag_with_sources(input_data):
|
||||||
|
"""RAG处理函数,只检索一次"""
|
||||||
|
question = input_data
|
||||||
|
# 只检索一次
|
||||||
|
docs = self.retriever.invoke(question)
|
||||||
|
context = format_docs(docs)
|
||||||
|
|
||||||
|
# 生成答案
|
||||||
|
messages = self.prompt.invoke({"context": context, "question": question})
|
||||||
|
answer = self.llm.invoke(messages).content
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"source_documents": docs,
|
||||||
|
"context": context,
|
||||||
|
"question": question
|
||||||
|
}
|
||||||
|
|
||||||
|
rag_chain = RunnableLambda(rag_with_sources)
|
||||||
|
return rag_chain
|
||||||
|
|
||||||
|
def invoke(self, question: str) -> Dict[str, Any]:
|
||||||
|
"""同步调用"""
|
||||||
|
# 只调用一次,获取完整结果
|
||||||
|
result = self.chain.invoke(question)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": result["answer"],
|
||||||
|
"sources": self._format_sources(result["source_documents"]),
|
||||||
|
"metadata": {
|
||||||
|
"has_context": bool(result["source_documents"]),
|
||||||
|
"source_count": len(result["source_documents"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def ainvoke(self, question: str) -> Dict[str, Any]:
|
||||||
|
"""异步调用"""
|
||||||
|
# 只调用一次,获取完整结果
|
||||||
|
result = await self.chain.ainvoke(question)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": result["answer"],
|
||||||
|
"sources": self._format_sources(result["source_documents"]),
|
||||||
|
"metadata": {
|
||||||
|
"has_context": bool(result["source_documents"]),
|
||||||
|
"source_count": len(result["source_documents"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def astream(self, question: str):
|
||||||
|
"""流式调用(只流式输出答案)"""
|
||||||
|
# 先获取文档(这是唯一一次检索)
|
||||||
|
docs = await self.retriever.ainvoke(question)
|
||||||
|
context = "\n\n".join(doc.page_content for doc in docs)
|
||||||
|
|
||||||
|
# 构建prompt
|
||||||
|
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
||||||
|
|
||||||
|
# 流式生成答案
|
||||||
|
async for chunk in self.llm.astream(messages):
|
||||||
|
if isinstance(chunk, str):
|
||||||
|
yield chunk
|
||||||
|
else:
|
||||||
|
# 处理AIMessageChunk
|
||||||
|
yield chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||||
|
|
||||||
|
async def astream_with_sources(self, question: str):
|
||||||
|
"""流式调用(返回答案流和文档,包含思考过程)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 0. 思考阶段开始
|
||||||
|
start_time = time.time()
|
||||||
|
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||||
|
|
||||||
|
# 1. 检索文档
|
||||||
|
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
||||||
|
retrieval_start = time.time()
|
||||||
|
docs = await self.retriever.ainvoke(question)
|
||||||
|
retrieval_time = time.time() - retrieval_start
|
||||||
|
|
||||||
|
# 发送检索结果
|
||||||
|
yield {
|
||||||
|
"type": "thinking",
|
||||||
|
"stage": "retrieved",
|
||||||
|
"message": f"找到 {len(docs)} 条相关文档",
|
||||||
|
"doc_count": len(docs),
|
||||||
|
"time": round(retrieval_time, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
context = "\n\n".join(doc.page_content for doc in docs)
|
||||||
|
|
||||||
|
# 2. 构建prompt
|
||||||
|
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||||
|
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
||||||
|
|
||||||
|
# 3. 流式生成答案
|
||||||
|
answer_chunks = []
|
||||||
|
async for chunk in self.llm.astream(messages):
|
||||||
|
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||||
|
answer_chunks.append(content)
|
||||||
|
yield {"type": "chunk", "content": content}
|
||||||
|
|
||||||
|
# 4. 完成,返回sources
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
yield {
|
||||||
|
"type": "sources",
|
||||||
|
"sources": self._format_sources(docs),
|
||||||
|
"answer": "".join(answer_chunks),
|
||||||
|
"metadata": {
|
||||||
|
"total_time": round(total_time, 2),
|
||||||
|
"retrieval_time": round(retrieval_time, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _format_sources(self, documents: List) -> List[Dict]:
|
||||||
|
"""格式化来源信息"""
|
||||||
|
sources = []
|
||||||
|
for doc in documents:
|
||||||
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
|
sources.append({
|
||||||
|
"title": metadata.get("title", "未知标题"),
|
||||||
|
"filename": metadata.get("filename", "未知文件"),
|
||||||
|
"page": metadata.get("chunk_index", 0),
|
||||||
|
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
||||||
|
})
|
||||||
|
return sources
|
||||||
|
|
||||||
|
def create_rag_chain(
|
||||||
|
knowledge_base_ids: Optional[List[int]] = None,
|
||||||
|
search_type: str = "similarity",
|
||||||
|
k: int = 5,
|
||||||
|
model: Optional[str] = None
|
||||||
|
) -> RAGChain:
|
||||||
|
"""创建RAG链实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
knowledge_base_ids: 知识库ID列表
|
||||||
|
search_type: 搜索类型
|
||||||
|
k: 返回文档数量
|
||||||
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
|
"""
|
||||||
|
return RAGChain(
|
||||||
|
knowledge_base_ids=knowledge_base_ids,
|
||||||
|
search_type=search_type,
|
||||||
|
k=k,
|
||||||
|
model=model
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
LangChain 1.0 对话链(Normal Mode)
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from langchain_core.runnables import RunnablePassthrough
|
||||||
|
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||||
|
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||||
|
from langchain_core.output_parsers import StrOutputParser
|
||||||
|
|
||||||
|
from ..llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationChain:
|
||||||
|
"""标准对话链(LangChain 1.0)"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""初始化对话链
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: 系统提示词
|
||||||
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
|
"""
|
||||||
|
self.llm = get_llm_client(model=model).llm
|
||||||
|
self.system_prompt = system_prompt or "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。"
|
||||||
|
|
||||||
|
# 创建带历史的Prompt模板
|
||||||
|
self.prompt = ChatPromptTemplate.from_messages([
|
||||||
|
SystemMessage(content=self.system_prompt),
|
||||||
|
MessagesPlaceholder(variable_name="chat_history"),
|
||||||
|
("human", "{question}")
|
||||||
|
])
|
||||||
|
|
||||||
|
# 创建对话链
|
||||||
|
self.chain = self._create_chain()
|
||||||
|
|
||||||
|
def _create_chain(self):
|
||||||
|
"""创建对话链"""
|
||||||
|
return (
|
||||||
|
self.prompt
|
||||||
|
| self.llm
|
||||||
|
| StrOutputParser()
|
||||||
|
)
|
||||||
|
|
||||||
|
def invoke(self, question: str, chat_history: List[Dict] = None) -> Dict[str, Any]:
|
||||||
|
"""同步调用"""
|
||||||
|
history_messages = self._format_history(chat_history or [])
|
||||||
|
answer = self.chain.invoke({
|
||||||
|
"question": question,
|
||||||
|
"chat_history": history_messages
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"sources": [],
|
||||||
|
"metadata": {
|
||||||
|
"mode": "normal",
|
||||||
|
"has_history": bool(chat_history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def ainvoke(self, question: str, chat_history: List[Dict] = None) -> Dict[str, Any]:
|
||||||
|
"""异步调用"""
|
||||||
|
history_messages = self._format_history(chat_history or [])
|
||||||
|
answer = await self.chain.ainvoke({
|
||||||
|
"question": question,
|
||||||
|
"chat_history": history_messages
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"answer": answer,
|
||||||
|
"sources": [],
|
||||||
|
"metadata": {
|
||||||
|
"mode": "normal",
|
||||||
|
"has_history": bool(chat_history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def astream_with_thinking(self, question: str, chat_history: List[Dict] = None):
|
||||||
|
"""流式调用(包含思考过程)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 思考阶段
|
||||||
|
start_time = time.time()
|
||||||
|
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||||
|
|
||||||
|
# 准备历史
|
||||||
|
history_messages = self._format_history(chat_history or [])
|
||||||
|
|
||||||
|
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||||
|
|
||||||
|
# 流式生成
|
||||||
|
async for chunk in self.chain.astream({
|
||||||
|
"question": question,
|
||||||
|
"chat_history": history_messages
|
||||||
|
}):
|
||||||
|
yield {"type": "chunk", "content": chunk}
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
yield {
|
||||||
|
"type": "complete",
|
||||||
|
"metadata": {
|
||||||
|
"total_time": round(total_time, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _format_history(self, chat_history: List[Dict]) -> List:
|
||||||
|
"""格式化聊天历史为LangChain消息格式"""
|
||||||
|
messages = []
|
||||||
|
for msg in chat_history:
|
||||||
|
if msg["role"] == "user":
|
||||||
|
messages.append(HumanMessage(content=msg["content"]))
|
||||||
|
elif msg["role"] == "assistant":
|
||||||
|
messages.append(AIMessage(content=msg["content"]))
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def create_conversation_chain(system_prompt: Optional[str] = None, model: Optional[str] = None) -> ConversationChain:
|
||||||
|
"""创建对话链实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: 系统提示词
|
||||||
|
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||||
|
"""
|
||||||
|
return ConversationChain(system_prompt=system_prompt, model=model)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
LangChain 1.0 文档加载器封装
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from langchain_community.document_loaders import (
|
||||||
|
PyPDFLoader,
|
||||||
|
Docx2txtLoader,
|
||||||
|
TextLoader,
|
||||||
|
UnstructuredMarkdownLoader
|
||||||
|
)
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
class DocumentLoaderFactory:
|
||||||
|
"""文档加载器工厂"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_loader(file_path: str, file_type: str):
|
||||||
|
"""根据文件类型获取对应的加载器"""
|
||||||
|
loaders = {
|
||||||
|
".pdf": PyPDFLoader,
|
||||||
|
".docx": Docx2txtLoader,
|
||||||
|
".txt": TextLoader,
|
||||||
|
".md": UnstructuredMarkdownLoader,
|
||||||
|
}
|
||||||
|
|
||||||
|
loader_class = loaders.get(file_type)
|
||||||
|
if not loader_class:
|
||||||
|
raise ValueError(f"Unsupported file type: {file_type}")
|
||||||
|
|
||||||
|
return loader_class(file_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_document(file_path: str, file_type: str, metadata: Optional[dict] = None) -> List[Document]:
|
||||||
|
"""加载文档并添加元数据"""
|
||||||
|
loader = DocumentLoaderFactory.get_loader(file_path, file_type)
|
||||||
|
documents = loader.load()
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
for doc in documents:
|
||||||
|
doc.metadata.update(metadata)
|
||||||
|
|
||||||
|
return documents
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
嵌入模型管理
|
||||||
|
"""
|
||||||
|
from typing import List
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingModel:
|
||||||
|
"""嵌入模型管理器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化嵌入模型"""
|
||||||
|
self.model_name = settings.embedding_model
|
||||||
|
self.model = None
|
||||||
|
self._load_model()
|
||||||
|
|
||||||
|
def _load_model(self):
|
||||||
|
"""加载嵌入模型"""
|
||||||
|
try:
|
||||||
|
self.model = SentenceTransformer(self.model_name)
|
||||||
|
print(f"嵌入模型 {self.model_name} 加载成功")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"嵌入模型加载失败: {str(e)}")
|
||||||
|
|
||||||
|
def encode(self, texts: List[str]) -> np.ndarray:
|
||||||
|
"""编码文本为向量"""
|
||||||
|
try:
|
||||||
|
embeddings = self.model.encode(texts)
|
||||||
|
return embeddings
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"文本编码失败: {str(e)}")
|
||||||
|
|
||||||
|
def encode_single(self, text: str) -> np.ndarray:
|
||||||
|
"""编码单个文本"""
|
||||||
|
return self.encode([text])[0]
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
"""LangChain兼容的查询嵌入方法"""
|
||||||
|
return self.encode_single(text).tolist()
|
||||||
|
|
||||||
|
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
"""LangChain兼容的文档嵌入方法"""
|
||||||
|
return self.encode(texts).tolist()
|
||||||
|
|
||||||
|
def get_embedding_dimension(self) -> int:
|
||||||
|
"""获取嵌入维度"""
|
||||||
|
if self.model is None:
|
||||||
|
return 0
|
||||||
|
return self.model.get_sentence_embedding_dimension()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局嵌入模型实例
|
||||||
|
embedding_model = EmbeddingModel()
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_model() -> EmbeddingModel:
|
||||||
|
"""获取嵌入模型实例"""
|
||||||
|
return embedding_model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
RAG系统的Prompt模板(LangChain 1.0)
|
||||||
|
"""
|
||||||
|
from langchain_core.prompts import ChatPromptTemplate
|
||||||
|
from langchain_core.messages import SystemMessage, HumanMessage
|
||||||
|
|
||||||
|
# RAG系统提示词
|
||||||
|
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
|
||||||
|
|
||||||
|
上下文信息:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 回答要准确、专业、详细
|
||||||
|
2. 如果上下文中没有相关信息,请诚实说明
|
||||||
|
3. 回答要结构清晰,逻辑性强
|
||||||
|
4. 适当引用相关概念和术语
|
||||||
|
5. 回答长度控制在500-1000字之间
|
||||||
|
|
||||||
|
请基于上述上下文信息回答用户的问题。"""
|
||||||
|
|
||||||
|
def create_rag_prompt() -> ChatPromptTemplate:
|
||||||
|
"""创建RAG聊天Prompt模板"""
|
||||||
|
return ChatPromptTemplate.from_messages([
|
||||||
|
("system", RAG_SYSTEM_PROMPT),
|
||||||
|
("human", "{question}")
|
||||||
|
])
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
文档检索器
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from .vector_store import get_vector_store
|
||||||
|
from .embeddings import get_embedding_model
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRetriever:
|
||||||
|
"""文档检索器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化检索器"""
|
||||||
|
self.vector_store = get_vector_store()
|
||||||
|
self.embedding_model = get_embedding_model()
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
top_k: int = 5,
|
||||||
|
score_threshold: float = 0.7,
|
||||||
|
filter_metadata: Optional[Dict] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""检索相关文档"""
|
||||||
|
try:
|
||||||
|
print(f"[DEBUG-RETRIEVER] 开始检索 - query: {query[:50]}..., filter_metadata: {filter_metadata}")
|
||||||
|
|
||||||
|
# 执行向量搜索
|
||||||
|
results = self.vector_store.search(
|
||||||
|
query=query,
|
||||||
|
n_results=top_k,
|
||||||
|
filter_metadata=filter_metadata
|
||||||
|
)
|
||||||
|
print(f"[DEBUG-RETRIEVER] 向量搜索返回结果数量: {len(results)}")
|
||||||
|
|
||||||
|
# 过滤低分结果
|
||||||
|
filtered_results = [
|
||||||
|
result for result in results
|
||||||
|
if result.get("distance", 1.0) <= (1 - score_threshold)
|
||||||
|
]
|
||||||
|
print(f"[DEBUG-RETRIEVER] 过滤后结果数量: {len(filtered_results)}")
|
||||||
|
|
||||||
|
return filtered_results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"文档检索失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def retrieve_by_document_id(self, document_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""根据文档ID检索"""
|
||||||
|
try:
|
||||||
|
results = self.vector_store.search(
|
||||||
|
query="", # 空查询
|
||||||
|
n_results=1000, # 获取大量结果
|
||||||
|
filter_metadata={"document_id": document_id}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
print(f"按文档ID检索失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_relevant_context(self, query: str, max_length: int = 2000) -> str:
|
||||||
|
"""获取相关上下文"""
|
||||||
|
results = self.retrieve(query, top_k=5)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 按相关性排序并拼接内容
|
||||||
|
context_parts = []
|
||||||
|
current_length = 0
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
content = result.get("content", "")
|
||||||
|
if current_length + len(content) > max_length:
|
||||||
|
break
|
||||||
|
|
||||||
|
context_parts.append(content)
|
||||||
|
current_length += len(content)
|
||||||
|
|
||||||
|
return "\n\n".join(context_parts)
|
||||||
|
|
||||||
|
def get_sources_info(self, query: str, top_k: int = 5, filter_metadata: Optional[Dict] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""获取来源信息"""
|
||||||
|
results = self.retrieve(query, top_k=top_k, filter_metadata=filter_metadata)
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for result in results:
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
source_info = {
|
||||||
|
"title": metadata.get("title", "未知标题"),
|
||||||
|
"filename": metadata.get("filename", "未知文件"),
|
||||||
|
"page": metadata.get("page", 0),
|
||||||
|
"score": 1 - result.get("distance", 1.0), # 转换为相似度分数
|
||||||
|
"preview": result.get("content", "")[:200] + "..." if len(result.get("content", "")) > 200 else result.get("content", "")
|
||||||
|
}
|
||||||
|
sources.append(source_info)
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
自定义知识库检索器(LangChain 1.0)
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
import math
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||||
|
from langchain_core.retrievers import BaseRetriever
|
||||||
|
|
||||||
|
class KnowledgeBaseRetriever(BaseRetriever):
|
||||||
|
"""支持知识库过滤和智能分数转换的检索器"""
|
||||||
|
|
||||||
|
vectorstore: any
|
||||||
|
knowledge_base_ids: Optional[List[int]] = None
|
||||||
|
search_type: str = "similarity"
|
||||||
|
search_kwargs: dict = {"k": 5}
|
||||||
|
score_threshold: float = 0.1
|
||||||
|
|
||||||
|
def _get_relevant_documents(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
run_manager: CallbackManagerForRetrieverRun
|
||||||
|
) -> List[Document]:
|
||||||
|
"""获取相关文档(LangChain 1.0标准接口)"""
|
||||||
|
print(f"[DEBUG-Retriever] 查询: {query}")
|
||||||
|
print(f"[DEBUG-Retriever] knowledge_base_ids: {self.knowledge_base_ids}")
|
||||||
|
|
||||||
|
# 构建知识库过滤条件
|
||||||
|
filter_dict = None
|
||||||
|
if self.knowledge_base_ids:
|
||||||
|
filter_dict = {"knowledge_base_id": {"$in": self.knowledge_base_ids}}
|
||||||
|
print(f"[DEBUG-Retriever] 构建的过滤条件: {filter_dict}")
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG-Retriever] 没有知识库ID,不进行过滤")
|
||||||
|
|
||||||
|
# 执行搜索
|
||||||
|
if self.search_type == "similarity":
|
||||||
|
docs_and_scores = self.vectorstore.similarity_search_with_score(
|
||||||
|
query=query,
|
||||||
|
k=self.search_kwargs.get("k", 5),
|
||||||
|
filter=filter_dict
|
||||||
|
)
|
||||||
|
print(f"[DEBUG-Retriever] 搜索返回文档数量: {len(docs_and_scores)}")
|
||||||
|
|
||||||
|
# 打印每个文档的知识库ID
|
||||||
|
for i, (doc, distance) in enumerate(docs_and_scores):
|
||||||
|
kb_id = doc.metadata.get("knowledge_base_id", "未知")
|
||||||
|
print(f"[DEBUG-Retriever] 文档{i+1}: knowledge_base_id={kb_id}, distance={distance:.4f}")
|
||||||
|
|
||||||
|
# 转换距离为分数并过滤
|
||||||
|
filtered_docs = []
|
||||||
|
for doc, distance in docs_and_scores:
|
||||||
|
score = self._convert_distance_to_score(distance)
|
||||||
|
if score > self.score_threshold:
|
||||||
|
filtered_docs.append(doc)
|
||||||
|
|
||||||
|
print(f"[DEBUG-Retriever] 过滤后文档数量: {len(filtered_docs)}")
|
||||||
|
return filtered_docs
|
||||||
|
|
||||||
|
elif self.search_type == "mmr":
|
||||||
|
return self.vectorstore.max_marginal_relevance_search(
|
||||||
|
query=query,
|
||||||
|
k=self.search_kwargs.get("k", 5),
|
||||||
|
fetch_k=self.search_kwargs.get("fetch_k", 20),
|
||||||
|
filter=filter_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _convert_distance_to_score(self, distance: float) -> float:
|
||||||
|
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
||||||
|
# 内积距离(负值)
|
||||||
|
if distance < 0:
|
||||||
|
return (1 + distance) / 2
|
||||||
|
|
||||||
|
# 大距离使用对数缩放
|
||||||
|
if distance > 100:
|
||||||
|
return 1 / (1 + math.log(distance))
|
||||||
|
|
||||||
|
# 标准距离转换
|
||||||
|
return 1 / (1 + distance)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
LangChain 1.0 文本分割器配置
|
||||||
|
"""
|
||||||
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
from typing import List
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
class ChineseTextSplitter:
|
||||||
|
"""中文文本分割器"""
|
||||||
|
|
||||||
|
def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):
|
||||||
|
# 针对中文优化的分隔符顺序
|
||||||
|
self.splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
chunk_overlap=chunk_overlap,
|
||||||
|
separators=[
|
||||||
|
"\n\n", # 段落
|
||||||
|
"\n", # 行
|
||||||
|
"。", # 句号
|
||||||
|
"!", # 感叹号
|
||||||
|
"?", # 问号
|
||||||
|
";", # 分号
|
||||||
|
",", # 逗号
|
||||||
|
" ", # 空格
|
||||||
|
"", # 字符
|
||||||
|
],
|
||||||
|
length_function=len,
|
||||||
|
is_separator_regex=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def split_documents(self, documents: List[Document]) -> List[Document]:
|
||||||
|
"""分割文档"""
|
||||||
|
return self.splitter.split_documents(documents)
|
||||||
|
|
||||||
|
def split_text(self, text: str) -> List[str]:
|
||||||
|
"""分割文本"""
|
||||||
|
return self.splitter.split_text(text)
|
||||||
|
|
||||||
|
def get_text_splitter(chunk_size: int = 1000, chunk_overlap: int = 200) -> ChineseTextSplitter:
|
||||||
|
"""获取文本分割器实例"""
|
||||||
|
return ChineseTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
LangChain 1.0 向量存储封装
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from langchain_chroma import Chroma
|
||||||
|
from langchain_core.documents import Document as LangChainDocument
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from .embeddings import get_embedding_model
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStore:
|
||||||
|
"""向量存储管理器(LangChain 1.0 Chroma)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化向量存储"""
|
||||||
|
self.vector_store_path = Path(settings.vector_store_path)
|
||||||
|
self.vector_store_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 获取嵌入模型(保留自定义实现)
|
||||||
|
self.embedding_model = get_embedding_model()
|
||||||
|
|
||||||
|
# 使用LangChain Chroma wrapper
|
||||||
|
self.vectorstore = Chroma(
|
||||||
|
collection_name="course_knowledge",
|
||||||
|
embedding_function=self.embedding_model,
|
||||||
|
persist_directory=str(self.vector_store_path),
|
||||||
|
collection_metadata={"hnsw:space": "l2"} # 保持L2距离度量
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_documents(self, documents: List[LangChainDocument]) -> bool:
|
||||||
|
"""添加LangChain文档到向量存储"""
|
||||||
|
try:
|
||||||
|
self.vectorstore.add_documents(documents)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"添加文档失败: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def as_retriever(self, **kwargs):
|
||||||
|
"""返回标准LangChain检索器"""
|
||||||
|
return self.vectorstore.as_retriever(**kwargs)
|
||||||
|
|
||||||
|
def similarity_search_with_score(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
k: int = 5,
|
||||||
|
filter: Optional[Dict] = None
|
||||||
|
):
|
||||||
|
"""相似度搜索(带分数)"""
|
||||||
|
print(f"[DEBUG-VectorStore] 查询参数 - k: {k}, filter: {filter}")
|
||||||
|
|
||||||
|
result = self.vectorstore.similarity_search_with_score(
|
||||||
|
query=query,
|
||||||
|
k=k,
|
||||||
|
filter=filter
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def max_marginal_relevance_search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
k: int = 5,
|
||||||
|
fetch_k: int = 20,
|
||||||
|
filter: Optional[Dict] = None
|
||||||
|
):
|
||||||
|
"""MMR搜索(多样性检索)"""
|
||||||
|
return self.vectorstore.max_marginal_relevance_search(
|
||||||
|
query=query,
|
||||||
|
k=k,
|
||||||
|
fetch_k=fetch_k,
|
||||||
|
filter=filter
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 单例模式
|
||||||
|
_vector_store_instance = None
|
||||||
|
|
||||||
|
def get_vector_store() -> VectorStore:
|
||||||
|
"""获取向量存储实例"""
|
||||||
|
global _vector_store_instance
|
||||||
|
if _vector_store_instance is None:
|
||||||
|
_vector_store_instance = VectorStore()
|
||||||
|
return _vector_store_instance
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 业务逻辑服务模块
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
学习分析服务
|
||||||
|
提供用户学习数据的统计和分析功能
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, distinct, and_
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.chat import ChatSession, ChatMessage
|
||||||
|
from ..models.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsService:
|
||||||
|
"""学习分析服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_user_statistics(self, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""获取用户统计数据"""
|
||||||
|
try:
|
||||||
|
# 获取用户信息
|
||||||
|
user = self.db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise ValueError(f"用户 {user_id} 不存在")
|
||||||
|
|
||||||
|
# 总会话数
|
||||||
|
total_sessions = self.db.query(ChatSession).filter(
|
||||||
|
ChatSession.user_id == user_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 总消息数(只统计用户发送的消息)
|
||||||
|
total_messages = self.db.query(ChatMessage).join(ChatSession).filter(
|
||||||
|
and_(
|
||||||
|
ChatSession.user_id == user_id,
|
||||||
|
ChatMessage.role == "user"
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 总文档数
|
||||||
|
total_documents = self.db.query(Document).filter(
|
||||||
|
Document.user_id == user_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 活跃天数(基于会话创建日期)
|
||||||
|
active_days = self.db.query(
|
||||||
|
distinct(func.date(ChatSession.created_at))
|
||||||
|
).filter(
|
||||||
|
ChatSession.user_id == user_id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 用户注册时间
|
||||||
|
user_since = user.created_at.isoformat() if user.created_at else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_sessions": total_sessions,
|
||||||
|
"total_messages": total_messages,
|
||||||
|
"total_documents": total_documents,
|
||||||
|
"active_days": active_days,
|
||||||
|
"user_since": user_since,
|
||||||
|
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 获取用户统计数据失败: {str(e)}")
|
||||||
|
raise Exception(f"获取统计数据失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_user_learning_trends(self, user_id: int, days: int = 30) -> List[Dict[str, Any]]:
|
||||||
|
"""获取用户学习趋势数据"""
|
||||||
|
try:
|
||||||
|
# 计算开始日期
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# 按日期统计消息和会话数量
|
||||||
|
trends_query = self.db.query(
|
||||||
|
func.date(ChatSession.created_at).label('date'),
|
||||||
|
func.count(distinct(ChatSession.id)).label('sessions'),
|
||||||
|
func.count(ChatMessage.id).label('messages')
|
||||||
|
).join(
|
||||||
|
ChatMessage, ChatSession.id == ChatMessage.session_id
|
||||||
|
).filter(
|
||||||
|
and_(
|
||||||
|
ChatSession.user_id == user_id,
|
||||||
|
ChatSession.created_at >= start_date,
|
||||||
|
ChatMessage.role == "user" # 只统计用户消息
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
func.date(ChatSession.created_at)
|
||||||
|
).order_by(
|
||||||
|
func.date(ChatSession.created_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
trends = []
|
||||||
|
for row in trends_query:
|
||||||
|
# 处理日期格式,确保返回字符串
|
||||||
|
date_str = row.date.isoformat() if hasattr(row.date, 'isoformat') else str(row.date)
|
||||||
|
trends.append({
|
||||||
|
"date": date_str,
|
||||||
|
"sessions": row.sessions,
|
||||||
|
"messages": row.messages
|
||||||
|
})
|
||||||
|
|
||||||
|
return trends
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 获取学习趋势数据失败: {str(e)}")
|
||||||
|
raise Exception(f"获取学习趋势失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_popular_questions(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""获取用户热门问题"""
|
||||||
|
try:
|
||||||
|
# 统计用户消息中出现频率最高的问题
|
||||||
|
# 这里简化处理,实际可能需要更复杂的文本分析
|
||||||
|
popular_query = self.db.query(
|
||||||
|
ChatMessage.content.label('question'),
|
||||||
|
func.count(ChatMessage.id).label('count')
|
||||||
|
).join(ChatSession).filter(
|
||||||
|
and_(
|
||||||
|
ChatSession.user_id == user_id,
|
||||||
|
ChatMessage.role == "user",
|
||||||
|
func.length(ChatMessage.content) > 10 # 过滤太短的消息
|
||||||
|
)
|
||||||
|
).group_by(
|
||||||
|
ChatMessage.content
|
||||||
|
).order_by(
|
||||||
|
func.count(ChatMessage.id).desc()
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
popular_questions = []
|
||||||
|
for row in popular_query:
|
||||||
|
popular_questions.append({
|
||||||
|
"question": row.question,
|
||||||
|
"count": row.count,
|
||||||
|
"category": "用户问题" # 简化分类
|
||||||
|
})
|
||||||
|
|
||||||
|
return popular_questions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 获取热门问题失败: {str(e)}")
|
||||||
|
raise Exception(f"获取热门问题失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_knowledge_coverage(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""获取知识覆盖度(简化版本)"""
|
||||||
|
try:
|
||||||
|
# 这里是一个简化的实现
|
||||||
|
# 实际可能需要基于文档内容和问题内容进行更复杂的分析
|
||||||
|
coverage_data = [
|
||||||
|
{"topic": "基础概念", "coverage": 75, "questions": 20},
|
||||||
|
{"topic": "实践方法", "coverage": 60, "questions": 15},
|
||||||
|
{"topic": "技术标准", "coverage": 45, "questions": 10},
|
||||||
|
{"topic": "案例分析", "coverage": 30, "questions": 8},
|
||||||
|
{"topic": "政策法规", "coverage": 25, "questions": 5}
|
||||||
|
]
|
||||||
|
|
||||||
|
return coverage_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 获取知识覆盖度失败: {str(e)}")
|
||||||
|
raise Exception(f"获取知识覆盖度失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_learning_report(self, user_id: int) -> Dict[str, Any]:
|
||||||
|
"""获取学习报告"""
|
||||||
|
try:
|
||||||
|
stats = self.get_user_statistics(user_id)
|
||||||
|
|
||||||
|
# 计算学习进度(基于活跃天数和消息数量)
|
||||||
|
days_since_registration = 1
|
||||||
|
if stats["user_since"]:
|
||||||
|
# 处理日期字符串,确保正确解析
|
||||||
|
user_since_str = stats["user_since"]
|
||||||
|
if isinstance(user_since_str, str):
|
||||||
|
# 移除时区信息并解析
|
||||||
|
clean_date_str = user_since_str.replace('Z', '').replace('+00:00', '')
|
||||||
|
reg_date = datetime.fromisoformat(clean_date_str)
|
||||||
|
else:
|
||||||
|
reg_date = user_since_str
|
||||||
|
days_since_registration = max(1, (datetime.now() - reg_date).days)
|
||||||
|
|
||||||
|
# 简化的学习进度计算
|
||||||
|
learning_progress = min(100, (stats["active_days"] / days_since_registration) * 100)
|
||||||
|
|
||||||
|
# 学习建议
|
||||||
|
recommendations = []
|
||||||
|
if stats["total_messages"] < 50:
|
||||||
|
recommendations.append("建议增加提问频率,多与AI助手互动学习")
|
||||||
|
if stats["total_documents"] < 5:
|
||||||
|
recommendations.append("建议上传更多相关文档,丰富知识库内容")
|
||||||
|
if stats["active_days"] < 7:
|
||||||
|
recommendations.append("建议保持每日学习习惯,提高学习连续性")
|
||||||
|
|
||||||
|
# 知识缺口
|
||||||
|
knowledge_gaps = []
|
||||||
|
if stats["total_sessions"] < 10:
|
||||||
|
knowledge_gaps.append("基础概念理解不够深入")
|
||||||
|
if stats["total_documents"] < 3:
|
||||||
|
knowledge_gaps.append("实践应用能力有待提升")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"total_questions": stats["total_messages"],
|
||||||
|
"topics_covered": ["国土空间规划基础理论", "规划编制方法与技术", "土地利用规划"],
|
||||||
|
"learning_progress": round(learning_progress, 1),
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"study_time": stats["total_messages"] * 2, # 估算学习时间(分钟)
|
||||||
|
"knowledge_gaps": knowledge_gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 获取学习报告失败: {str(e)}")
|
||||||
|
raise Exception(f"获取学习报告失败: {str(e)}")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
用户认证服务
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
from ..models.user import User
|
||||||
|
from ..core.security import get_password_hash, verify_password, create_access_token
|
||||||
|
from ..core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""用户认证服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""创建用户"""
|
||||||
|
try:
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
if self.get_user_by_username(username):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查邮箱是否已存在
|
||||||
|
if self.get_user_by_email(email):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 限制密码长度(bcrypt 最大支持72字节)
|
||||||
|
if len(password) > 72:
|
||||||
|
password = password[:72]
|
||||||
|
|
||||||
|
# 创建新用户
|
||||||
|
hashed_password = get_password_hash(password)
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
full_name=full_name,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(user)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
print(f"创建用户失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||||
|
"""验证用户"""
|
||||||
|
try:
|
||||||
|
user = self.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 限制密码长度(bcrypt 最大支持72字节)
|
||||||
|
if len(password) > 72:
|
||||||
|
password = password[:72]
|
||||||
|
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 更新最后登录时间
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"用户认证失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||||
|
"""根据用户名获取用户"""
|
||||||
|
return self.db.query(User).filter(User.username == username).first()
|
||||||
|
|
||||||
|
def get_user_by_email(self, email: str) -> Optional[User]:
|
||||||
|
"""根据邮箱获取用户"""
|
||||||
|
return self.db.query(User).filter(User.email == email).first()
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
|
"""根据ID获取用户"""
|
||||||
|
return self.db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
def update_user(self, user_id: int, **kwargs) -> Optional[User]:
|
||||||
|
"""更新用户信息"""
|
||||||
|
try:
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(user, key) and value is not None:
|
||||||
|
setattr(user, key, value)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
print(f"更新用户失败: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def deactivate_user(self, user_id: int) -> bool:
|
||||||
|
"""停用用户"""
|
||||||
|
try:
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
print(f"停用用户失败: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||||
|
"""修改密码"""
|
||||||
|
try:
|
||||||
|
user = self.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not verify_password(old_password, user.hashed_password):
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.hashed_password = get_password_hash(new_password)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
print(f"修改密码失败: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_access_token_for_user(self, user: User) -> Dict[str, Any]:
|
||||||
|
"""为用户创建访问令牌"""
|
||||||
|
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": settings.access_token_expire_minutes * 60,
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"is_active": user.is_active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_user_statistics(self) -> Dict[str, Any]:
|
||||||
|
"""获取用户统计信息"""
|
||||||
|
try:
|
||||||
|
total_users = self.db.query(User).count()
|
||||||
|
active_users = self.db.query(User).filter(User.is_active == True).count()
|
||||||
|
recent_users = self.db.query(User).filter(
|
||||||
|
User.created_at >= datetime.utcnow() - timedelta(days=30)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"recent_users": recent_users,
|
||||||
|
"inactive_users": total_users - active_users
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"获取用户统计失败: {str(e)}")
|
||||||
|
return {
|
||||||
|
"total_users": 0,
|
||||||
|
"active_users": 0,
|
||||||
|
"recent_users": 0,
|
||||||
|
"inactive_users": 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
书籍内容读取服务
|
||||||
|
根据数据库中的行号范围从LaTeX文件动态读取内容
|
||||||
|
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models.book_structure import Chapter, Section, Subsection
|
||||||
|
from .latex_parser import LaTeXParser
|
||||||
|
|
||||||
|
|
||||||
|
class BookContentService:
|
||||||
|
"""书籍内容服务"""
|
||||||
|
|
||||||
|
def __init__(self, book_dir: str):
|
||||||
|
"""
|
||||||
|
初始化服务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
book_dir: 书籍目录路径
|
||||||
|
"""
|
||||||
|
# 处理相对路径:如果相对路径不存在,尝试从项目根目录查找
|
||||||
|
book_path = Path(book_dir)
|
||||||
|
if not book_path.exists() or not book_path.is_absolute():
|
||||||
|
# 脚本位置: dofile/backend/src/services/book_content_service.py
|
||||||
|
# 项目根目录: 向上3级
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||||
|
book_path = project_root / "data" / "book"
|
||||||
|
if not book_path.exists():
|
||||||
|
# 如果还是不存在,使用原始路径
|
||||||
|
book_path = Path(book_dir)
|
||||||
|
|
||||||
|
self.book_dir = book_path
|
||||||
|
self.parser = LaTeXParser(self.book_dir)
|
||||||
|
|
||||||
|
def get_chapter_content(self, db: Session, chapter_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取章节完整内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
chapter_id: 章节ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
章节内容文本,如果章节不存在则返回None
|
||||||
|
"""
|
||||||
|
chapter = db.query(Chapter).filter(Chapter.id == chapter_id).first()
|
||||||
|
if not chapter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_path = self.book_dir / Path(chapter.file_path).name
|
||||||
|
content = self.parser.get_content_by_lines(
|
||||||
|
file_path,
|
||||||
|
chapter.start_line,
|
||||||
|
chapter.end_line
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"读取章节内容失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_section_content(self, db: Session, section_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取节完整内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
section_id: 节ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
节内容文本,如果节不存在则返回None
|
||||||
|
"""
|
||||||
|
section = db.query(Section).filter(Section.id == section_id).first()
|
||||||
|
if not section:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 通过section获取chapter以获取文件路径
|
||||||
|
chapter = db.query(Chapter).filter(Chapter.id == section.chapter_id).first()
|
||||||
|
if not chapter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = self.book_dir / Path(chapter.file_path).name
|
||||||
|
content = self.parser.get_content_by_lines(
|
||||||
|
file_path,
|
||||||
|
section.start_line,
|
||||||
|
section.end_line
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"读取节内容失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_subsection_content(self, db: Session, subsection_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取小节完整内容(知识点,包含其下所有subsubsection内容)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
subsection_id: 小节ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
小节内容文本,如果小节不存在则返回None
|
||||||
|
"""
|
||||||
|
subsection = db.query(Subsection).filter(Subsection.id == subsection_id).first()
|
||||||
|
if not subsection:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 通过subsection获取section和chapter以获取文件路径
|
||||||
|
section = db.query(Section).filter(Section.id == subsection.section_id).first()
|
||||||
|
if not section:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chapter = db.query(Chapter).filter(Chapter.id == section.chapter_id).first()
|
||||||
|
if not chapter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_path = self.book_dir / Path(chapter.file_path).name
|
||||||
|
content = self.parser.get_content_by_lines(
|
||||||
|
file_path,
|
||||||
|
subsection.start_line,
|
||||||
|
subsection.end_line
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"读取小节内容失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
文档处理服务(LangChain 1.0)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from langchain_core.documents import Document as LangChainDocument
|
||||||
|
|
||||||
|
from ..models.document import Document, DocumentChunk
|
||||||
|
from ..rag.vector_store import get_vector_store
|
||||||
|
from ..rag.document_loaders import DocumentLoaderFactory
|
||||||
|
from ..rag.text_splitters import get_text_splitter
|
||||||
|
|
||||||
|
class DocumentService:
|
||||||
|
"""文档处理服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.vector_store = get_vector_store()
|
||||||
|
|
||||||
|
async def process_document(self, document_id: int) -> bool:
|
||||||
|
"""处理文档(使用LangChain 1.0)"""
|
||||||
|
try:
|
||||||
|
document = self.db.query(Document).filter(Document.id == document_id).first()
|
||||||
|
if not document:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1. 使用LangChain加载文档
|
||||||
|
documents = DocumentLoaderFactory.load_document(
|
||||||
|
file_path=document.file_path,
|
||||||
|
file_type=document.file_type,
|
||||||
|
metadata={
|
||||||
|
"document_id": document.id,
|
||||||
|
"knowledge_base_id": document.knowledge_base_id,
|
||||||
|
"title": document.title,
|
||||||
|
"filename": document.filename
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 使用中文优化的文本分割器
|
||||||
|
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
|
||||||
|
splits = text_splitter.split_documents(documents)
|
||||||
|
|
||||||
|
# 3. 添加到向量存储
|
||||||
|
success = self.vector_store.add_documents(splits)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
document.is_processed = True
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"处理文档失败: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]:
|
||||||
|
"""搜索文档(保留原有接口兼容性)"""
|
||||||
|
try:
|
||||||
|
# 构建过滤条件
|
||||||
|
filter_dict = None
|
||||||
|
if knowledge_base_ids:
|
||||||
|
filter_dict = {"knowledge_base_id": {"$in": knowledge_base_ids}}
|
||||||
|
|
||||||
|
# 使用LangChain Chroma进行搜索
|
||||||
|
results = self.vector_store.similarity_search_with_score(
|
||||||
|
query=query,
|
||||||
|
k=limit,
|
||||||
|
filter=filter_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# 格式化结果
|
||||||
|
search_results = []
|
||||||
|
for doc, distance in results:
|
||||||
|
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||||
|
score = self._convert_distance_to_score(distance)
|
||||||
|
|
||||||
|
search_results.append({
|
||||||
|
"content": doc.page_content,
|
||||||
|
"metadata": metadata,
|
||||||
|
"score": score,
|
||||||
|
"distance": distance
|
||||||
|
})
|
||||||
|
|
||||||
|
return search_results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"搜索文档失败: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _convert_distance_to_score(self, distance: float) -> float:
|
||||||
|
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
# 内积距离(负值)
|
||||||
|
if distance < 0:
|
||||||
|
return (1 + distance) / 2
|
||||||
|
|
||||||
|
# 大距离使用对数缩放
|
||||||
|
if distance > 100:
|
||||||
|
return 1 / (1 + math.log(distance))
|
||||||
|
|
||||||
|
# 标准距离转换
|
||||||
|
return 1 / (1 + distance)
|
||||||
|
|
||||||
|
def get_document_chunks(self, document_id: int) -> List[DocumentChunk]:
|
||||||
|
"""获取文档的所有块"""
|
||||||
|
return self.db.query(DocumentChunk).filter(
|
||||||
|
DocumentChunk.document_id == document_id
|
||||||
|
).order_by(DocumentChunk.chunk_index).all()
|
||||||
|
|
||||||
|
def delete_document_chunks(self, document_id: int) -> bool:
|
||||||
|
"""删除文档的所有块"""
|
||||||
|
try:
|
||||||
|
self.db.query(DocumentChunk).filter(
|
||||||
|
DocumentChunk.document_id == document_id
|
||||||
|
).delete()
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除文档块失败: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
文件监控服务
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent, FileDeletedEvent
|
||||||
|
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from ..core.database import get_db
|
||||||
|
from .knowledge_base_service import KnowledgeBaseService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseHandler(FileSystemEventHandler):
|
||||||
|
"""知识库文件监控处理器"""
|
||||||
|
|
||||||
|
def __init__(self, knowledge_base_service: KnowledgeBaseService):
|
||||||
|
self.kb_service = knowledge_base_service
|
||||||
|
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
|
||||||
|
self.supported_extensions = settings.allowed_extensions
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
"""处理文件创建事件"""
|
||||||
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
|
print(f"检测到新文件: {event.src_path}")
|
||||||
|
self._process_file_async(event.src_path, "created")
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
"""处理文件修改事件"""
|
||||||
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
|
print(f"检测到文件修改: {event.src_path}")
|
||||||
|
self._process_file_async(event.src_path, "modified")
|
||||||
|
|
||||||
|
def on_deleted(self, event):
|
||||||
|
"""处理文件删除事件"""
|
||||||
|
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||||
|
print(f"检测到文件删除: {event.src_path}")
|
||||||
|
self._handle_file_deletion(event.src_path)
|
||||||
|
|
||||||
|
def _is_supported_file(self, file_path: str) -> bool:
|
||||||
|
"""检查是否为支持的文件类型"""
|
||||||
|
return Path(file_path).suffix.lower() in self.supported_extensions
|
||||||
|
|
||||||
|
def _process_file_async(self, file_path: str, event_type: str):
|
||||||
|
"""异步处理文件"""
|
||||||
|
def process():
|
||||||
|
try:
|
||||||
|
# 等待文件写入完成
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
result = self.kb_service.process_file(file_path)
|
||||||
|
print(f"文件处理结果 ({event_type}): {result}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
||||||
|
|
||||||
|
# 在后台线程中处理
|
||||||
|
thread = threading.Thread(target=process)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _handle_file_deletion(self, file_path: str):
|
||||||
|
"""处理文件删除"""
|
||||||
|
try:
|
||||||
|
# 查找并删除对应的数据库记录
|
||||||
|
from ..models.document import Document
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
document = self.kb_service.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.file_path == file_path,
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if document:
|
||||||
|
result = self.kb_service.delete_document(document.id)
|
||||||
|
print(f"文件删除处理结果: {result}")
|
||||||
|
else:
|
||||||
|
print(f"未找到对应的数据库记录: {file_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class FileWatcherService:
|
||||||
|
"""文件监控服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.observer: Optional[Observer] = None
|
||||||
|
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""启动文件监控"""
|
||||||
|
if self.is_running:
|
||||||
|
print("文件监控服务已在运行")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not settings.enable_file_watcher:
|
||||||
|
print("文件监控服务已禁用")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.knowledge_base_dir.exists():
|
||||||
|
print(f"知识库目录不存在: {self.knowledge_base_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建数据库会话
|
||||||
|
db = next(get_db())
|
||||||
|
kb_service = KnowledgeBaseService(db)
|
||||||
|
|
||||||
|
# 创建事件处理器
|
||||||
|
event_handler = KnowledgeBaseHandler(kb_service)
|
||||||
|
|
||||||
|
# 创建观察者
|
||||||
|
self.observer = Observer()
|
||||||
|
self.observer.schedule(
|
||||||
|
event_handler,
|
||||||
|
str(self.knowledge_base_dir),
|
||||||
|
recursive=True # 递归监控子目录
|
||||||
|
)
|
||||||
|
|
||||||
|
# 启动观察者
|
||||||
|
self.observer.start()
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}")
|
||||||
|
|
||||||
|
# 执行初始扫描
|
||||||
|
print("执行初始知识库扫描...")
|
||||||
|
scan_result = kb_service.scan_directory()
|
||||||
|
print(f"初始扫描结果: {scan_result}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"启动文件监控服务失败: {str(e)}")
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止文件监控"""
|
||||||
|
if self.observer and self.is_running:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join()
|
||||||
|
self.is_running = False
|
||||||
|
print("文件监控服务已停止")
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""检查监控服务是否活跃"""
|
||||||
|
return self.is_running and self.observer and self.observer.is_alive()
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""获取监控服务状态"""
|
||||||
|
return {
|
||||||
|
"is_running": self.is_running,
|
||||||
|
"is_active": self.is_active(),
|
||||||
|
"knowledge_base_dir": str(self.knowledge_base_dir),
|
||||||
|
"directory_exists": self.knowledge_base_dir.exists(),
|
||||||
|
"enable_file_watcher": settings.enable_file_watcher
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 全局文件监控服务实例
|
||||||
|
file_watcher_service: Optional[FileWatcherService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_watcher_service() -> FileWatcherService:
|
||||||
|
"""获取文件监控服务实例"""
|
||||||
|
global file_watcher_service
|
||||||
|
if file_watcher_service is None:
|
||||||
|
file_watcher_service = FileWatcherService()
|
||||||
|
return file_watcher_service
|
||||||
|
|
||||||
|
|
||||||
|
def start_file_watcher():
|
||||||
|
"""启动文件监控服务"""
|
||||||
|
watcher = get_file_watcher_service()
|
||||||
|
watcher.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_file_watcher():
|
||||||
|
"""停止文件监控服务"""
|
||||||
|
watcher = get_file_watcher_service()
|
||||||
|
watcher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
统一图像生成服务基类
|
||||||
|
支持硅基流动平台的多种图像生成模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
import httpx
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ImageGenerationService:
|
||||||
|
"""统一的图像生成服务基类"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = "https://api.siliconflow.cn/v1/image/generations"
|
||||||
|
self.api_key = settings.siliconflow_api_key
|
||||||
|
self.timeout = 60
|
||||||
|
|
||||||
|
async def call_api(self, model: str, payload: dict) -> dict:
|
||||||
|
"""调用硅基流动 API"""
|
||||||
|
print(f"[DEBUG] 调用SiliconFlow API: {self.base_url}")
|
||||||
|
print(f"[DEBUG] 模型: {model}")
|
||||||
|
print(f"[DEBUG] 请求参数: {payload}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
self.base_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={"model": model, **payload}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"[DEBUG] API响应状态: {response.status_code}")
|
||||||
|
print(f"[DEBUG] API响应类型: {type(result)}")
|
||||||
|
print(f"[DEBUG] API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_image(self, image_id: str, base64_data: str) -> str:
|
||||||
|
"""保存 base64 图像到本地文件系统"""
|
||||||
|
try:
|
||||||
|
# 清理base64数据(移除可能的data URL前缀)
|
||||||
|
if base64_data.startswith('data:image'):
|
||||||
|
base64_data = base64_data.split(',')[1]
|
||||||
|
|
||||||
|
# 移除可能的空白字符
|
||||||
|
base64_data = base64_data.strip()
|
||||||
|
|
||||||
|
print(f"[DEBUG] 开始解码base64数据,长度: {len(base64_data)}")
|
||||||
|
|
||||||
|
# 解码 base64 数据
|
||||||
|
image_bytes = base64.b64decode(base64_data)
|
||||||
|
|
||||||
|
# 验证是否为有效的图片数据
|
||||||
|
if len(image_bytes) == 0:
|
||||||
|
raise Exception("解码后的图像数据为空")
|
||||||
|
|
||||||
|
print(f"[DEBUG] 图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
image_dir = Path(settings.generated_images_dir)
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 保存图像文件
|
||||||
|
image_path = image_dir / f"{image_id}.png"
|
||||||
|
with open(image_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||||
|
|
||||||
|
return str(image_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 保存图像失败: {str(e)}")
|
||||||
|
print(f"[ERROR] base64数据长度: {len(base64_data) if base64_data else 0}")
|
||||||
|
print(f"[ERROR] base64数据前100字符: {base64_data[:100] if base64_data else 'None'}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
def generate_image_id(self) -> str:
|
||||||
|
"""生成唯一的图像ID"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
def get_image_url(self, image_id: str) -> str:
|
||||||
|
"""获取图像的访问URL"""
|
||||||
|
return f"/generated_images/{image_id}.png"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""
|
||||||
|
图生图服务
|
||||||
|
支持 Qwen/Qwen-Image-Edit 模型进行图像编辑
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from .image_generation_service import ImageGenerationService
|
||||||
|
|
||||||
|
|
||||||
|
class ImageToImageService(ImageGenerationService):
|
||||||
|
"""图生图服务"""
|
||||||
|
|
||||||
|
MODEL = "Qwen/Qwen-Image-Edit"
|
||||||
|
|
||||||
|
EDIT_MODES = {
|
||||||
|
"optimize": "全图优化增强",
|
||||||
|
"style_transfer": "风格转换",
|
||||||
|
"local_edit": "局部编辑修改",
|
||||||
|
"outpaint": "图像智能扩展"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def edit_image(
|
||||||
|
self,
|
||||||
|
image_file: UploadFile,
|
||||||
|
prompt: str,
|
||||||
|
mode: str = "optimize",
|
||||||
|
mask_file: Optional[UploadFile] = None,
|
||||||
|
strength: float = 0.8 # 保留用于兼容性,API不支持
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""编辑图像"""
|
||||||
|
|
||||||
|
# 验证编辑模式
|
||||||
|
if mode not in self.EDIT_MODES:
|
||||||
|
raise ValueError(f"不支持的编辑模式: {mode}")
|
||||||
|
|
||||||
|
# 读取图像数据
|
||||||
|
image_data = await image_file.read()
|
||||||
|
image_b64 = base64.b64encode(image_data).decode()
|
||||||
|
|
||||||
|
# 构建 API 请求参数
|
||||||
|
# 注意:Qwen-Image-Edit 不支持 strength 和 image_size 参数
|
||||||
|
payload = {
|
||||||
|
"prompt": self._build_edit_prompt(prompt, mode),
|
||||||
|
"image": f"data:image/png;base64,{image_b64}", # 使用正确的 base64 格式
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果是局部编辑,添加蒙版(注意:API可能不支持mask)
|
||||||
|
if mode == "local_edit" and mask_file:
|
||||||
|
mask_data = await mask_file.read()
|
||||||
|
mask_b64 = base64.b64encode(mask_data).decode()
|
||||||
|
payload["mask_image"] = f"data:image/png;base64,{mask_b64}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 调用 API
|
||||||
|
result = await self.call_api(self.MODEL, payload)
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
print(f"API响应结构: {type(result)}")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
print(f"响应键: {list(result.keys())}")
|
||||||
|
if "images" in result:
|
||||||
|
print(f"图像数量: {len(result['images'])}")
|
||||||
|
if result["images"]:
|
||||||
|
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||||
|
|
||||||
|
# 保存结果
|
||||||
|
image_id = self.generate_image_id()
|
||||||
|
edited_image_data = result["images"][0]
|
||||||
|
|
||||||
|
# 检查是否为字典类型
|
||||||
|
if isinstance(edited_image_data, dict):
|
||||||
|
print(f"[DEBUG] 图像数据是字典,键: {list(edited_image_data.keys())}")
|
||||||
|
|
||||||
|
# 优先检查URL字段
|
||||||
|
if "url" in edited_image_data:
|
||||||
|
image_url = edited_image_data["url"]
|
||||||
|
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
|
# 如果没有URL,尝试base64字段
|
||||||
|
elif any(key in edited_image_data for key in ["b64_json", "b64", "data"]):
|
||||||
|
image_b64 = edited_image_data.get("b64_json") or edited_image_data.get("b64") or edited_image_data.get("data")
|
||||||
|
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, image_b64)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||||
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
|
elif isinstance(edited_image_data, str):
|
||||||
|
print(f"[DEBUG] 图像数据是字符串,长度: {len(edited_image_data)}")
|
||||||
|
if edited_image_data.startswith('http'):
|
||||||
|
print(f"[DEBUG] 检测到URL字符串: {edited_image_data}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, edited_image_data)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG] 检测到base64字符串")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, edited_image_data)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未知的图像数据类型: {type(edited_image_data)}")
|
||||||
|
raise Exception(f"不支持的图像数据类型: {type(edited_image_data)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": image_id,
|
||||||
|
"url": self.get_image_url(image_id),
|
||||||
|
"original_filename": image_file.filename,
|
||||||
|
"edit_prompt": prompt,
|
||||||
|
"mode": mode,
|
||||||
|
"metadata": {
|
||||||
|
"strength": strength,
|
||||||
|
"has_mask": mask_file is not None,
|
||||||
|
"file_path": image_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"图像编辑失败: {str(e)}")
|
||||||
|
|
||||||
|
async def create_variation(
|
||||||
|
self,
|
||||||
|
image_file: UploadFile,
|
||||||
|
num_variations: int = 3
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""生成图像变体"""
|
||||||
|
|
||||||
|
# 读取图像数据
|
||||||
|
image_data = await image_file.read()
|
||||||
|
image_b64 = base64.b64encode(image_data).decode()
|
||||||
|
|
||||||
|
# 构建 API 请求参数
|
||||||
|
payload = {
|
||||||
|
"image": image_b64,
|
||||||
|
"prompt": "create variations",
|
||||||
|
"batch_size": num_variations,
|
||||||
|
"strength": 0.6 # 变体强度
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 调用 API
|
||||||
|
result = await self.call_api(self.MODEL, payload)
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
print(f"API响应结构: {type(result)}")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
print(f"响应键: {list(result.keys())}")
|
||||||
|
if "images" in result:
|
||||||
|
print(f"图像数量: {len(result['images'])}")
|
||||||
|
|
||||||
|
# 处理变体结果
|
||||||
|
variations = []
|
||||||
|
for i, img_data in enumerate(result["images"]):
|
||||||
|
print(f"[DEBUG] 处理第 {i+1} 个变体")
|
||||||
|
image_id = self.generate_image_id()
|
||||||
|
|
||||||
|
# 检查是否为字典类型
|
||||||
|
if isinstance(img_data, dict):
|
||||||
|
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||||
|
|
||||||
|
# 优先检查URL字段
|
||||||
|
if "url" in img_data:
|
||||||
|
image_url = img_data["url"]
|
||||||
|
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
|
# 如果没有URL,尝试base64字段
|
||||||
|
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||||
|
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||||
|
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, image_b64)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||||
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
|
elif isinstance(img_data, str):
|
||||||
|
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||||
|
if img_data.startswith('http'):
|
||||||
|
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, img_data)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG] 检测到base64字符串")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, img_data)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||||
|
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||||
|
|
||||||
|
variations.append({
|
||||||
|
"id": image_id,
|
||||||
|
"url": self.get_image_url(image_id),
|
||||||
|
"type": "variation",
|
||||||
|
"metadata": {
|
||||||
|
"original_filename": image_file.filename,
|
||||||
|
"file_path": image_path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return variations
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"生成图像变体失败: {str(e)}")
|
||||||
|
|
||||||
|
async def style_transfer(
|
||||||
|
self,
|
||||||
|
image_file: UploadFile,
|
||||||
|
style_prompt: str,
|
||||||
|
strength: float = 0.8 # 保留参数用于兼容性,但不传递给API
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""风格转换"""
|
||||||
|
return await self.edit_image(
|
||||||
|
image_file=image_file,
|
||||||
|
prompt=style_prompt,
|
||||||
|
mode="style_transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def optimize_image(
|
||||||
|
self,
|
||||||
|
image_file: UploadFile,
|
||||||
|
optimization_prompt: str = "优化图像质量,增强细节,提高清晰度",
|
||||||
|
strength: float = 0.6 # 保留参数用于兼容性,但不传递给API
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""图像优化"""
|
||||||
|
return await self.edit_image(
|
||||||
|
image_file=image_file,
|
||||||
|
prompt=optimization_prompt,
|
||||||
|
mode="optimize"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def outpaint_image(
|
||||||
|
self,
|
||||||
|
image_file: UploadFile,
|
||||||
|
expansion_prompt: str,
|
||||||
|
strength: float = 0.7 # 保留参数用于兼容性,但不传递给API
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""图像智能扩展"""
|
||||||
|
return await self.edit_image(
|
||||||
|
image_file=image_file,
|
||||||
|
prompt=expansion_prompt,
|
||||||
|
mode="outpaint"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_edit_prompt(self, prompt: str, mode: str) -> str:
|
||||||
|
"""构建编辑提示词"""
|
||||||
|
mode_prefix = {
|
||||||
|
"optimize": "优化和增强图像质量,",
|
||||||
|
"style_transfer": "转换为以下风格:",
|
||||||
|
"local_edit": "局部修改:",
|
||||||
|
"outpaint": "智能扩展图像,保持连贯性,"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = mode_prefix.get(mode, "")
|
||||||
|
return f"{prefix}{prompt}"
|
||||||
|
|
||||||
|
def get_available_modes(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取可用的编辑模式"""
|
||||||
|
return [
|
||||||
|
{"id": "optimize", "name": "全图优化", "description": "优化和增强图像质量,提高清晰度"},
|
||||||
|
{"id": "style_transfer", "name": "风格转换", "description": "将图像转换为指定风格"},
|
||||||
|
{"id": "local_edit", "name": "局部编辑", "description": "使用蒙版进行局部修改"},
|
||||||
|
{"id": "outpaint", "name": "图像扩展", "description": "智能扩展图像边界,保持连贯性"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_style_presets(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取预设风格选项"""
|
||||||
|
return [
|
||||||
|
{"id": "realistic", "name": "真实感", "description": "转换为真实摄影风格"},
|
||||||
|
{"id": "artistic", "name": "艺术化", "description": "转换为艺术绘画风格"},
|
||||||
|
{"id": "technical", "name": "技术图", "description": "转换为技术图纸风格"},
|
||||||
|
{"id": "sketch", "name": "素描", "description": "转换为素描手绘风格"},
|
||||||
|
{"id": "watercolor", "name": "水彩", "description": "转换为水彩画风格"},
|
||||||
|
{"id": "oil_painting", "name": "油画", "description": "转换为油画风格"}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||||
|
"""从URL下载图像"""
|
||||||
|
try:
|
||||||
|
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||||
|
|
||||||
|
# 下载图像
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(image_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
image_bytes = response.content
|
||||||
|
|
||||||
|
if len(image_bytes) == 0:
|
||||||
|
raise Exception("下载的图像数据为空")
|
||||||
|
|
||||||
|
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
image_dir = Path(self.get_image_dir())
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 保存图像文件
|
||||||
|
image_path = image_dir / f"{image_id}.png"
|
||||||
|
with open(image_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||||
|
return str(image_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_image_dir(self) -> str:
|
||||||
|
"""获取图像目录"""
|
||||||
|
from src.core.config import settings
|
||||||
|
return settings.generated_images_dir
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""
|
||||||
|
知识库管理服务
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
from ..models.document import Document, DocumentChunk
|
||||||
|
from ..core.config import get_settings
|
||||||
|
from .document_service import DocumentService
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseService:
|
||||||
|
"""知识库管理服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.document_service = DocumentService(db)
|
||||||
|
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
|
||||||
|
|
||||||
|
def scan_directory(self, directory: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""扫描知识库目录并批量导入"""
|
||||||
|
if directory is None:
|
||||||
|
directory = self.knowledge_base_dir
|
||||||
|
else:
|
||||||
|
directory = Path(directory)
|
||||||
|
|
||||||
|
if not directory.exists():
|
||||||
|
return {"success": False, "message": f"目录不存在: {directory}"}
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"scanned_files": 0,
|
||||||
|
"new_files": 0,
|
||||||
|
"updated_files": 0,
|
||||||
|
"skipped_files": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 递归扫描目录
|
||||||
|
for file_path in directory.rglob("*"):
|
||||||
|
if file_path.is_file() and self._is_supported_file(file_path):
|
||||||
|
try:
|
||||||
|
result = self.process_file(str(file_path))
|
||||||
|
results["scanned_files"] += 1
|
||||||
|
|
||||||
|
if result["status"] == "new":
|
||||||
|
results["new_files"] += 1
|
||||||
|
elif result["status"] == "updated":
|
||||||
|
results["updated_files"] += 1
|
||||||
|
elif result["status"] == "skipped":
|
||||||
|
results["skipped_files"] += 1
|
||||||
|
elif result["status"] == "error":
|
||||||
|
results["errors"].append({
|
||||||
|
"file": str(file_path),
|
||||||
|
"error": result["error"]
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({
|
||||||
|
"file": str(file_path),
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
results["success"] = len(results["errors"]) == 0
|
||||||
|
return results
|
||||||
|
|
||||||
|
def process_file(self, file_path: str) -> Dict[str, Any]:
|
||||||
|
"""处理单个文件(检查、提取、入库)"""
|
||||||
|
try:
|
||||||
|
file_path = Path(file_path)
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
return {"status": "error", "error": "文件不存在"}
|
||||||
|
|
||||||
|
# 检查文件类型
|
||||||
|
if not self._is_supported_file(file_path):
|
||||||
|
return {"status": "skipped", "message": "不支持的文件类型"}
|
||||||
|
|
||||||
|
# 获取文件信息
|
||||||
|
file_stat = file_path.stat()
|
||||||
|
file_size = file_stat.st_size
|
||||||
|
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
|
||||||
|
|
||||||
|
# 计算文件哈希
|
||||||
|
file_hash = self._calculate_file_hash(file_path)
|
||||||
|
|
||||||
|
# 检查文件是否已存在
|
||||||
|
existing_doc = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.file_path == str(file_path),
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_doc:
|
||||||
|
# 检查是否需要更新
|
||||||
|
if (existing_doc.last_modified and
|
||||||
|
existing_doc.last_modified >= last_modified and
|
||||||
|
existing_doc.file_hash == file_hash):
|
||||||
|
return {"status": "skipped", "message": "文件未修改"}
|
||||||
|
|
||||||
|
# 更新现有文档
|
||||||
|
return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
|
||||||
|
else:
|
||||||
|
# 创建新文档
|
||||||
|
return self._create_document(file_path, file_size, last_modified, file_hash)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
def _is_supported_file(self, file_path: Path) -> bool:
|
||||||
|
"""检查是否为支持的文件类型"""
|
||||||
|
return file_path.suffix.lower() in settings.allowed_extensions
|
||||||
|
|
||||||
|
def _calculate_file_hash(self, file_path: Path) -> str:
|
||||||
|
"""计算文件哈希"""
|
||||||
|
hash_md5 = hashlib.md5()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
hash_md5.update(chunk)
|
||||||
|
return hash_md5.hexdigest()
|
||||||
|
|
||||||
|
def _create_document(self, file_path: Path, file_size: int, last_modified: datetime, file_hash: str,
|
||||||
|
knowledge_base_id: Optional[int] = None, user_id: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
"""创建新文档"""
|
||||||
|
try:
|
||||||
|
# 计算相对路径用于描述
|
||||||
|
try:
|
||||||
|
relative_path = file_path.relative_to(self.knowledge_base_dir)
|
||||||
|
except ValueError:
|
||||||
|
relative_path = file_path.name
|
||||||
|
|
||||||
|
# 创建文档记录
|
||||||
|
document = Document(
|
||||||
|
user_id=user_id, # 如果指定了user_id则使用,否则为None(系统文档)
|
||||||
|
knowledge_base_id=knowledge_base_id, # 关联知识库
|
||||||
|
filename=file_path.name,
|
||||||
|
original_filename=file_path.name,
|
||||||
|
file_path=str(file_path),
|
||||||
|
file_size=file_size,
|
||||||
|
file_type=file_path.suffix.lower(),
|
||||||
|
title=file_path.stem,
|
||||||
|
description=f"知识库文档: {relative_path}",
|
||||||
|
is_processed=False,
|
||||||
|
is_public=True,
|
||||||
|
source_type="knowledge_base",
|
||||||
|
last_modified=last_modified,
|
||||||
|
file_hash=file_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(document)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(document)
|
||||||
|
|
||||||
|
# 处理文档(向量化)- 在后台异步处理,不阻塞主流程
|
||||||
|
try:
|
||||||
|
# 使用同步方法,但不等待完成(在后台处理)
|
||||||
|
import threading
|
||||||
|
def process_in_background():
|
||||||
|
try:
|
||||||
|
self.document_service.process_document(document.id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"后台处理文档 {document.id} 失败: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=process_in_background, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"启动文档处理线程失败: {e}")
|
||||||
|
|
||||||
|
return {"status": "new", "document_id": document.id, "message": "文档创建成功,正在处理中"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_document(self, document: Document, file_path: Path, file_size: int, last_modified: datetime, file_hash: str) -> Dict[str, Any]:
|
||||||
|
"""更新现有文档"""
|
||||||
|
try:
|
||||||
|
# 更新文档信息
|
||||||
|
document.file_size = file_size
|
||||||
|
document.last_modified = last_modified
|
||||||
|
document.file_hash = file_hash
|
||||||
|
document.is_processed = False # 标记为未处理,需要重新处理
|
||||||
|
|
||||||
|
# 删除旧的文档块
|
||||||
|
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document.id).delete()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# 重新处理文档 - 在后台异步处理
|
||||||
|
try:
|
||||||
|
import threading
|
||||||
|
def process_in_background():
|
||||||
|
try:
|
||||||
|
self.document_service.process_document(document.id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"后台处理文档 {document.id} 失败: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=process_in_background, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"启动文档处理线程失败: {e}")
|
||||||
|
|
||||||
|
return {"status": "updated", "document_id": document.id, "message": "文档更新成功,正在重新处理中"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
def is_file_updated(self, file_path: str) -> bool:
|
||||||
|
"""检查文件是否需要更新"""
|
||||||
|
try:
|
||||||
|
file_path = Path(file_path)
|
||||||
|
if not file_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 获取文件信息
|
||||||
|
file_stat = file_path.stat()
|
||||||
|
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
|
||||||
|
file_hash = self._calculate_file_hash(file_path)
|
||||||
|
|
||||||
|
# 查询数据库中的记录
|
||||||
|
existing_doc = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.file_path == str(file_path),
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_doc:
|
||||||
|
return True # 新文件
|
||||||
|
|
||||||
|
# 检查修改时间和哈希
|
||||||
|
if (existing_doc.last_modified and
|
||||||
|
existing_doc.last_modified < last_modified):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if existing_doc.file_hash != file_hash:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return True # 出错时默认需要更新
|
||||||
|
|
||||||
|
def get_knowledge_base_status(self) -> Dict[str, Any]:
|
||||||
|
"""获取知识库状态"""
|
||||||
|
try:
|
||||||
|
# 统计文档数量
|
||||||
|
total_docs = self.db.query(Document).filter(
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).count()
|
||||||
|
|
||||||
|
processed_docs = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.source_type == "knowledge_base",
|
||||||
|
Document.is_processed == True
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 统计文件大小
|
||||||
|
total_size = self.db.query(Document).filter(
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).with_entities(Document.file_size).all()
|
||||||
|
|
||||||
|
total_size_bytes = sum(size[0] for size in total_size) if total_size else 0
|
||||||
|
|
||||||
|
# 统计文件类型
|
||||||
|
file_types = {}
|
||||||
|
docs_by_type = self.db.query(Document.file_type).filter(
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for file_type in docs_by_type:
|
||||||
|
file_type_str = file_type[0]
|
||||||
|
file_types[file_type_str] = file_types.get(file_type_str, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_documents": total_docs,
|
||||||
|
"processed_documents": processed_docs,
|
||||||
|
"unprocessed_documents": total_docs - processed_docs,
|
||||||
|
"total_size_bytes": total_size_bytes,
|
||||||
|
"total_size_mb": round(total_size_bytes / (1024 * 1024), 2),
|
||||||
|
"file_types": file_types,
|
||||||
|
"knowledge_base_dir": str(self.knowledge_base_dir),
|
||||||
|
"directory_exists": self.knowledge_base_dir.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def delete_document(self, document_id: int) -> Dict[str, Any]:
|
||||||
|
"""删除知识库文档"""
|
||||||
|
try:
|
||||||
|
document = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.id == document_id,
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return {"success": False, "message": "文档不存在"}
|
||||||
|
|
||||||
|
# 删除文档块
|
||||||
|
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
||||||
|
|
||||||
|
# 删除文档记录
|
||||||
|
self.db.delete(document)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "文档删除成功"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def reindex_document(self, document_id: int) -> Dict[str, Any]:
|
||||||
|
"""重新索引指定文档"""
|
||||||
|
try:
|
||||||
|
document = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.id == document_id,
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return {"success": False, "message": "文档不存在"}
|
||||||
|
|
||||||
|
# 删除旧的文档块
|
||||||
|
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
||||||
|
|
||||||
|
# 重新处理文档
|
||||||
|
success = self.document_service.process_document(document_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return {"success": True, "message": "文档重新索引成功"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "message": "文档重新索引失败"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def import_files_to_knowledge_base(
|
||||||
|
self,
|
||||||
|
knowledge_base_id: int,
|
||||||
|
directory: Path,
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从指定目录导入真实文件到知识库
|
||||||
|
|
||||||
|
Args:
|
||||||
|
knowledge_base_id: 知识库ID
|
||||||
|
directory: 要扫描的目录路径
|
||||||
|
user_id: 用户ID(可选,用于系统知识库时可为None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含统计信息的字典
|
||||||
|
"""
|
||||||
|
if isinstance(directory, str):
|
||||||
|
directory = Path(directory)
|
||||||
|
|
||||||
|
if not directory.exists():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"目录不存在: {directory}",
|
||||||
|
"scanned_files": 0,
|
||||||
|
"new_files": 0,
|
||||||
|
"updated_files": 0,
|
||||||
|
"skipped_files": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"scanned_files": 0,
|
||||||
|
"new_files": 0,
|
||||||
|
"updated_files": 0,
|
||||||
|
"skipped_files": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 递归扫描目录下的所有真实文件
|
||||||
|
all_files = [f for f in directory.rglob("*") if f.is_file() and self._is_supported_file(f)]
|
||||||
|
total_files = len(all_files)
|
||||||
|
|
||||||
|
print(f" 找到 {total_files} 个支持的文件,开始处理...")
|
||||||
|
|
||||||
|
for idx, file_path in enumerate(all_files, 1):
|
||||||
|
try:
|
||||||
|
# 获取文件信息
|
||||||
|
file_stat = file_path.stat()
|
||||||
|
file_size = file_stat.st_size
|
||||||
|
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
|
||||||
|
file_hash = self._calculate_file_hash(file_path)
|
||||||
|
|
||||||
|
# 检查文件是否已存在于该知识库中
|
||||||
|
existing_doc = self.db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.file_path == str(file_path),
|
||||||
|
Document.knowledge_base_id == knowledge_base_id,
|
||||||
|
Document.source_type == "knowledge_base"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
results["scanned_files"] += 1
|
||||||
|
|
||||||
|
# 显示进度(每10个文件或最后一个文件时显示)
|
||||||
|
if idx % 10 == 0 or idx == total_files:
|
||||||
|
percentage = (idx * 100) // total_files if total_files > 0 else 0
|
||||||
|
print(f"\r 处理进度: {idx}/{total_files} ({percentage}%)", end="", flush=True)
|
||||||
|
|
||||||
|
if existing_doc:
|
||||||
|
# 检查是否需要更新
|
||||||
|
if (existing_doc.last_modified and
|
||||||
|
existing_doc.last_modified >= last_modified and
|
||||||
|
existing_doc.file_hash == file_hash):
|
||||||
|
results["skipped_files"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 更新现有文档
|
||||||
|
result = self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
|
||||||
|
if result["status"] == "updated":
|
||||||
|
results["updated_files"] += 1
|
||||||
|
else:
|
||||||
|
results["errors"].append({
|
||||||
|
"file": str(file_path),
|
||||||
|
"error": result.get("error", "更新失败")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 创建新文档
|
||||||
|
result = self._create_document(
|
||||||
|
file_path,
|
||||||
|
file_size,
|
||||||
|
last_modified,
|
||||||
|
file_hash,
|
||||||
|
knowledge_base_id=knowledge_base_id,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
if result["status"] == "new":
|
||||||
|
results["new_files"] += 1
|
||||||
|
else:
|
||||||
|
results["errors"].append({
|
||||||
|
"file": str(file_path),
|
||||||
|
"error": result.get("error", "创建失败")
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({
|
||||||
|
"file": str(file_path),
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
print() # 换行
|
||||||
|
results["success"] = len(results["errors"]) == 0
|
||||||
|
return results
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
LaTeX文件解析器
|
||||||
|
解析章节文件,提取章节、节、小节的标题和内容
|
||||||
|
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class LaTeXParser:
|
||||||
|
"""LaTeX解析器"""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path):
|
||||||
|
"""
|
||||||
|
初始化解析器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_dir: 书籍目录路径
|
||||||
|
"""
|
||||||
|
self.base_dir = base_dir
|
||||||
|
|
||||||
|
def parse_chapter_file(self, file_path: Path) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
解析单个LaTeX章节文件,提取章节、节、小节的标题和行号。
|
||||||
|
|
||||||
|
层级结构:
|
||||||
|
- \chapter{} -> Chapter
|
||||||
|
- \section{} -> Section
|
||||||
|
- \subsection{} -> Subsection(知识点,包含其下所有subsubsection内容)
|
||||||
|
- \subsubsection{} -> 忽略,内容合并到subsection中
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含章节结构的列表,格式:
|
||||||
|
[{
|
||||||
|
"type": "chapter",
|
||||||
|
"title": "...",
|
||||||
|
"start_line": 1,
|
||||||
|
"end_line": 100,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "...",
|
||||||
|
"start_line": 5,
|
||||||
|
"end_line": 50,
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"type": "subsection",
|
||||||
|
"title": "...",
|
||||||
|
"start_line": 10,
|
||||||
|
"end_line": 30,
|
||||||
|
"subsection_number": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"section_number": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chapter_number": 1
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
content = file_path.read_text(encoding='utf-8')
|
||||||
|
lines = content.splitlines()
|
||||||
|
structure = []
|
||||||
|
|
||||||
|
# Regex for \chapter, \section, \subsection, \subsubsection
|
||||||
|
chapter_re = re.compile(r'^\\chapter\*?{(.*?)}')
|
||||||
|
section_re = re.compile(r'^\\section\*?{(.*?)}')
|
||||||
|
subsection_re = re.compile(r'^\\subsection\*?{(.*?)}')
|
||||||
|
subsubsection_re = re.compile(r'^\\subsubsection\*?{(.*?)}')
|
||||||
|
|
||||||
|
current_chapter = None
|
||||||
|
current_section = None
|
||||||
|
current_subsection = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line_num = i + 1 # 1-based line number
|
||||||
|
|
||||||
|
# Try to match chapter first (highest level)
|
||||||
|
match_chapter = chapter_re.match(line)
|
||||||
|
if match_chapter:
|
||||||
|
# Finalize previous chapter's end_line
|
||||||
|
if current_chapter:
|
||||||
|
if current_section:
|
||||||
|
current_section["end_line"] = line_num - 1
|
||||||
|
current_chapter["end_line"] = line_num - 1
|
||||||
|
|
||||||
|
title = match_chapter.group(1).strip()
|
||||||
|
# Extract chapter number from filename if possible
|
||||||
|
chapter_num_match = re.search(r'chapter(\d+)', file_path.name)
|
||||||
|
chapter_number = int(chapter_num_match.group(1)) if chapter_num_match else len(structure) + 1
|
||||||
|
|
||||||
|
current_chapter = {
|
||||||
|
"type": "chapter",
|
||||||
|
"title": title,
|
||||||
|
"start_line": line_num,
|
||||||
|
"end_line": line_num, # Will be updated later
|
||||||
|
"chapter_number": chapter_number,
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
structure.append(current_chapter)
|
||||||
|
current_section = None
|
||||||
|
current_subsection = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to match section
|
||||||
|
match_section = section_re.match(line)
|
||||||
|
if match_section:
|
||||||
|
# Finalize previous section's end_line
|
||||||
|
if current_section:
|
||||||
|
if current_subsection:
|
||||||
|
current_subsection["end_line"] = line_num - 1
|
||||||
|
current_section["end_line"] = line_num - 1
|
||||||
|
|
||||||
|
title = match_section.group(1).strip()
|
||||||
|
# Remove numbering if present in title
|
||||||
|
title = re.sub(r'^\d+(\.\d+)*\.\s*', '', title)
|
||||||
|
|
||||||
|
if current_chapter:
|
||||||
|
section_number = len(current_chapter["sections"]) + 1
|
||||||
|
current_section = {
|
||||||
|
"type": "section",
|
||||||
|
"title": title,
|
||||||
|
"start_line": line_num,
|
||||||
|
"end_line": line_num, # Will be updated later
|
||||||
|
"section_number": section_number,
|
||||||
|
"subsections": []
|
||||||
|
}
|
||||||
|
current_chapter["sections"].append(current_section)
|
||||||
|
current_subsection = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to match subsection (知识点)
|
||||||
|
match_subsection = subsection_re.match(line)
|
||||||
|
if match_subsection:
|
||||||
|
# Finalize previous subsection's end_line
|
||||||
|
if current_subsection and current_section:
|
||||||
|
current_subsection["end_line"] = line_num - 1
|
||||||
|
|
||||||
|
title = match_subsection.group(1).strip()
|
||||||
|
# Remove numbering if present in title
|
||||||
|
title = re.sub(r'^\d+(\.\d+)*\.\s*', '', title)
|
||||||
|
|
||||||
|
# Ensure current_section exists before adding subsection
|
||||||
|
if current_section:
|
||||||
|
subsection_number = len(current_section["subsections"]) + 1
|
||||||
|
current_subsection = {
|
||||||
|
"type": "subsection",
|
||||||
|
"title": title,
|
||||||
|
"start_line": line_num,
|
||||||
|
"end_line": line_num, # Will be updated later, includes all subsubsections
|
||||||
|
"subsection_number": subsection_number
|
||||||
|
}
|
||||||
|
current_section["subsections"].append(current_subsection)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to match subsubsection (忽略,但内容会包含在subsection中)
|
||||||
|
match_subsubsection = subsubsection_re.match(line)
|
||||||
|
if match_subsubsection:
|
||||||
|
# Subsubsection内容会被包含在当前subsection中
|
||||||
|
# 不需要单独处理,subsection的end_line会包含所有subsubsection
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Finalize the end_line for the last chapter/section/subsection
|
||||||
|
if current_chapter:
|
||||||
|
if current_section:
|
||||||
|
if current_subsection:
|
||||||
|
current_subsection["end_line"] = len(lines)
|
||||||
|
current_section["end_line"] = len(lines)
|
||||||
|
current_chapter["end_line"] = len(lines)
|
||||||
|
|
||||||
|
# Post-process to ensure end_lines are correctly set for all elements
|
||||||
|
for chap in structure:
|
||||||
|
if chap["end_line"] == chap["start_line"]:
|
||||||
|
chap["end_line"] = len(lines)
|
||||||
|
for sec in chap["sections"]:
|
||||||
|
if sec["end_line"] == sec["start_line"]:
|
||||||
|
sec["end_line"] = len(lines)
|
||||||
|
for sub in sec["subsections"]:
|
||||||
|
if sub["end_line"] == sub["start_line"]:
|
||||||
|
# Find next subsection or end of section
|
||||||
|
next_subsection_start = None
|
||||||
|
for other_sub in sec["subsections"]:
|
||||||
|
if other_sub["start_line"] > sub["start_line"]:
|
||||||
|
next_subsection_start = other_sub["start_line"]
|
||||||
|
break
|
||||||
|
if next_subsection_start:
|
||||||
|
sub["end_line"] = next_subsection_start - 1
|
||||||
|
else:
|
||||||
|
sub["end_line"] = sec["end_line"]
|
||||||
|
|
||||||
|
return structure
|
||||||
|
|
||||||
|
def get_content_by_lines(self, file_path: Path, start_line: int, end_line: int) -> str:
|
||||||
|
"""
|
||||||
|
从指定文件的指定行范围读取内容。
|
||||||
|
包含subsection及其下所有subsubsection的内容。
|
||||||
|
"""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = file_path.read_text(encoding='utf-8').splitlines()
|
||||||
|
|
||||||
|
# Adjust for 0-based indexing in Python list
|
||||||
|
start_idx = max(0, start_line - 1)
|
||||||
|
end_idx = min(len(lines), end_line)
|
||||||
|
|
||||||
|
content_lines = lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
# 移除LaTeX命令,只保留纯文本
|
||||||
|
cleaned_content = self._clean_latex_commands("\n".join(content_lines))
|
||||||
|
return cleaned_content
|
||||||
|
|
||||||
|
def _convert_latex_table_to_markdown(self, table_content: str) -> str:
|
||||||
|
"""
|
||||||
|
将LaTeX表格转换为Markdown格式。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table_content: LaTeX表格内容(不包含\begin{tabular}和\end{tabular})
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown格式的表格字符串
|
||||||
|
"""
|
||||||
|
if not table_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 先按 \\ 分割行(LaTeX表格使用 \\ 作为行分隔符)
|
||||||
|
raw_lines = table_content.split('\\\\')
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for raw_line in raw_lines:
|
||||||
|
line = raw_line.strip()
|
||||||
|
# 移除行内的 \hline 命令
|
||||||
|
line = re.sub(r'\\hline', '', line)
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 分割单元格(使用 & 分隔符)
|
||||||
|
cells = [cell.strip() for cell in line.split('&')]
|
||||||
|
|
||||||
|
# 清理单元格内容(移除LaTeX命令)
|
||||||
|
cleaned_cells = []
|
||||||
|
for cell in cells:
|
||||||
|
# 移除常见的LaTeX命令,但保留文本内容
|
||||||
|
cell = re.sub(r'\\(textbf|textit|emph|text)\{([^}]+)\}', r'\2', cell)
|
||||||
|
# 移除其他带大括号的命令
|
||||||
|
cell = re.sub(r'\\[a-zA-Z]+\{[^}]*\}', '', cell)
|
||||||
|
# 移除单独的LaTeX命令
|
||||||
|
cell = re.sub(r'\\([a-zA-Z]+)', '', cell)
|
||||||
|
cell = cell.strip()
|
||||||
|
cleaned_cells.append(cell)
|
||||||
|
|
||||||
|
# 过滤掉空行
|
||||||
|
if cleaned_cells and any(cell for cell in cleaned_cells):
|
||||||
|
rows.append(cleaned_cells)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 转换为Markdown表格
|
||||||
|
markdown_lines = []
|
||||||
|
|
||||||
|
# 表头(第一行)
|
||||||
|
if rows:
|
||||||
|
header = rows[0]
|
||||||
|
if not header or not any(header):
|
||||||
|
if len(rows) > 1:
|
||||||
|
header = rows[1]
|
||||||
|
rows = rows[1:]
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
markdown_lines.append('| ' + ' | '.join(header) + ' |')
|
||||||
|
markdown_lines.append('| ' + ' | '.join(['---'] * len(header)) + ' |')
|
||||||
|
|
||||||
|
# 数据行(跳过表头)
|
||||||
|
for row in rows[1:]:
|
||||||
|
while len(row) < len(header):
|
||||||
|
row.append('')
|
||||||
|
markdown_lines.append('| ' + ' | '.join(row[:len(header)]) + ' |')
|
||||||
|
|
||||||
|
return '\n'.join(markdown_lines)
|
||||||
|
|
||||||
|
def _clean_latex_commands(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
移除LaTeX命令和环境,保留纯文本。
|
||||||
|
注意:保留段落文本内容和表格(转换为Markdown格式)。
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 移除 \chapter{}, \section{}, \subsection{}, \subsubsection{} 命令行
|
||||||
|
text = re.sub(r'^\s*\\(chapter|section|subsection|subsubsection)\*?{.*?}\s*$', '', text, flags=re.MULTILINE)
|
||||||
|
# 移除 \label{}, \ref{}, \cite{} 等命令(但保留内容)
|
||||||
|
text = re.sub(r'\\label\{[^}]+\}', '', text)
|
||||||
|
text = re.sub(r'\\ref\{[^}]+\}', '', text)
|
||||||
|
text = re.sub(r'\\cite\{[^}]+\}', '', text)
|
||||||
|
|
||||||
|
# 处理表格:将LaTeX表格转换为Markdown表格
|
||||||
|
def replace_table(match):
|
||||||
|
table_content = match.group(0)
|
||||||
|
# 提取tabular环境内容
|
||||||
|
tabular_pattern = r'\\begin\{tabular\}[^{]*\{[^}]*\}(.*?)\\end\{tabular\}'
|
||||||
|
tabular_match = re.search(tabular_pattern, table_content, re.DOTALL)
|
||||||
|
if tabular_match:
|
||||||
|
inner_content = tabular_match.group(1)
|
||||||
|
markdown_table = self._convert_latex_table_to_markdown(inner_content)
|
||||||
|
return '\n\n' + markdown_table + '\n\n' if markdown_table else ''
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# 先处理完整的table环境(包含tabular)
|
||||||
|
text = re.sub(r'\\begin\{table\}.*?\\end\{table\}', replace_table, text, flags=re.DOTALL)
|
||||||
|
# 再处理单独的tabular环境(如果table环境没有匹配到)
|
||||||
|
text = re.sub(r'\\begin\{tabular\}.*?\\end\{tabular\}', replace_table, text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# 移除图片环境
|
||||||
|
text = re.sub(r'\\begin\{figure\}.*?\\end\{figure\}', '', text, flags=re.DOTALL)
|
||||||
|
# 移除表格相关的LaTeX命令
|
||||||
|
text = re.sub(r'\\(centering|caption|toprule|midrule|bottomrule|hline)\b', '', text)
|
||||||
|
# 移除注释
|
||||||
|
text = re.sub(r'%.*$', '', text, flags=re.MULTILINE)
|
||||||
|
# 移除多余的空行(保留段落间的空行)
|
||||||
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||||
|
# 移除行首尾的空白
|
||||||
|
lines = [line.strip() for line in text.split('\n')]
|
||||||
|
text = '\n'.join(lines)
|
||||||
|
return text.strip()
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
文生图服务
|
||||||
|
支持 Kwai-Kolors/Kolors 和 Qwen/Qwen-Image 模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from pathlib import Path
|
||||||
|
from .image_generation_service import ImageGenerationService
|
||||||
|
|
||||||
|
|
||||||
|
class TextToImageService(ImageGenerationService):
|
||||||
|
"""文生图服务"""
|
||||||
|
|
||||||
|
MODELS = {
|
||||||
|
"kolors": "Kwai-Kolors/Kolors",
|
||||||
|
"qwen": "Qwen/Qwen-Image"
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT_TEMPLATES = {
|
||||||
|
"urban_planning": "城市总体规划图,{prompt},鸟瞰视角,清晰的功能分区,道路网络完整",
|
||||||
|
"land_use": "土地利用规划图,{prompt},色块清晰,图例完整,专业制图风格",
|
||||||
|
"ecological": "生态保护规划图,{prompt},生态敏感区标注,绿色基调,自然元素",
|
||||||
|
"transportation": "交通规划图,{prompt},路网清晰,站点标注,流线设计",
|
||||||
|
"custom": "{prompt}"
|
||||||
|
}
|
||||||
|
|
||||||
|
STYLE_ENHANCEMENTS = {
|
||||||
|
"realistic": "高清真实感,专业摄影风格,细节丰富",
|
||||||
|
"conceptual": "概念设计图,简洁明了,设计感强,现代风格",
|
||||||
|
"technical": "技术图纸风格,精确详细,工程制图,CAD风格",
|
||||||
|
"artistic": "艺术化表现,创意设计,视觉冲击力强,插画风格",
|
||||||
|
"schematic": "示意图风格,清晰标注,信息传达准确,图表化"
|
||||||
|
}
|
||||||
|
|
||||||
|
SIZE_OPTIONS = {
|
||||||
|
"512x512": "512x512",
|
||||||
|
"1024x1024": "1024x1024",
|
||||||
|
"1024x1792": "1024x1792",
|
||||||
|
"1792x1024": "1792x1024"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: str = "kolors",
|
||||||
|
template: str = "custom",
|
||||||
|
style: str = "realistic",
|
||||||
|
size: str = "1024x1024",
|
||||||
|
num_images: int = 1
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""生成图像"""
|
||||||
|
|
||||||
|
# 验证模型
|
||||||
|
if model not in self.MODELS:
|
||||||
|
raise ValueError(f"不支持的模型: {model}")
|
||||||
|
|
||||||
|
# 构建增强提示词
|
||||||
|
enhanced_prompt = self._build_prompt(prompt, template, style)
|
||||||
|
|
||||||
|
# 构建 API 请求参数
|
||||||
|
payload = {
|
||||||
|
"prompt": enhanced_prompt,
|
||||||
|
"image_size": size,
|
||||||
|
"num_inference_steps": 50,
|
||||||
|
"guidance_scale": 7.5,
|
||||||
|
"batch_size": num_images
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 调用 API
|
||||||
|
result = await self.call_api(self.MODELS[model], payload)
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
print(f"API响应结构: {type(result)}")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
print(f"响应键: {list(result.keys())}")
|
||||||
|
if "images" in result:
|
||||||
|
print(f"图像数量: {len(result['images'])}")
|
||||||
|
if result["images"]:
|
||||||
|
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||||
|
|
||||||
|
# 处理结果
|
||||||
|
images = []
|
||||||
|
for i, img_data in enumerate(result.get("images", [])):
|
||||||
|
print(f"[DEBUG] 处理第 {i+1} 张图像")
|
||||||
|
image_id = self.generate_image_id()
|
||||||
|
|
||||||
|
# 检查是否为字典类型
|
||||||
|
if isinstance(img_data, dict):
|
||||||
|
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||||
|
|
||||||
|
# 优先检查URL字段
|
||||||
|
if "url" in img_data:
|
||||||
|
image_url = img_data["url"]
|
||||||
|
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, image_url)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
|
# 如果没有URL,尝试base64字段
|
||||||
|
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||||
|
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||||
|
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, image_b64)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||||
|
raise Exception("API响应中未找到有效的图像数据")
|
||||||
|
|
||||||
|
# 如果直接是字符串,判断是URL还是base64
|
||||||
|
elif isinstance(img_data, str):
|
||||||
|
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||||
|
if img_data.startswith('http'):
|
||||||
|
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||||
|
try:
|
||||||
|
image_path = await self.download_image_from_url(image_id, img_data)
|
||||||
|
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
else:
|
||||||
|
print(f"[DEBUG] 检测到base64字符串")
|
||||||
|
try:
|
||||||
|
image_path = self.save_image(image_id, img_data)
|
||||||
|
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||||
|
raise Exception(f"保存图像失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||||
|
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||||
|
|
||||||
|
images.append({
|
||||||
|
"id": image_id,
|
||||||
|
"url": self.get_image_url(image_id),
|
||||||
|
"prompt": enhanced_prompt,
|
||||||
|
"model": model,
|
||||||
|
"metadata": {
|
||||||
|
"original_prompt": prompt,
|
||||||
|
"template": template,
|
||||||
|
"style": style,
|
||||||
|
"size": size,
|
||||||
|
"file_path": image_path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"图像生成失败: {str(e)}")
|
||||||
|
|
||||||
|
def _build_prompt(self, prompt: str, template: str, style: str) -> str:
|
||||||
|
"""构建增强提示词"""
|
||||||
|
# 应用模板
|
||||||
|
if template in self.PROMPT_TEMPLATES:
|
||||||
|
templated_prompt = self.PROMPT_TEMPLATES[template].format(prompt=prompt)
|
||||||
|
else:
|
||||||
|
templated_prompt = prompt
|
||||||
|
|
||||||
|
# 添加风格描述
|
||||||
|
style_desc = self.STYLE_ENHANCEMENTS.get(style, "")
|
||||||
|
|
||||||
|
# 组合最终提示词
|
||||||
|
if style_desc:
|
||||||
|
final_prompt = f"{templated_prompt},{style_desc}"
|
||||||
|
else:
|
||||||
|
final_prompt = templated_prompt
|
||||||
|
|
||||||
|
return final_prompt
|
||||||
|
|
||||||
|
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||||
|
"""从URL下载图像"""
|
||||||
|
try:
|
||||||
|
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||||
|
|
||||||
|
# 下载图像
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(image_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
image_bytes = response.content
|
||||||
|
|
||||||
|
if len(image_bytes) == 0:
|
||||||
|
raise Exception("下载的图像数据为空")
|
||||||
|
|
||||||
|
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
image_dir = Path(self.get_image_dir())
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 保存图像文件
|
||||||
|
image_path = image_dir / f"{image_id}.png"
|
||||||
|
with open(image_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||||
|
return str(image_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||||
|
raise Exception(f"下载图像失败: {str(e)}")
|
||||||
|
|
||||||
|
def get_image_dir(self) -> str:
|
||||||
|
"""获取图像目录"""
|
||||||
|
from src.core.config import settings
|
||||||
|
return settings.generated_images_dir
|
||||||
|
|
||||||
|
def get_available_models(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取可用的模型列表"""
|
||||||
|
return [
|
||||||
|
{"id": "kolors", "name": "Kwai-Kolors/Kolors", "description": "快手可图,中英双语,高质量生成"},
|
||||||
|
{"id": "qwen", "name": "Qwen/Qwen-Image", "description": "通义万象,多场景支持,快速生成"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_available_templates(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取可用的提示词模板"""
|
||||||
|
return [
|
||||||
|
{"id": "custom", "name": "自定义", "description": "直接使用用户输入的提示词"},
|
||||||
|
{"id": "urban_planning", "name": "城市规划", "description": "城市总体规划图,鸟瞰视角,功能分区清晰"},
|
||||||
|
{"id": "land_use", "name": "土地利用", "description": "土地利用规划图,色块清晰,专业制图风格"},
|
||||||
|
{"id": "ecological", "name": "生态保护", "description": "生态保护规划图,生态敏感区标注,绿色基调"},
|
||||||
|
{"id": "transportation", "name": "交通规划", "description": "交通规划图,路网清晰,站点标注,流线设计"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_available_styles(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取可用的风格选项"""
|
||||||
|
return [
|
||||||
|
{"id": "realistic", "name": "真实感", "description": "高清真实感,专业摄影风格,细节丰富"},
|
||||||
|
{"id": "conceptual", "name": "概念图", "description": "概念设计图,简洁明了,设计感强,现代风格"},
|
||||||
|
{"id": "technical", "name": "技术图", "description": "技术图纸风格,精确详细,工程制图,CAD风格"},
|
||||||
|
{"id": "artistic", "name": "艺术化", "description": "艺术化表现,创意设计,视觉冲击力强,插画风格"},
|
||||||
|
{"id": "schematic", "name": "示意图", "description": "示意图风格,清晰标注,信息传达准确,图表化"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_available_sizes(self) -> List[Dict[str, str]]:
|
||||||
|
"""获取可用的尺寸选项"""
|
||||||
|
return [
|
||||||
|
{"id": "512x512", "name": "512×512", "description": "正方形,适合头像和图标"},
|
||||||
|
{"id": "1024x1024", "name": "1024×1024", "description": "高清正方形,适合详细图像"},
|
||||||
|
{"id": "1024x1792", "name": "1024×1792", "description": "竖版,适合海报和长图"},
|
||||||
|
{"id": "1792x1024", "name": "1792×1024", "description": "横版,适合横幅和全景图"}
|
||||||
|
]
|
||||||
Generated
+4358
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
# 国土空间规划:概念、原理、方法 - LaTeX版本
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
book/
|
||||||
|
├── main.tex # 主LaTeX文件
|
||||||
|
├── abstract.tex # 内容提要
|
||||||
|
├── preface.tex # 前言
|
||||||
|
├── chapter01.tex # 第一章:生态文明下的人居环境和国土空间
|
||||||
|
├── chapter02.tex # 第二章:自然资源与土地利用
|
||||||
|
├── chapter03.tex # 第三章:城乡空间规划思想与理论发展
|
||||||
|
├── chapter04.tex # 第四章:国土空间规划基本原理与体系构成
|
||||||
|
├── chapter05.tex # 第五章:国土空间规划的技术工具与方法
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 编译说明
|
||||||
|
|
||||||
|
### 使用XeLaTeX编译(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xelatex main.tex
|
||||||
|
xelatex main.tex # 第二次编译以生成目录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用pdfLaTeX编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdflatex main.tex
|
||||||
|
pdflatex main.tex # 第二次编译以生成目录
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **中文字体**:本文件使用`ctex`宏包处理中文,需要确保系统安装了中文字体。
|
||||||
|
|
||||||
|
2. **图片路径**:如果需要在文档中插入图片,请将图片文件放在`book`目录下,并在LaTeX文件中使用相对路径引用。
|
||||||
|
|
||||||
|
3. **表格**:文档中的表格引用(如"见表1.1")需要手动转换为LaTeX表格格式。
|
||||||
|
|
||||||
|
4. **参考文献**:主文件中已注释掉参考文献部分,如需添加,请创建`references.bib`文件并取消注释。
|
||||||
|
|
||||||
|
## 转换说明
|
||||||
|
|
||||||
|
- 所有Word文档已转换为LaTeX格式
|
||||||
|
- 章节标题已清理,移除了手动编号(LaTeX会自动编号)
|
||||||
|
- 保留了基本的段落结构和格式
|
||||||
|
- 表格和图片需要手动处理
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. 添加图片引用:将图片文件转换为PDF或PNG格式,并在相应位置添加`\includegraphics`命令
|
||||||
|
2. 格式化表格:将文档中的表格描述转换为LaTeX表格格式
|
||||||
|
3. 添加交叉引用:使用`\label`和`\ref`命令添加交叉引用
|
||||||
|
4. 优化格式:根据需要调整字体、行距、页边距等格式设置
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
\textbf{内容提要}
|
||||||
|
|
||||||
|
国土空间规划是我国空间规划体系的历史性变革,是对各类传统空间规划编制体系的整合和系统性的重构,是推进生态文明建设,落实治国理政理念,提升国土空间效能,服务人居环境发展的重要的公共政策和技术性的工具。
|
||||||
|
|
||||||
|
本书基于“新工科”研究成果,将国土空间相关概念解析和有关规划原理以“多规合一”为目标进行梳理,形成聚焦性的国土空间规划原理核心内容,并以人居环境学科与国土空间规划互动互融为前提,梳理城乡规划空间理论,讲解国土空间规划体系构成及“双评价、一张图”等技术内容,以期为国土空间规划实践和教学研究提供参考与借鉴。
|
||||||
|
|
||||||
|
本书适合从事国土空间规划相关领域的研究、实践以及与国土空间有关的国土自然资源管理、发展与改革、生态环境保护部门等的技术管理人员阅读和参考,也可作为高等院校相关本科与研究生的教学参考用书。
|
||||||
|
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
\chapter{生态文明下的人居环境和国土空间}
|
||||||
|
“生态兴则文明兴,生态衰则文明衰” 生态环境是人类生存和发展的根基,生态环境变化直接影响到文明兴衰演替,生态文明建设是关系中华民族永续发展的根本大计,是我国持续发展最为重要的基础,同理,国土资源是经济社会发展的重要物质基础,国土空间规划,就是要促进国土资源的合理利用和格局优化,满足社会发展与生态保护的要求,实现国土空间可持续发展,在人居环境科学理论的框架下,国土空间规划重点聚焦于“生态、资源、环境、安全”这些约束条件,是实现以人民为中心的“五位一体”发展目标的战略选择和重要路径。
|
||||||
|
|
||||||
|
\section{我国人居环境发展历程}
|
||||||
|
\subsection{原始时期}
|
||||||
|
人类最初主要依附于自然,以自然的采集和狩猎、穴居和巢居为主要生活方式,随着人类的不断进化和发展,石器制作技术的提高以及农业大发展,逐渐产生原始固定的居民点村落。村落聚族而居,开始有了统一的规划并十分注重防御,由于生产与生活的需要,村落产生了简单的分区,形成相对清晰的居住区、墓葬区和陶窑区等(见表1.1)。
|
||||||
|
|
||||||
|
表1. 1原始时期人居环境发展情况
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
时期 & 物质生产方式 & 人居环境情况 \\
|
||||||
|
\hline
|
||||||
|
旧石器时代 & 采摘和狩猎为主 & 生活完全依附于自然,穴居或树居 \\
|
||||||
|
中石器时代 & 农业出现,农业与畜牧业及狩猎业分开 & 农业为主的固定居民点—原始的村落 \\
|
||||||
|
新石器时代 & 农业为主兼营渔猎 & 简单分区,聚族而居 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\subsection{古代时期}
|
||||||
|
中国古代人居环境的发展,与中国奴隶社会及封建社会整个时期中社会经济的发展和特点紧密结合,人居模式随着时代的变迁发生了巨大的变革(见表1.2)。
|
||||||
|
|
||||||
|
表1. 2古代时期人居环境发展情况(根据吴良镛先生的人居环境史绘制)
|
||||||
|
|
||||||
|
\subsection{近代时期}
|
||||||
|
近代以来,特别是鸦片战争后,以租界出现、城墙拆毁为特征,城市开始出现,现代意义的城市化也从此启动。近代社会大转型进程中,中国人居建设步履艰难,险阻迭起,前路漫漫。但仍有难能可贵的艰难探索与独特创造,如“中国近代第一城”的南通,“新新世界雏形”淮南地区兴办垦牧公司,重庆北碚营卫星城。中国园林也进入继承、蜕变时期,作为广大公共生活之必需,发挥游憩作用并形成了租界园林、教堂园林、别墅园林、铁路园林等。中国民族形式在建筑现代化进程中成功运用,如南京中山陵、广州中山纪念堂等。
|
||||||
|
|
||||||
|
城市是社会经济的重要产物,社会的发展变革使城市发生不同内容和形式的变化(见表1.3)。近代以来中国农村经济社会在内忧外患中发展,传统的田园诗般的古朴生活状态被打破,出现了严重衰落。人们开始“救济乡村”“复兴乡村”,20世纪20年代末30年代初,华东华北和华中自下而上地广泛开展乡村社会学习和社会改良运动,通过兴办教育、改良农业、流通金融、提倡合作、办理地方自治与自卫,建立公共保障制度,实现“民族再造”和“民族自救”。
|
||||||
|
|
||||||
|
表1. 3近代城市发展类型
|
||||||
|
|
||||||
|
\subsection{现代时期}
|
||||||
|
现代时期的人居环境建设,以工业发展和五年计划实施为中心,使我国城乡发展产生了巨大的变化,以城市规划工作视角总结,能够比较全面地了解这一发展阶段。
|
||||||
|
|
||||||
|
\subsubsection{1.城市建设起步和规划初创(1949-1978年)}
|
||||||
|
随着社会主义新制度的建立,党中央提出了“必须用极大的努力去学会管理城市和建设城市”以及“城市建设为生产服务,为劳动人民生活服务”等论述,为制定新中国城市建设方针奠定了思想基础。
|
||||||
|
|
||||||
|
1949到20世纪60年代,呈现了包括长春第一汽车厂在内的156项工业基地建设等一系列建设成就,中国人居取得了长足的进步,人居建设实践出现了人民公社、农业学大寨等模式。鉴于大庆油田建设的成功,在“消除三大差别”、“走非工业化城市发展道路”等极左思潮影响下,城市的作用一度受到否定,这一时期中国最根本的问题就是如何解决吃饭问题,如何实现农业快速发展并为工业化奠定基础和提供保障。我国采取了城乡“剪刀差”的做法,即在“工业导向、城市偏向”的整体发展战略和“挖乡补城、以农哺工”的资金积累模式下,发展农业的意义除了解决吃饭问题之外,更重要的是为工业化提供积累和降低成本。由此导致了农村经济体制和城乡关系的变迁,逐步建立起农业支持工业、农村支持城市、城乡分隔的“二元”经济与社会体制,城镇化进程相当缓慢。20世纪的后半叶,中国进入一个新的历史时期。新中国成立初期提出要“变消费城市为生产城市”,为落实第二个五年计划中大规模工业建设项目,在区域范围内联合选厂,建设城市与工业镇,建立制度等,那段时间被称为“城市规划的春天”。
|
||||||
|
|
||||||
|
(1)城市建设的恢复(1949—1952年)
|
||||||
|
|
||||||
|
解放初期的大多数城市,工业基础薄弱,布局极不合理;市政设施及福利事业不足,居住条件恶劣;城市化程度很低,发展也不平衡,内地许多城镇还停留在封建时代,根本没有现代工业与设施。在城市建设方面恢复、扩建和新建了一些工业;整治城市环境,初步改变了全国城市的环境面貌;维修、改建、新建住宅,改善劳动人民的居住条件;整修城市道路,增设公共交通,改善供水、供电等设施。由于经济能力所限,这一时期较为重点的城市建设,主要是一些大城市内的棚户区改造与工人新村的规划建设,如上海的肇嘉浜、北京的龙须沟、天津的墙子河等,上海新建了第一个完整的工人居住区——曹杨新村,天津在中山门、西南楼、唐家口等地修建了工人新村。
|
||||||
|
|
||||||
|
随着城市建设的恢复与发展,1952年建工部组织召开了第一次城市建设座谈会,会议提出城市建设要根据国家的长期计划,针对不同城市有计划有步骤地进行新建或改建,加强规划设计工作和统一领导,克服盲目性,以适应大规模经济建设的需要。会议决定从中央到地方建立健全管理机构;各城市都要开展城市规划;划定城市建设范围;对城市分类排队,中国开始了重点工业的城市发展规划和城市建设。
|
||||||
|
|
||||||
|
经过三年的调整恢复与发展,至1952年设市城市为160个,比1949年增加了17.6\%,城市人口及其分布都有了很大变化。新中国的人居环境建设,开始步入了一个以工业城市为目标进行规划建设的新阶段。
|
||||||
|
|
||||||
|
(2)城市规划的引入与发展(1953-1957年)
|
||||||
|
|
||||||
|
这一时期是中国的第一个国民经济五年计划时期,国家急需建立城市规划体系。为此引入了“苏联模式”的规划方式,这一时期城市规划与建设工作奠定了中国城市规划与建设事业的开创性基础。“城市规划”用语得以统一;确立了以工业化为理论基础、以工业城市和社会主义城市为目标的城市规划学科,并建立了与之相应的规划建设机构,设置了城市规划专业,积累和培养一支城市规划专业队伍;随着大规模工业建设及手工业和工商业的社会主义改造,而进行的城市建设,是中国历史上前所未有的。
|
||||||
|
|
||||||
|
由于全面学习前苏联,包括与计划体制相适应的一整套城市规划理论与方法,使中国现代的城市规划与建设,具有严格的计划经济体制特征,也带有一些“古典形式主义”的色彩。在城市规划中强调平面构图、立体轮廓,讲究轴线、对称、放射路、对景、双周边街坊街景等古典形式主义手法;城市建设也一度出现了“规模过大、占地过多、求新过急、标准过高”的所谓“四过”现象,忽视工程经济等问题。
|
||||||
|
|
||||||
|
1954年建筑工程部组织召开了全国第一次城市建设会议,这次会议检查了过去城市建设工作中盲目、分散建设的缺点与错误,明确了城市建设必须贯彻国家过渡时期的总路线和总任务,为国家社会主义工业化、为生产、为劳动人民服务,采取与工业建设相适应的重点建设,稳步前进的方针。这次会议提出,第一个五年计划期间,城市建设必须把力量集中在“141”项工程所在地的重点工业城市,确保这些重要工业建设的顺利完成。在重点工业城市,市政建设也应把力量集中在工业区以及配合工业建设的主要工程项目上。除北京积极建设外,包头、太原、兰州、西安、武汉、洛阳、成都等是“一五”计划的重点工业城市,必须采取积极步骤,使城市建设工作能赶上工业建设的需要。上海、鞍山、沈阳、广州等城市,过去有一定工业基础和一些近代化市政设施,今后还要建设一些新的工业,城市建设可以进行必要的改建和扩建。会议还建议中央人民政府成立城市建设委员会或城市建设部,负责领导全国城市建设工作。
|
||||||
|
|
||||||
|
1953-1957年,中国成功地执行了发展国民经济的第一个五年计划。其建设的突出特点有三:一是按照社会主义有可能把有限的资金尽最大可能集中起来使用的原则,由国家来统一安排建设计划;二是针对旧中国许多工业行业都是空白的特点,计划优先安排这些空白工业产业的项目;三是把多数建设项目安排在内地,改变原有工业生产集中在沿海的布局。因此,一五期间共安排大中型工业建设项目825项,其中包括苏联援建的156项。
|
||||||
|
|
||||||
|
(3)城市规划的动荡与中断(1958-1978年)
|
||||||
|
|
||||||
|
1958年大批劳动力涌向城市,出现了一次城市化高峰。许多城市为适应工业发展的需要,迅速编制、修订城市规划。于是,城市人口骤增,城市数量迅速增多;城市和农村工业遍地开花,在天津、上海、南京、南昌等大城市中,规划建设了大量卫星城。
|
||||||
|
|
||||||
|
由于这种所谓“快速规划”、“人民公社规划”等空想主义思潮的影响,导致了城市布局混乱。由于工业建设的盲目冒进,各城市不切实际地扩大城市规模,发展大城市,建设一条街,急于改变城市面貌,并借国庆十周年之际,盲目过早地改建旧城,不顾财力,大建楼堂馆所等。
|
||||||
|
|
||||||
|
1960年建筑工程部党组就城市规划问题向中央提交报告,提出今后城市建设的基本方针应以发展中小城市为主,尽可能地把城市搞得好些、美些,努力实现城市园林化。
|
||||||
|
|
||||||
|
1960-1962年是中国的三年困难时期,“三年不搞城市规划”的决策,不仅对过去几年不切实际的城市规划无从补救,而且导致规划机构撤并、人员下放,城市规划事业大为削弱,许多城市又进入无规划的混乱自发建设状况。1961年,国家提出“调整、巩固、充实、提高”的八字方针,作出了调整城市工业项目、压缩城市人口、撤销部分市镇建制等决策,全国设计革命领导小组,认为城市规划“只考虑远景,不照顾现实,规模过大”,又一次否定城市规划。
|
||||||
|
|
||||||
|
1964年在内地建设上实行“山、散、洞”的“三线”建设方针,无视城市规划的合理布局,在城市建设上采取的是一种“不要城市、不要规划的分散主义”做法。
|
||||||
|
|
||||||
|
1966年城市规划及建设被迫处于停滞甚至中断状态。至1968年全国许多城市的规划机构被撤销,人员下放,资料散失,学科专业停办,致使城市规划基本停顿,城市建设和管理呈现无政府主义状态,名胜古迹和园林绿地被侵占、破坏,违章建筑泛滥,城市布局混乱,造成了许多无法挽救的损失和后遗症。从1966年至1971年,“三线”建设进入高峰时期。这一时期建设的工厂,统统安排在山沟和山洞里,不但不建城市,而且要求新厂建设消除工厂的特征,实行厂社结合。要求城市向农村看齐,消灭城乡差别。由于这种错误指导思想的影响,有的城市根本不设立城市建设投资。
|
||||||
|
|
||||||
|
1971年周恩来主持中央日常工作,城市规划与建设工作开始出现转机,这一年在北京召开了城市建设会议,决定恢复城市规划机构,重新编制规划。1973年,《关于加强城市规划工作的意见》、《关于编制与审批城市规划工作的暂行规定》、《城市规划居住区用地控制指标》等几个文件草稿征求意见,并于1974年试行。1975年召开了小城镇规划建设座谈会,研究了建设小城镇的方针政策,推动了城市规划和城市建设工作。
|
||||||
|
|
||||||
|
\subsubsection{2. 城市建设的全面恢复和发展(1979—2000年)}
|
||||||
|
1978年改革开放后,国家大规模恢复建设,经济社会发生了深刻的变化,实现了持续稳定的快速发展。城市规划也步入了崭新的阶段。
|
||||||
|
|
||||||
|
这一时期城市人口增长迅速。1953年第一次人口普查时城市化水平为13.26\%,而1982、1990、2000年的第三、四、五次人口普查显示,城市化水平分别为20.60\%、26.23\%和36.09\%。1950年50万人口以上的大城市为13个,1980、1990和2000年分别增长为45个、59个和93个:20万至50万人口的中等城市则从22个,分别增长为70个、117个和218个。
|
||||||
|
|
||||||
|
1980年代改革开放的初期,从农村改革到城市改革,在不断地探索和推进过程中,自下而上地形成以乡镇企业和小城镇建设为基础的大规模城镇化进程;1990年代后,社会主义市场经济逐步确立,小城镇、大城市及开发区的全面迅速发展,我国人居环境发展进入一个全新的历史时期。
|
||||||
|
|
||||||
|
(1)1980年代的改革和恢复
|
||||||
|
|
||||||
|
随着经济体制改革从计划经济走向社会主义市场经济,城市与城市规划发展面临新的形势。
|
||||||
|
|
||||||
|
1978年3月,国务院召开第三次城市工作会议,制定了《关于加强城市建设工作的意见》,制定了一系列城市规划及其建设的方针、政策以及关键问题。强调城市在国民经济中的重要地位和作用,提出控制大城市规模,多搞小城镇,要求认真编制和修订城市总体规划、近期规划和详细规划等,城市规划工作重新走上正轨。这一时期是中国城市规划迅速发展阶段,可以称为开放式的规划时期,是中国内地城市规划的第二个春天。
|
||||||
|
|
||||||
|
1979年全面开展了城市规划的准备工作,国家建委起草了《中华人民共和国城市规划法草案》、《关于发展小城镇的意见》、《关于城镇建设用地综合开发的试行办法》、《关于征收城镇土地使用费的意见》等文件。国务院批准兰州市和呼和浩特市的总体规划,这是自第一个五年计划以来,国家重新审批城市规划的第一批城市,是城市规划工作重新步入正轨的重要标志。
|
||||||
|
|
||||||
|
1980年全国城市规划工作会议批判了不要城市规划和忽视城市建设的错误,提出了“控制大城市规模、合理发展中等城市、积极发展小城市”的城市建设方针,这次会议讨论通过了《城市规划法草案》,在现代中国的城市规划事业的发展历程中,占有重要的地位。同年正式颁布了《城市规划编制审批暂行办法》和《城市规划定额指标暂行规定》,使全国制定城市规划拥有了新的技术性法规。
|
||||||
|
|
||||||
|
1984年国务院颁发《城市规划条例》,这是我国城市建设和城市规划方面的第一部基本法规,同年中国建筑学会召开主题为“居住区环境规划”大会。
|
||||||
|
|
||||||
|
1985年科学家钱学森在致《新建筑》编辑部的信中提出“构建园林城市”设想。1990年,他又明确指出“城市规划立意要尊重生态环境,追求山环水绕的境界”。1992年10月他再次呼吁:“把整个城市建成一座大型园林”,我称之为‘山水城市、人造山水’。
|
||||||
|
|
||||||
|
1986年国务院在北京召开全国城市建设工作会议,这是继1978年第三次全国城市工作会议以来,国务院召开的专门研究城市建设问题的又一次重要会议。1986年公布了38个第二批历史文化名城,还公布了第一次全国城镇房屋普查结果,城乡建设环境保护部和国家统计局发布了第一次全国城镇房屋普查结果,其统计数字表明,我国28个省、自治区、直辖市的城镇普查范围内共有房屋建筑面积46.76亿平方米,其中323个市(不含市属县)有房屋28.33亿平方米,占60%,城市居民的居住水平略低于县镇。截至1986年底,全国有96\%的设市城市和85\%的县镇编制完成了城市总体规划,以2000年为期的城市总体规划在全国范围内已基本完成,以此为标志,我国的城市规划工作进入了一个新的历史发展阶段。
|
||||||
|
|
||||||
|
1989年12月全国人大通过了《城市规划法》,城市规划法是我国在城市规划、城市建设和城市管理方面的第一部法律,是城市建设的龙头。
|
||||||
|
|
||||||
|
城市规划及城市建设经济得到全面规划,并稳步开启法制的轨道,这一时期城市规划的内容在深度、广度上都有重大变革,规划思想的进一步丰富完善,为城市开放探索新的转折点做好了准备。
|
||||||
|
|
||||||
|
(2)1990年代的探索和发展
|
||||||
|
|
||||||
|
90年代邓小平主席发表了重要的南巡讲话和党的十四大决定建立社会主义市场经济体制以后,城市规划开创了一个全面宽敞的开放式城市规划体系,城市建设进入一个更快的发展阶段。
|
||||||
|
|
||||||
|
1991年第二次全国城市规划工作会议在北京举行。深圳被联合国授予人居奖。1993年由中国城市规划学会理事长、清华大学教授吴良镛先生主持设计建设的北京菊儿胡同四合院工程荣获1992年度的“世界人居奖”。
|
||||||
|
|
||||||
|
1993年建设部的全国农村建设工作会议,提出了20世纪末中国小城镇建设发展目标,随后经国务院同意颁发的《关于加强小城镇建设的若干意见》、《小城镇综合改革试点指导意见》(1995年4月)、《小城镇户籍管理制度改革试点方案》(1997年6月)等,从政策上对小城镇户籍管理制度、农村人口在小城镇就业和居住进行了适当改革和调整。
|
||||||
|
|
||||||
|
1998年的十五届三中全会《中共中央关于农业和农村工作若干重大问题的决定》,指出“发展小城镇,是带动农村经济和社会发展的一个大战略”。小城镇迅速发展,一些地方的小城镇已经成长为中小城市,成为农村生产、服务、文化、教育和信息中心,对带动农村发展发挥着重要作用,目前,小城镇的格局基本形成。中国第一份人类发展报告《中国人类发展报告1997》在北京发表。标志中国建筑史学走向世界的“第一届中国建筑史学国际研讨会”在北京举行,大会的主题为“人为环境与自然环境的融合”。
|
||||||
|
|
||||||
|
\subsubsection{3.新世纪城镇化高速发展期(2000-2016年)}
|
||||||
|
(1)城乡统筹发展——2000-2011年
|
||||||
|
|
||||||
|
2000年建设部下发《关于设立“中国人居环境奖”的通知》(建城〔2000〕93号),正式设立“中国人居环境奖”(含“中国人居环境范例奖”)。该奖是全国人居环境建设领域的最高荣誉奖项,在推动我国城市建设和管理事业发展上发挥了重要作用,同年建设部制定印发《创建国家园林城市实施方案》和《国家园林城市标准》。
|
||||||
|
|
||||||
|
2003年,党的十七大报告中提出了“科学发展观”这一理论。科学发展观的核心是以人为本,人是自然界的一部分,生态环境是人类生存的基础,良好的生态环境是实现人的全面发展的必然追求。科学发展观的基本要求是全面协调可持续,坚持把社会主义经济建设、政治建设、文化建设、社会建设和生态文明建设,看成是一个相互联系、相互促进、不可分割的整体和过程。科学发展观的根本方法是统筹兼顾,要求在发展过程中必须统筹人与自然的关系,在利用自然的同时,保护好自然。只有生态文明建设和经济建设齐头并进,构成了人类发展的科学可持续性。
|
||||||
|
|
||||||
|
2005年国家林业局颁布实施“国家森林城市”评价指标。创建城市森林是改善城市生态环境的主要措施,是城市生态建设实现城乡一体化发展,全面推进我国城市走生产发展、生活富裕、生态良好发展道路的重要途径。
|
||||||
|
|
||||||
|
2008年《中华人民共和国城乡规划法》开始实施,它是一部关于城乡规划建设和管理的基本法律,它的颁布实施,是全面贯彻落实科学发展观,协调城乡空间布局,改善人居环境,依法促进城乡经济社会全面协调可持续发展的客观要求,也是走中国特色城镇化道路的客观需要。
|
||||||
|
|
||||||
|
2000年代以来,中国各级政府一再强调要积极推进乡村人居环境的建设,并付出了巨大的努力,从新农村建设到乡村环境整治、美丽乡村建设,再到中共十九大报告提出实施乡村振兴战略,标志着中国从过去以城市为中心的高速城镇化阶段,全面转向以城乡统筹为目标的高质量城镇化阶段。
|
||||||
|
|
||||||
|
(2)五个统筹整合(2012-2016年)
|
||||||
|
|
||||||
|
2012年《中国人居环境发展报告-人居环境白皮书》由中国建筑工业出版社出版,《人居环境白皮书:中国人居环境发展报告》分为调研篇、社区篇、引导篇、集萃篇、论文篇、资料篇六大部分内容,基本涵盖了从城市、住区、住宅单体建筑环境到城镇居民人居环境满意度调查等各个层面和领域。编撰重点体现了四个方面主旨:基于城市化,面向未来;突出社会性,关注民生;展示权威度,引领人居;拓宽覆盖面,持续创新。
|
||||||
|
|
||||||
|
2012年,党的十八大正式将生态文明建设写入党章,体现了对新世纪快速发展阶段出现的一系列阶段性问题的科学判断和对人类社会发展规律的深刻把握,是对人居科学理论的丰富和完善,对人与自然和谐发展的深刻洞察,是实现我国全面建设小康社会宏伟目标的基本要求,也是对日益恶劣的全球环境问题主动承担大国责任的庄严承诺。
|
||||||
|
|
||||||
|
建设生态文明,是关系人民福祉,关乎民族未来的长远大计,把生态文明放在突出地位,融入经济建设,政治建设,文化建设,社会建设各方面和全过程,努力建设美丽中国,实现中华民族永续发展,“美丽中国”一词进入公众视野。
|
||||||
|
|
||||||
|
2014年首届“世界城市日”全球启动仪式在上海世博中心举行,主题为“城市,让生活更美好”,年度主题为“城市转型与发展”。“世界城市日”是中国首次在联合国推动设立的国际日,获得了联合国全体会员国的支持 。
|
||||||
|
|
||||||
|
2014年3月14日,习总书记主持召开中央财经领导小组第五次会议时指出: “在经济社会发展方面我们提出了‘五个统筹’,治水也要统筹自然生态的各要素,不能就水论水。要用系统论的思想方法看问题,生态系统是一个有机生命躯体,应该统筹治水和治山、治水和治林、治水和治田、治山和治林等。”
|
||||||
|
|
||||||
|
2015年住建部、上海市政府、联合国人居署在上海共同举办“2015世界城市日论坛”,年度主题为“城市设计,共创和谐”。2015年中央城市工作会议,提出城市工作要把创造优良人居环境作为中心目标,努力把城市建设成为人与人,人与自然和谐共处的美丽家园。
|
||||||
|
|
||||||
|
2016年为配合“人居三”大会的召开,根据联合国人居署有关要求,我国住房和城乡建设部组织力量编写了“人居三”中国国家报告,报告介绍了中国城乡住区发展20年回顾、中国政府对人类住区发展的基本主张、中国城乡住区发展的行动计划、中国政府支持“人居三”大会的工作及期望;“人居三”大会通过《新城市议程》以来第一次有关城市发展问题的全球性大规模活动,本次会议的主题是“城市2030,人人共享的城市”。
|
||||||
|
|
||||||
|
\subsubsection{4.国土空间统筹发展新时代(2017—至今)}
|
||||||
|
2017年中共十九大报告提出人与自然和谐共生的重大要求,将建设“美丽中国”作为全面建设社会主义现代化国家的重大目标,明确提出要统筹山水林田湖草生命共同体,坚持人与自然和谐共生,强调从自然资源的整体性与系统性角度合理统筹国土空间规划,达到人与自然之间的相互平衡关系。
|
||||||
|
|
||||||
|
十九大报告将生态文明视作“中华民族永续发展的千年大计”,十九大以来,国家更加关注生态文明建设,坚持以人为中心,满足人民对于美好生活的向往,这一时期追求“绿水青山就是金山银山”的现代化城乡宜居的人居环境。
|
||||||
|
|
||||||
|
2017年中国住房城乡建设部、广东省人民政府与联合国人居署共同在广州举办2017“世界城市日”全球主场活动。同年国家发布《农村人居环境整治三年行动方案》。方案提出,到2020年,实现农村人居环境明显改善,村庄环境基本干净整洁有序,村民环境与健康意识普遍增强。
|
||||||
|
|
||||||
|
2018年2月,习近平总书记在四川成都天府新区视察时提出了“公园城市”理念,指出天府新区是“一带一路”建设和长江经济带发展的重要节点,一定要规划好建设好,特别是要突出公园城市特点,把生态价值考虑进去,努力打造新的增长极,建设内陆开放经济高地。
|
||||||
|
|
||||||
|
2018年3月11日,十三届全国人大第一次会议生态文明写入《宪法》,标志着我国步入了新时代中国生态文明建设和绿色发展的新阶段,建设公园城市是新时代新形势下的必然要求。
|
||||||
|
|
||||||
|
习近平总书记在2019年中国北京世界园艺博览会开幕式上的讲话明确指出:“纵观人类文明发展史,生态兴则文明兴,生态衰则文明衰。”生态文明建设已经纳入中国国家发展总体布局,建设美丽中国已经成为中国人民心向往的奋斗目标。
|
||||||
|
|
||||||
|
2021年国务院印发《农村人居环境整治提升五年行动方案(2021-2025年)》明确到2025年,农村人居环境显著改善生态宜居美丽乡村建设取得新进步的行动目标,“十四五”时期将深入实施农村人居环境整治提升五年行动,扎实推进农村厕所革命,加快推进农村生活污水治理,全面提升农村生活垃圾治理水平,推动村容村貌整体提升,建立健全长效管护机制。
|
||||||
|
|
||||||
|
2022年国务院批复同意成都建设践行新发展理念的公园城市示范区,并提出示范区建设要打造山水人城和谐相融的公园城市。《中国县域人居环境气象评估报告》正式发布。《报告》旨在助力政府科学评估县域人居环境的改善效果,提高县域人居环境改善和治理的现代化管理水平。
|
||||||
|
|
||||||
|
\section{我国传统人居环境的理念与人居环境学科发展}
|
||||||
|
\subsection{人居环境的核心理念}
|
||||||
|
中国传统生态智慧,历经数千年中华文化传承和城市建设实践,其思想核心是建立人与自然和谐统一的关系。它统合在儒、道、佛等思想流派中,以“天人合一”“道法自然”“众生平等”等为基本精神,诠释着最朴素的生态伦理和哲学,有别于西方以“还原论”“物竞天择”“改造世界”为特点的自然哲学,中国传统生态智慧具有以“整体”“共生”“适应”“永续”为特征的绿色发展理念,为中国特色理论体系在城乡建设领域的构建确立了思想内核。
|
||||||
|
|
||||||
|
\subsubsection{1.整体理念}
|
||||||
|
在描绘人与自然的基本关系上,西方文化强调解析和还原,东方文化强调事物的整体性和综合性。中国哲学的思维特征则是体验的、综合的,与伦理、社会、人生结合,善于从整体视角研究高度复杂的系统。“一阴一阳之谓道”,表明事物和现象同时具有对立和统一的关系,它们相互依存、结成整体,因此不能孤立地看待任一部分。董仲舒的“以类合之,天人一也”,表达了人与天地万物具有同类合一的性质,在东方整体观的认知下,人与自然统合为一个整体。对自然各个领域的认识也首先强调关注其全貌及其展现的整体规律,其次才是各组成部分之间的相互关系,再次才是组成部分自身的特征。
|
||||||
|
|
||||||
|
吴良镛院士谈及中国人居环境的特点时曾说,“中国人居环境的营造,不是专注于一座建筑的设计或钟情于一片风景的塑造,而是强调整体,采用规划、建筑、园林‘三位一体’的营造方略。”
|
||||||
|
|
||||||
|
\subsubsection{2.共生理念}
|
||||||
|
中国传统哲学认为天地造化、万物同源。《庄子·秋水》中写到“以道观之,物无贵贱”,因为万物平等,中国人的世界观带有善待万物的思想。
|
||||||
|
|
||||||
|
《礼记·中庸》有“万物并育而不相害,道并行而不相悖”的名句,表达了每一个生命体都拥有符合自然规律的生存权利,“上天有好生之德”,人类不能强行剥夺其他生命的发展权,善待生命、热爱自然才是符合世界运行的根本规律。习总书记提出的“山水林田湖草是一个生命共同体”思想重新诠释了这一传统认知,表达出生态环境各要素之间相互关联、相互影响、相互依存的特性,深刻阐明了万物共生共荣的自然规律,“人的命脉在田,田的命脉在水,水的命脉在山,山的命脉在土,土的命脉在树。”
|
||||||
|
|
||||||
|
\subsubsection{3.适应理念}
|
||||||
|
中国长期的农业社会实践形成了遵循自然规律的认知,人类在自然面前作为弱者采取“顺应自然”的行为,在“因就”中满足自身的发展诉求。“二十四节气”表达的就是应时务农的思想,《管子·乘马》中“凡立国都,非于大山之下,必于广川之上,高毋近旱,而水用足;下毋近水,而沟防省;因天材,就地利,故城郭不必中规矩,道路不必中准绳。”代表着因地制宜进行城市选址和建设的经验总结,有鲜明的“顺应自然”特色。
|
||||||
|
|
||||||
|
今天,人类科学技术虽已取得巨大进步,改造自然的能力空前提高,但城市发展仍然面临也将长期面临如何适应自然的课题,气候安全、生态安全、能源资源安全、防灾减灾等问题的交织。按照“复杂适应系统”理论,主体需要根据客体环境的变化主动寻求适应,使自身的演进趋向“绿色化”;在与环境及其它主体间持续不断的交互作用中,不断“学习”或“积累经验”,调整自身结构和行为方式,实现顺应环境的发展,如果把城乡空间视为生命体,其宏观至微观层面系统的生长进化、推陈出新,都需要在这个适应的进程中逐步变化。
|
||||||
|
|
||||||
|
\subsubsection{4.永续理念}
|
||||||
|
中国传统对自然的利用强调 “从长计议”。它提倡有限度地开发利用自然,主动约束过度开采行为,以维系自然的永续平衡。从《礼记·王制》中可以看到,中国古代推行“时禁”“不夭其生,不绝其长”,认为对山林川泽的索取,要避开草木、鱼鳖刚开始生长发育的阶段,反对滥采以确保百姓能够长期获得所需资源。《淮南子·主术训》也讲到“畋不掩群,不取麛夭。不涸泽而渔,不焚林而猎。”认为人类渔猎动物应有不可逾越的底线,取之有度,用之有节,从而避免破坏大环境生态平衡。永续理念建立了城乡人居环境发展的时间维度,这与可持续发展观所强调的“代际公平”思想不谋而合。
|
||||||
|
|
||||||
|
\subsection{人居环境的科学发展}
|
||||||
|
\subsubsection{1.道萨迪亚斯的“人类聚居学”}
|
||||||
|
希腊建筑师道萨迪亚斯在20世纪50年代创立研究人类聚居的理论,又称城市居住规划学、人类环境生态学。1965年在希腊雅典成立人类聚居学世界学会。在他的积极努力下,联合国于1976年在温哥华召开“世界人居大会”,此后每隔20年召开一次,最新的第三届“世界人居大会”于2016年在厄瓜多尔首都基多召开,并通过了《新城市议程》,为城市可持续发展设定了新的全球标准。
|
||||||
|
|
||||||
|
道萨迪亚斯认为传统建筑学、地理学、社会学、人类学等学科,仅仅各自研究涉及人类聚居的某一侧面,而人类聚居学要吸收上述各学科的成果,在更高的层次上对人类聚居进行全面、综合的研究。一方面要建立一套科学的体系和方法,了解和掌握人类聚居的发展规律;另一方面要解决人类聚居中存在的具体问题,创造出良好的人类生活环境。
|
||||||
|
|
||||||
|
人类聚居主要是指包括乡村、集镇、城市等在内的人类生活环境,由五个基本要素组成——自然界、人、社会、建筑物、联系网络,人类聚居学研究上述五项要素以及它们之间的相互关系。
|
||||||
|
|
||||||
|
道萨迪亚斯按规模大小把人类聚居分成15级层次单位:个人、居室、住宅、 住宅组团、 小型邻里、邻里、集镇、城市、大城市、大都会、城市组团、大城市群区、城市地区、城市洲、全球城市。这15级单位上下互相联系构成人类聚居系统,要想解决各层次中的问题,必须对整个系统进行研究。
|
||||||
|
|
||||||
|
\subsubsection{2.吴良镛院士的“人居环境科学”}
|
||||||
|
1993年8月,吴良镛和周干峙、林志群在中科院技术科学部学部大会上,阐释了“人居环境学”的观念和系统。其后,吴良镛院士不断完善,于2001年出版《人居科学导论》,系统阐述了人居环境科学的框架。人居环境科学是一门以人类聚居为研究对象,着重探讨人与环境之间相互关系的科学,强调把人类聚居作为一个整体,而不像城市规划学、地理学、社会学那样,只涉及人类聚居的某一部分或是某个侧面,人居环境科学的目的是了解并掌握人类聚居发生、发展的客观规律,以更好地建设符合人类理想的聚居环境。
|
||||||
|
|
||||||
|
在借鉴人类聚居学的基础上,吴良镛院士提出人居环境的五个子系统,即自然系统、人类系统、居住系统、社会系统、支撑系统;根据中国实践将人居环境分为五个层次,即全球、区域、城市、社区和建筑;明确了发展中国人居环境科学的五大原则,即生态观、经济观、科技观、社会观和文化观,这些内容共同搭起了人居环境科学的框架。吴良镛院士认为,每个学科都应该有自己的方法论,人居环境科学面对错综复杂的自然与社会问题,需要借助复杂性科学的方法论,通过多学科的交叉从整体上予以探索和解决。
|
||||||
|
|
||||||
|
1999年6月23日,国际建协第20届世界建筑师大会在北京召开,大会一致通过了由吴良镛院士起草的《北京宪章》。《北京宪章》被公认为是指导二十一世纪人居环境发展的重要纲领性文献,标志着吴良镛院士的广义建筑学与人居环境学说,已被全球普遍接受和推崇。
|
||||||
|
|
||||||
|
人居环境科学是研究人类聚居及其环境相互关系与发展规律的科学,其理论与实践从中国建设的实际出发,与世界人居运动形成了良好的呼应,是“中国改革开放40周年的标志性成果之一”。
|
||||||
|
|
||||||
|
\subsubsection{3.新时代人居环境科学的侧重点}
|
||||||
|
当前,我国内外部环境的巨大变化、社会经济发展动力的变化、人口结构和城镇化发展阶段的变化,都已昭示了新发展阶段的到来。新阶段的城乡发展实践,需要新阶段的人居环境科学理论来支撑。
|
||||||
|
|
||||||
|
不同阶段的主要矛盾不同,决定解决其方法的不同。新时代我国社会主要矛盾已经转化为人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾,我国经济主要矛盾已经转化为生态资源环境安全紧约束和人均GDP倍增之间的矛盾,而两者共同的解决途径是破解“生态、资源、环境、安全”投入和“经济、政治、社会、文化、生态”产出之间“此消彼长、难以两全”的矛盾关系,实现两个“脱钩”:经济增长与资源环境消耗脱钩(经济增长快于资源环境消耗),人民生活质量提升与经济增长脱钩(人民生活质量提升快于经济增长)。
|
||||||
|
|
||||||
|
因此,在人居环境科学理论的框架之下,新发展阶段应将重点聚焦于“生态、资源、环境、安全”这组紧约束条件,回答在此条件之下如何实现“以人民为中心”的“五位一体”发展目标,这归根结蒂是一个道路问题。通过技术创新和制度创新,通过最小的生态资源环境安全代价,最大程度地满足人民对美好生活的向往。
|
||||||
|
|
||||||
|
\section{生态文明演进与我国国土空间的生态安全问题}
|
||||||
|
人类的发展经历了原始文明、农耕文明、工业文明等阶段,在深刻反思各类教训基础上,基于可持续发展理念,生态文明思想逐渐被人类所接受。
|
||||||
|
|
||||||
|
\subsection{生态文明的演进}
|
||||||
|
原始文明时期人类是自然的产物,人与自然之间是以自然为主宰,人敬畏自然、崇拜自然。从原始文明过渡到农业文明,是人类历史上的重大飞跃。农耕文明时期人类与自然之间保持着有序、协同、共生的关系,然而生产力相对落后,物质财富相对匮乏,农耕文明的活动主要是农业和畜牧业,人类不完全依赖自然,并努力寻找适合自身生存的生活地点。虽然农耕文明阶段生产力低下,人类对自然的破坏和损伤不大,但人与自然仍然存在大量矛盾。18世纪中叶以后欧洲爆发工业革命,农耕文明迅速被工业文明替代,工业文明时期生产力得到空前解放和发展,人类在获得巨大的物质财富的同时也导致了一定的资源环境危机,影响到人类生存质量。工业文明以大工业生产方式为主导,以物质财富的迅速积累为重要标志,并通过征服自然来积累财富,大量的掠夺式开发利用自然导致生态平衡被破坏,危害到人与自然的可持续发展。1970年代以后,生态环境问题日益严重,被国际上视为焦点话题。1972年在瑞典斯德哥尔摩举行了联合国人居环境会议,会议通过了《人类环境宣言》指出地球的能源和资源是有限的,为了支持人类的长远发展,必须遵循有机增长的路径。1973年联合国成立环境规划署,1980年代国际社会提出可持续发展理念,生态文明的思想开始广泛流传。生态文明时期是在深刻反思工业文明教训的基础上,汲取农耕文明精粹,协调“发展”和“保护”关系的新型发展理念。生态文明是对工业文明的深刻变革和扬弃,是汲取农耕文明精粹、协调人与自然关系的新型发展理念,人类文明的又一次提升和飞跃。
|
||||||
|
|
||||||
|
\subsection{我国生态资源的基本情况}
|
||||||
|
\subsubsection{1.自然资源总量大、类型多}
|
||||||
|
中国陆地面积约960万km²,居世界第三位;目前耕地面积约1.28亿hm²,居世界第四位;森林面积约2.8亿hm²,居世界第五位;草地约2.6亿hm²,居世界第四位;水资源约29638.2亿m³,居世界第六位;按45种主要矿产资源的潜在价值,按矿产总值比计,居世界第三位;水能、太阳能、煤炭资源分别居世界第一、第二、第四位。中国已发现矿产173种,矿产地(点)20余万处,已探明储量的157种,其中有20余种矿产储量居世界前列。从总量看,中国是世界资源大国之一。
|
||||||
|
|
||||||
|
\subsubsection{2.人均自然资源量少、分布不均}
|
||||||
|
中国主要自然资源的人均占有水平低。2019年,我国耕地面积居世界第四位,但人均耕地面积不足1.4亩,不到世界平均水平的1/2;人均森林面积仅为世界平均水平的1/5;我国人均可再生淡水资源为2200立方米,仅为世界平均水平的1/4,且时空分布极不平衡;全国建制市中的缺水城市占2/3以上,其中100多个城市严重缺水;地下水资源超采严重,浅层地下水含量逐年持续下降。中国主要矿产资源的人均占有量较低,与世界平均水平比较,石油探测储量的人均占比仅为世界人均占有量的7.9\%,天然气的占比不足18\%,一次性能源消费量(包括原煤、原油、天然气、水电、核电)的占比为26\%。中国自然资源分布的东西差异极其明显,南北资源组合的差异也很大。耕地资源、森林资源、水资源的90\%以上集中分布在东部,而能源、矿产等地下资源和天然草地相对集中于西部。中国大量淡水资源集中在南方,北方淡水资源只有南方淡水资源的1/4。而耕地的分布却是南少北多。
|
||||||
|
|
||||||
|
\subsubsection{3.资源禀赋欠佳}
|
||||||
|
中国耕地中,一等到三等耕地仅占31\%,中低产田占比三分之二以上;草地资源主要分布在半干旱、干旱地区与山区,资源质量较差;林地资源则较好,一等林地约占65\%。多数矿产资源贫矿多而富矿少。共、伴生矿多,单矿种矿少,利用难度大,成本较高。
|
||||||
|
|
||||||
|
\subsection{我国生态资源环境挑战}
|
||||||
|
过去四十年,中国的工业化、城市化超越了任何一个经济体,也超越了工业化国家的任何一个历史时期,但同时也带来了生态资源环境安全等方面的问题,具体体现在以下方面:
|
||||||
|
|
||||||
|
\subsubsection{1.生态系统脆弱、退化形势严峻}
|
||||||
|
我国生态系统整体质量和稳定性状况不容乐观。自然生态系统总体较为脆弱、生态承载力和环境容量较低、优质生态产品供给能力不足现象尚未得到根本扭转。“胡焕庸线”东南方43\%的国土,居住着全国94\%左右的人口,以平原、水网、低山丘陵和喀斯特地貌为主,生态环境压力巨大;该线西北方57\%的国土,供养大约全国6\%的人口,以草原、戈壁沙漠、绿洲和雪域高原为主,生态系统非常脆弱。资源过度开发导致生态系统退化形势依然严峻。根据2021年中国水土保持公告,全国水土流失面积267.42万 km2,占国土面积的27.86\%;过度农垦、放牧导致草原生态系统失衡,2019年重点天然草原平均牲畜超载率达10.1\%,2021年我国人工林面积6801.96千公顷,超过森林总面积的30\%,且不少位于干旱、半干旱地区;不少农业开发和建设占用挤占或损毁生态空间,从历史上看,农牧交错带地区大量耕地是通过开垦优质草原、森林、湿地形成的,全国地理国情监测数据表明,2019年全国种植土地(含果树等经济作物)、建设用地(含设施农用地)均比2015年有所增加,全国草地面积有所减少,全国矿山开采占用、损毁土地问题比较严重。
|
||||||
|
|
||||||
|
\subsubsection{2.人均资源短缺、利用效率不高}
|
||||||
|
2021年,原油、天然气、煤炭、铁矿石、铜精矿、铝土矿的进口额约占我国矿产品进口总额的85\%以上,2019年,我国有2/3的战略性矿产存在较高的对外依存度,其中约有1/2的战略性矿产对外依存度超出了50\%。
|
||||||
|
|
||||||
|
我国有一批未来需求潜力大、但被他国“卡脖子”的矿种。2019年,我国油气、铁、铜、铝、镍、钴、锆、铬等等15种战略性矿产的资源储量占全球比重均低于20\%。其中,石油储量仅占全球总量的1.5\%;煤炭储量也仅占全球总量的13.2\%;从数量上对比看,我国2/3以上的战略性矿产资源储量在全球均处于劣势地位。
|
||||||
|
|
||||||
|
未来较长的一段时期内,我国仍然将保持国际制造业大国的地位。尤其是战略性新兴产业还将成为国家重点扶持的产业,这会导致战略性矿产消费量快速增长,加剧供需矛盾。资源粗放利用问题依然突出。城乡建设仍以外延扩张的发展模式为主,2021年全国人均城镇村及工矿用地250平方米。
|
||||||
|
|
||||||
|
\subsubsection{3.环境容量有限、防治任务艰巨}
|
||||||
|
根据2021年水土流失动态监测成果,全国水土流失面积267.42万平方公里,占陆地国土面积(不含港澳台)的27.96\%;根据第五次全国荒漠化和沙化监测结果,全国荒漠化土地面积为261.16万平方千米,沙化土地面积为172.12万km2,占国土面积的18.0\%;首次全国土壤污染状况调查显示全国土壤总的超标率为16.1\%。2021年,全国339个地级及以上城市中121个城市环境空气质量超标,占35.7\%;全国酸雨区面积约36.9万平方千米,占国土面积的3.8\%。
|
||||||
|
|
||||||
|
\subsubsection{4.城市韧性不足、安全事件频发}
|
||||||
|
伴随城镇化快速扩张的过程,城市公共安全事件频发,如水灾、气象灾害、环境污染、地震、火灾爆炸、危化品泄漏、交通事故、疫病疫情以及群体性事件等都会严重影响社会安全与稳定,并威胁人民生命财产安全。与城市相关的灾害种类有30多种,可分为自然和人为灾害两大类。(1)\textbf{城市自然灾害。}我国70\%以上的大城市集中在东部经济发达地区及沿海开放地带,其中不少地区是自然灾害的易发、多发区。(2)\textbf{城市人为灾害。}包括人类管理不善或疏忽、错误造成的,如火灾与爆炸、城市工业与高新技术致灾、公害致灾、城市生命保障线事故、交通事故等,以及人类的故意行为造成的灾害。从危险化学品事故看,危化品在产、运、储、销、用及废弃处理等环节中都有可能由于管理不善或疏忽、错误引起事故,造成人员伤亡和财产的损失。各类城市公共安全事件暴露出了我国城市韧性不足,公共应急管理体系软件建设和公共安全设施硬件建设都存在明显短板。
|
||||||
|
|
||||||
|
\section{生态文明建设的基本观念}
|
||||||
|
2017年,党的十九大报告提出了“2035年基本实现社会主义现代化、2050年建成富强民主文明和谐美丽的社会主义现代化强国的宏伟目标。” 在“富强民主文明和谐”之后增加“美丽”一词,体现了我党对社会主义现代化内涵理解的不断全面和深化,体现了“环境就是民生,青山就是美丽,蓝天也是幸福”的生态价值理念。
|
||||||
|
|
||||||
|
在此目标之下,党中央提出“十四五”时期经济社会发展要以推动高质量发展为主题,这是根据我国发展阶段、发展环境、发展条件变化做出的科学判断。高质量发展就是从“有没有”到“好不好”的发展,就是要着力解决发展不平衡不充分问题,解决这一社会主要矛盾的过程,就是高质量供给加快成长、升级需求得到有效满足的过程。
|
||||||
|
|
||||||
|
推动高质量发展,必须坚定不移贯彻新发展理念,以满足人民对美好生活的需要为出发点,以深化供给侧结构性改革为主线,坚持质量第一、效益优先,切实转变发展方式,推动质量变革、效率变革、动力变革,实现发展质量、结构、规模、速度、效益、安全相统一。
|
||||||
|
|
||||||
|
高质量发展是体现五大新发展理念的发展,是创新成为第一动力、协调成为内生特点、绿色成为普遍形态、开放成为必由之路、共享成为根本目的的发展。完整、准确、全面贯彻新发展理念,必须坚持系统观念,统筹国内国际两个大局,统筹“五位一体”总体布局和“四个全面”战略布局,加强前瞻性思考、全局性谋划、战略性布局、整体性推进。
|
||||||
|
|
||||||
|
2019年全国两会期间,习总书记参加内蒙古代表团审议时指出:“党的十八大以来,我们党关于生态文明建设的思想不断丰富和完善,在‘五位一体’总体布局中,生态文明建设是其中一位,在新时代坚持和发展中国特色社会主义基本方略中坚持人与自然和谐共生是其中一条基本方略,在新发展理念中绿色是其中一大理念,在三大攻坚战中污染防治是其中一大攻坚战。这‘四个一’体现了我们党对生态文明建设规律的把握,体现了生态文明建设在新时代党和国家事业发展中的地位,体现了党对建设生态文明的部署和要求。各地区各部门要认真贯彻落实,努力推动我国生态文明建设迈上新台阶。”
|
||||||
|
|
||||||
|
习近平生态文明思想是新时代中国特色社会主义思想的重要组成部分,是贯彻绿色发展理念、探索“生态优先、绿色发展”路径的指导思想和行动指南。全面准确地理解和认识习近平生态文明思想有助于从整体上把握新时代中国特色社会主义思想,更好地贯彻党的十九大精神,推进绿色发展,实现中国的绿色崛起。习近平生态文明思想集中展现了八个方面思维:
|
||||||
|
|
||||||
|
\subsection{历史思维:“生态兴则文明兴,生态衰则文明衰”}
|
||||||
|
生态环境是人类生存和发展的根基,生态环境变化直接影响文明兴衰演替。2013年5月24日,习总书记在主持十八届中共中央政治局第六次集体学习时指出:“生态文明是人类社会进步的重大成果。人类经历了原始文明、农业文明、工业文明,生态文明是工业文明发展到一定阶段的产物,是实现人与自然和谐发展的新要求。历史地看,生态兴则文明兴,生态衰则文明衰。古今中外,这方面的事例众多。”古代埃及、古代巴比伦、古代印度、古代中国四大文明古国均发源于森林茂密、水量丰沛、田野肥沃的地区。而生态环境衰退特别是严重的土地荒漠化则导致古代埃及、古代巴比伦衰落。我国古代一些地区,例如楼兰古国、河西走廊、黄土高原等,也有过惨痛教训。
|
||||||
|
|
||||||
|
生态文明建设是关系中华民族永续发展的根本大计。中华民族向来尊重自然、热爱自然,绵延5000多年的中华文明孕育着丰富的生态文化。奔腾不息的长江、黄河是中华民族的摇篮,哺育了灿烂的中华文明。2016年8月,习总书记在青海考察工作时强调:“党的十八大以来,我反复强调生态环境保护和生态文明建设,就是因为生态环境是人类生存最为基础的条件,是我国持续发展最为重要的基础。‘天育物有时,地生财有限。’生态环境没有替代品,用之不觉,失之难存。人类发展活动必须尊重自然、顺应自然、保护自然,否则就会遭到大自然的报复,这是规律,谁也无法抗拒。”
|
||||||
|
|
||||||
|
\subsection{全球思维:“共谋全球生态文明建设”}
|
||||||
|
生态文明建设关乎人类未来,建设绿色家园是各国人民的共同梦想。国际社会需要加强合作、共同努力,构建尊崇自然、绿色发展的生态体系,推动实现全球可持续发展。任何一国都无法置身事外、独善其身。习总书记强调:“我们建设现代化国家,走美欧老路是走不通的,再有几个地球也不够中国人消耗。‘走老路,去消耗资源,去污染环境,难以为继!’”
|
||||||
|
|
||||||
|
我国已成为全球生态文明建设的重要参与者、贡献者、引领者,主张加快构筑尊崇自然、绿色发展的生态体系,共建清洁美丽的世界。要深度参与全球环境治理,增强我国在全球环境治理体系中的话语权和影响力,积极引导国际秩序变革方向,形成世界环境保护和可持续发展的解决方案。要坚持环境友好,引导应对气候变化国际合作,要推进“一带一路”建设,让生态文明的理念和实践造福沿线各国人民。
|
||||||
|
|
||||||
|
2020年9月22日,习总书记在第七十五届联合国大会一般性辩论上宣布:“中国将提高国家自主贡献力度,采取更加有力的政策和措施,二氧化碳排放力争于2030年前达到峰值,努力争取2060年前实现碳中和。” 2020年12月12日,习总书记在气候雄心峰会上指出:“到2030年,中国单位国内生产总值二氧化碳排放将比2005年下降65\%以上,非化石能源占一次能源消费比重将达到25\%左右,森林蓄积量将比2005年增加60亿立方米,风电、太阳能发电总装机容量将达到12亿千瓦以上。”2021年4月22日,习总书记在领导人气候峰会上指出,“气候变化带给人类的挑战是现实的、严峻的、长远的。但是,我坚信,只要心往一处想、劲往一处使,同舟共济、守望相助,人类必将能够应对好全球气候环境挑战,把一个清洁美丽的世界留给子孙后代。”
|
||||||
|
|
||||||
|
\subsection{底线思维:“坚守资源上限、环境底线、生态红线”}
|
||||||
|
2018年5月18日,习总书记在全国生态环境保护大会上指出,“之所以反复强调要高度重视和正确处理生态文明建设问题,就是因为我国环境容量有限,生态系统脆弱,污染重、损失大、风险高的生态环境状况还没有根本扭转,并且独特的地理环境加剧了地区间的不平衡。”要加快形成节约资源和保护环境的空间格局、产业结构、生产方式、生活方式,把经济活动、人的行为限制在自然资源和生态环境能够承受的限度内,给自然生态留下休养生息的时间和空间。
|
||||||
|
|
||||||
|
2018年5月18日,习总书记在全国生态环境保护大会上指出:“要加快划定并严守生态保护红线、环境质量底线、资源利用上线三条红线。对突破三条红线、仍然沿用粗放增长模式、吃祖宗饭砸子孙碗的事,绝对不能再干,绝对不允许再干。”在生态保护红线方面,要建立严格的管控体系,实现一条红线管控重要生态空间,确保生态功能不降低、面积不减少、性质不改变。在环境质量底线方面,将生态环境质量只能更好、不能变坏作为底线,并在此基础上不断改善,对生态破坏严重、环境质量恶化的区域必须严肃问责。在资源利用上线方面,不仅要考虑人类和当代的需要,也要考虑大自然和后人的需要,把握好自然资源开发利用的度,不要突破自然资源承载能力。
|
||||||
|
|
||||||
|
\subsection{民本思维:“良好生态环境是最普惠的民生福祉”}
|
||||||
|
我国社会主要矛盾转化为人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾,人民群众对优美生态环境需要已经成为这一矛盾的重要方面。
|
||||||
|
|
||||||
|
良好生态环境是最普惠、也是最基本的民生福祉。2013年4月,习总书记在海南考察时指出:“对人的生存来说,金山银山固然重要,但绿水青山是人民幸福生活的重要内容,是金钱不能代替的。你挣到了钱,但空气、饮用水都不合格,哪有什么幸福可言。”
|
||||||
|
|
||||||
|
2015年,习总书记参加江西代表团审议时指出:“环境就是民生,青山就是美丽,蓝天也是幸福。”要积极回应人民群众所想、所盼、所急,大力推进生态文明建设。要坚持生态惠民、生态利民、生态为民,重点解决损害群众健康的突出环境问题,加快改善生态环境质量,提供更多优质生态产品,努力实现社会公平正义,不断满足人民日益增长的优美生态环境需要。
|
||||||
|
|
||||||
|
每个人都是生态环境的保护者、建设者、受益者,要增强全民节约意识、环保意识、生态意识,培育生态道德和行为准则,开展全民绿色行动,动员全社会都以实际行动减少能源资源消耗和污染排放,为生态环境保护做出贡献。
|
||||||
|
|
||||||
|
\subsection{整体思维:“坚持人与自然和谐共生”}
|
||||||
|
2018年5月4日,习总书记在纪念马克思诞辰200周年大会上指出,学习马克思,就要学习和实践马克思主义关于人与自然关系的思想。“自然物构成人类生存的自然条件,人类在同自然的互动中生产、生活、发展,人类善待自然,自然也会馈赠人类,但‘如果说人靠科学和创造性天才征服了自然力,那么自然力也对人进行报复’。自然是生命之母,人与自然是生命共同体,人类必须敬畏自然、尊重自然、顺应自然、保护自然。”
|
||||||
|
|
||||||
|
“天地与我并生,而万物与我为一。”生态环境没有替代品,用之不觉,失之难存。《吕氏春秋》中说:“竭泽而渔,岂不获得?而明年无鱼;焚薮而田,岂不获得?而明年无兽。”这些关于对自然要取之以时、取之有度的思想,有十分重要的现实意义。当人类合理利用、友好保护自然时,自然的回报常常是慷慨的;当人类无序开发、粗暴掠夺自然时,自然的惩罚必然是无情的。人类对大自然的伤害最终会伤及人类自身,这是无法抗拒的规律。
|
||||||
|
|
||||||
|
2018年5月18日,习总书记在全国生态环境保护大会上指出:“在整个发展过程中,都要坚持节约优先、保护优先、自然恢复为主的方针,不能只讲索取不讲投入,不能只讲发展不讲保护,不能只讲利用不讲修复,要像保护眼睛一样保护生态环境,像对待生命一样对待生态环境,多谋打基础、利长远的善事,多干保护自然、修复生态的实事,多做治山理水、显山露水的好事,让群众望得见山、看得见水、记得住乡愁,让自然生态美景永驻人间,还自然以宁静、和谐、美丽。”
|
||||||
|
|
||||||
|
\subsection{系统思维:“山水林田湖草是生命共同体”}
|
||||||
|
习总书记深刻指出:“生态是统一的自然系统,是相互依存、紧密联系的有机链条。人的命脉在田,田的命脉在水,水的命脉在山,山的命脉在土,土的命脉在林和草,这个生命共同体是人类生存发展的物质基础。一定要算大账、算长远账、算整体账、算综合账,如果因小失大、顾此失彼,最终必然对生态环境造成系统性、长期性破坏。”
|
||||||
|
|
||||||
|
2013年11月9日,习总书记在《关于〈中共中央关于全面深化改革若干重大问题的决定〉的说明》中指出:“用途管制和生态修复必须遵循自然规律,如果种树的只管种树、治水的只管治水、护田的单纯护田,很容易顾此失彼,最终造成生态的系统性破坏。由一个部门负责领土范围内所有国土空间用途管制职责,对山水林田湖进行统一保护、统一修复是十分必要的。”
|
||||||
|
|
||||||
|
要从系统工程和全局角度寻求新的治理之道,统筹兼顾、整体施策、多措并举,全方位、全地域、全过程开展生态文明建设。2014年3月14日,习总书记主持召开中央财经领导小组第五次会议时指出:“在经济社会发展方面我们提出了‘五个统筹’,治水也要统筹自然生态的各要素,不能就水论水。要用系统论的思想方法看问题,生态系统是一个有机生命躯体,应该统筹治水和治山、治水和治林、治水和治田、治山和治林等。”
|
||||||
|
|
||||||
|
2018年5月18日,习总书记在全国生态环境保护大会上指出:“要从系统工程和全局角度寻求新的治理之道,不能再是头痛医头、脚痛医脚,各管一摊、相互掣肘,而必须统筹兼顾、整体施策、多措并举,全方位、全地域、全过程开展生态文明建设。比如,治理好水污染、保护好水环境,就需要全面统筹左右岸、上下游、陆上水上、地表地下、河流海洋、水生态水资源、污染防治与生态保护,达到系统治理的最佳效果。”
|
||||||
|
|
||||||
|
2019年9月18日,习总书记在郑州主持召开黄河流域生态保护和高质量发展座谈会时指出:“治理黄河,重在保护,要在治理。要坚持山水林田湖草综合治理、系统治理、源头治理,统筹推进各项工作,加强协同配合,推动黄河流域高质量发展。”
|
||||||
|
|
||||||
|
\subsection{辩证思维:“绿水青山就是金山银山”}
|
||||||
|
回望40年的改革开放,我国提供农产品、工业品、服务产品的能力显著增强,但是,我国在提供生态产品的能力上是下降的,这成为当前要素供给中的突出短板,当自然资源成为稀缺性资源和产业资本投资获利的对象时,生态产品就具有了商品性质,成为高使用价值和高资产价值的统一体。以此为前提,“绿水青山”就有可能转化为“金山银山”,人类也就此步入生态文明新时代。
|
||||||
|
|
||||||
|
2020年4月21日,习总书记在陕西安康市平利县老县镇蒋家坪村考察时指出:“人不负青山,青山定不负人。绿水青山既是自然财富,又是经济财富。希望乡亲们坚定不移走生态优先、绿色发展之路,因茶致富、因茶兴业,脱贫奔小康。”
|
||||||
|
|
||||||
|
“两山理论”核心是保护与发展间良性互动、对立统一的辩证关系。经济发展不能超越资源环境的承载力底线,不应是对资源环境的“竭泽而渔”;生态保护应当是顺应经济发展规律的积极、主动保护,而不是舍弃经济发展的“缘木求鱼”。“两山理论”揭示了保护生态环境就是保护生产力、改善生态环境就是发展生产力的道理,指明了实现发展和保护协同共生的新路径。绿水青山既是自然财富、生态财富,又是社会财富、经济财富。保护生态环境就是保护自然价值和增值自然资本,就是保护经济社会发展潜力和后劲,使绿水青山持续发挥生态效益和经济社会效益。
|
||||||
|
|
||||||
|
要以体制机制改革创新为核心,推进生态产业化和产业生态化,加快完善政府主导、企业和社会各界参与、市场化运作、可持续的生态产品价值实现路径,着力构建绿水青山转化为金山银山的政策制度体系,推动形成具有中国特色的生态文明建设新模式。
|
||||||
|
|
||||||
|
\subsection{法制思维:“用最严格制度最严密法治保护生态环境”}
|
||||||
|
2015年,习总书记参加江西代表团审议时指出:“要像保护眼睛一样保护生态环境,像对待生命一样对待生态环境。对破坏生态环境的行为,不能手软,不能下不为例。”
|
||||||
|
|
||||||
|
保护生态环境必须依靠制度、依靠法治。习总书记指出:“要加快制度创新,增加制度供给,完善制度配套,强化制度执行,让制度成为刚性的约束和不可触碰的高压线。要严格用制度管权治吏、护蓝增绿,有权必有责、有责必担当、失责必追究,保证党中央关于生态文明建设决策部署落地生根见效。”
|
||||||
|
|
||||||
|
2016年11月28日,习总书记就做好生态文明建设工作作出重要批示指出:“要深化生态文明体制改革,尽快把生态文明制度的‘四梁八柱’建立起来,把生态文明建设纳入制度化、法治化轨道。要加大环境督查工作力度,严肃查处违纪违法行为,着力解决生态环境方面突出问题,让人民群众不断感受到生态环境的改善。”
|
||||||
|
|
||||||
|
制度的生命力在于执行,对于已出台的一系列改革举措和相关制度,要像抓中央环境保护督察一样抓好落实。要落实领导干部生态文明建设责任制,严格考核问责。对那些不顾生态环境盲目决策、造成严重后果的人,必须追究其责任,而且应该终身追责。
|
||||||
|
|
||||||
|
\section{生态文明建设的总体路径}
|
||||||
|
迈入新发展阶段,意味着高水平保护、高质量发展、高品质生活、高效能治理的协同并进,社会、经济、生态、治理发展的方方面面也将实现由量到质、由大到强的一次大跨越。
|
||||||
|
|
||||||
|
\subsection{高水平保护——框定生态资源环境安全底线}
|
||||||
|
生态文明建设是关系中华民族永续发展的根本大计,是实现人与自然和谐发展的新要求。
|
||||||
|
|
||||||
|
要尊重自然规律,树立底线思维,全面贯彻落实生态文明思想,坚持总体国家安全观,筑牢国家生态安全屏障。
|
||||||
|
|
||||||
|
要建立以国家公园为主体的自然保护地体系,推进自然保护地整合优化、统一设置、分级管理、分区管控,系统保护陆地和海洋重要自然生态系统,保护生物多样性;严格划定生态保护红线,将整合优化后的自然保护地、生态极度重要和极度脆弱区域以及国家一级公益林、重要湿地、饮用水源地一级保护区、冰川及永久积雪、红树林、珊瑚礁等重要生态系统等划入生态红线。
|
||||||
|
|
||||||
|
要按照山水林田湖草整体保护、系统修复、综合治理的要求,抓住重点区域、重点流域、重点海域的突出生态问题,统筹山水林田湖草系统保护修复、大规模国土绿化、海洋生态修复、荒漠化治理等重大工程。立足我国国情,探索基于自然的生态保护修复解决方案,提高生态修复的科学性和有效性。
|
||||||
|
|
||||||
|
耕地是国家粮食安全的根本保障,要严守耕地保护红线,实行严格的耕地用途管制,将中国人的饭碗牢牢端在自己手上。要强化耕地数量、质量、生态“三位一体”保护理念,实行严格的耕地占补平衡制度,促进形成保护更加有力、执行更加顺畅、管理更加高效的耕地保护新格局。
|
||||||
|
|
||||||
|
要立足本地自然资源条件,在建设过程中保护、传承历史文化和特色风貌,持续改善人居环境,协同推进经济高质量发展和生态环境高水平保护,切实增强人民群众生态环境获得感、幸福感和安全感。
|
||||||
|
|
||||||
|
\subsection{高质量发展——促进要素向优势地区集聚}
|
||||||
|
实现高质量发展要完整、准确、全面贯彻新发展理念,以创新发展解决发展动力问题,以协调发展解决发展不平衡问题,以绿色发展解决人与自然和谐问题,以开放发展解决发展内外联动问题,以共享发展解决社会公平正义问题。
|
||||||
|
|
||||||
|
要尊重经济规律,促进产业和人口向优势区域集中,形成以城市群为主要形态的增长动力源,进而带动经济总体效率提升;破除资源流动障碍,使市场在资源配置中起决定性作用,促进各类生产要素自由流动并向优势地区集中,提高资源配置效率。
|
||||||
|
|
||||||
|
同时,高质量发展不是仅对经济发达地区的要求,而是对所有地区无论处于何种发展阶段都需要践行的要求。我国国内各地区的资源禀赋、区位条件、经济基础、发展阶段各不相同,要发挥比较优势,推动生态和文化产品转化,探索差异化、特色化发展道路,加快动力转换、效率提升、结构优化。
|
||||||
|
|
||||||
|
总体上看,要加快构建高质量发展的动力系统,增强中心城市和城市群等经济发展优势地区的经济和人口承载能力,增强其他地区在保障粮食安全、生态安全、边疆安全,弘扬地域特征、民族文化、时代风貌等方面的功能,形成优势互补、高质量发展的国土空间格局。
|
||||||
|
|
||||||
|
\subsection{高品质生活——满足人民美好生活需要}
|
||||||
|
我国社会主要矛盾已经转化为人民日益增长的美好生活需要和不平衡不充分的发展之间的矛盾,以前我们要解决的是有没有的问题,现在则是要解决好不好的问题。
|
||||||
|
|
||||||
|
要尊重社会规律,紧扣人民对美好生活向往呈现出的多元化、多层次、多方面的特点,推动空间的供给侧改革,从关注量的多少转向重视质的提升。
|
||||||
|
|
||||||
|
要顺应人口结构变化趋势,建设全龄友好型城市。推进义务教育和学龄前教育设施均衡发展,促进儿童友好型社会建设;增加养老、医疗服务设施有效供给,提升老年人的幸福感和安全感;完善面向新青市民的生活、工作服务设施。以完善社区基本治理单元为重点,打造“15分钟社区生活圈”,以“共同缔造”为核心,创新完善社区治理机制,构建“纵向到底、横向到边、协商共治”的社会治理体系。
|
||||||
|
|
||||||
|
要坚持城乡一体、区域协同发展,推进全体人民共同富裕,通过完善住房保障体系、推进城镇基本公共服务均等化等方式,实现幼有所育、学有所教、劳有所得、病有所医、老有所养、住有所居、弱有所扶,保证全体人民在共建共享发展中有更多获得感。
|
||||||
|
|
||||||
|
\subsection{高效能治理——推进空间治理体系和治理能力现代化}
|
||||||
|
要破解规划类型过多、内容重叠冲突,审批流程复杂、周期过长,地方规划朝令夕改等问题,建立全国统一、责权清晰、科学高效的国土空间规划体系,整体谋划新时代国土空间开发保护格局。
|
||||||
|
|
||||||
|
要坚持事权对应原则,对应市场经济为主体下的有为政府事权,确定规划内容;对应各级政府行政事权,区分各级规划内容清单。以行政主体事权“清单”确定规划内容“清单”,明确规划的实施、管理、监督的责任主体。
|
||||||
|
|
||||||
|
要坚持有效行政原则,建立各级规划内容之间的刚性传递关系,避免任何一个环节的“断链”导致整体约束性的丧失;同时区分定性、定则、定量、定构、定界等不同精度的刚性内容,以对应不同层级政府事权,在确保规划管控“基因”延续的前提下为下位规划预留深化细化空间。
|
||||||
|
|
||||||
|
要坚持层次最简原则,在有效行政前提下实现规划层次的最简化,从而最大化地降低行政成本,并降低“刚性断链、整体失效”的概率。
|
||||||
|
|
||||||
|
要通过完善法规政策体系、完善技术标准体系、完善基础信息平台等方式强化规划法规政策与技术保障;通过强化规划法律地位、改进规划审批、健全用途管制制度、监督规划实施、推进“放管服”改革等方式强化规划实施与监管;通过加强组织领导、落实工作责任保障规划实施。
|
||||||
|
|
||||||
|
\section{生态文明下的人居环境与国土空间和谐共存}
|
||||||
|
成立自然资源部和推动国土空间规划都是源自生态文明建设这个最关键、最重要的时代背景之下,自然资源部贯彻落实党中央关于自然资源工作的方针政策和决策部署,在履行职责过程中坚持和加强党对自然资源工作的集中统一领导,国土空间规划是国家空间发展的指南、可持续发展的空间蓝图,是各类开发保护建设活动的基本依据。
|
||||||
|
|
||||||
|
“绿水青山就是金山银山”“人与自然和谐共生”“良好生态环境是最普惠的民生福祉”“山水林田湖草是生命共同体”“中华民族永续发展的根本大计”等习近平总书记关于生态文明建设的系列科学论断深刻地回答了为什么建设生态文明、建设什么样的生态文明、怎样建设生态文明等重大理论和实践问题,构成了习近平新时代中国特色社会主义思想的重要组成部分,也是中国生态文明建设的根本指导思想,也为自然资源部的职能定位、国土空间规划编制实施提供了最重要的理论支撑和实践依据。
|
||||||
|
|
||||||
|
\subsection{探索以生态优先、人居环境绿色发展为导向的新路子}
|
||||||
|
(1)坚持“生态优先、人居环境绿色发展”,是积极应对全球气候变化、共建人类命运共同体、以一个负责任的大国对全球做出的庄严承诺。从全球竞争力视角看,推进绿色低碳发展将成为衡量一个国家是否能够被其他国家所接受和认同、融入全球共同价值观的重要标准,将推动一个国家占据道义制高点和战略制高点。
|
||||||
|
|
||||||
|
(2)坚持“生态优先、人居环境绿色发展”,是在资源环境紧约束条件下、秉持立足自身的资源安全观、实现两个一百年中国梦的必由之路。通过资源、能源的集约高效循环利用,实现单位消耗的产出提升,并逐步实现资源环境消耗与经济社会发展脱钩。
|
||||||
|
|
||||||
|
(3)坚持“生态优先、人居环境绿色发展”,是满足人民对美好生活需要、建设美丽中国的前提和基础。良好生态环境是最公平的公共产品,也是最普惠的民生福祉,既要创造更多物质财富和精神财富以满足人民日益增长的美好生活需要,也要提供更多优质生态产品以满足人民日益增长的优美生态环境需要。城市发展的逻辑从过去“人跟着产业走”,转变为“人才跟着环境走,产业跟着人才走”,山清水秀的诗意栖居之地成为容纳新人才、孕育新经济的载体,成为城市的核心竞争力。
|
||||||
|
|
||||||
|
\subsection{生态文明下的国土空间导向}
|
||||||
|
\subsubsection{1.国土空间是山水林田湖草“生命共同体”}
|
||||||
|
习近平总书记指出:“生态是统一的自然系统,是相互依存、紧密联系的有机链条。”必须从系统工程和全局角度寻求新的治理之道,更加注重综合治理、系统治理、源头治理,实施好生态保护修复工程,加大生态系统保护力度,提升生态系统稳定性和可持续性。统筹山水林田湖草沙系统治理,深刻揭示了生态系统的整体性、系统性及其内在发展规律,为全方位、全地域、全过程开展生态文明建设提供了方法论指导。
|
||||||
|
|
||||||
|
国土空间是以自然生态环境为基底、承载人类多样化经济社会活动的综合性空间载体。从自然资源的整体性与系统性角度合理统筹国土空间规划,达到人与自然之间的相互平衡关系,要求我们统筹好局部与整体、开发与保护、近期与远期的关系,实现人与资源之间和谐共生,经济社会可持续发展。
|
||||||
|
|
||||||
|
\subsubsection{2.生态文明建设是国土空间规划的灵魂}
|
||||||
|
生态文明建设是关系人民福祉、关乎民族未来的长远大计,是建设美丽中国、实现中华民族永续发展的根本保障,也是各级人民政府以及全社会的思想指南和根本标杆,生态文明建设是中国特色社会主义事业的重要内容,关系人民福祉,关乎民族未来,事关“两个一百年”奋斗目标和中华民族伟大复兴中国梦的实现,同时,生态文明建设也构成了国土空间规划最重要的时代背景,为国土空间规划注入了基于“绿水青山就是金山银山”的生态文明观和“山水林田湖草是生命共同体”的自然资源观,这两个科学观点在理论上构成了国土空间规划的灵魂和基石,在实践上则为国土空间规划编制实施提供了可操作、可应用的工作支点。可以预见,生态文明建设不仅能实现“既要金山银山,又要青山绿水”的战略目标,又必然能为中华民族永享集约高效的生产空间、优美宜居的生活空间、山清水秀的生态空间提供科学的世界观和方法论,其必将促进生产方法和生活方式的根本改变,进而促进中国经济和社会发展、自然生态环境保护的全新转型,从而为实现真正的可持续发展夯实基础。
|
||||||
|
|
||||||
|
在资源环境紧约束的条件下,统筹山水林田湖草沙系统治理,加强生物多样性保护,提升生态系统质量和稳定性,着力建设健康美丽的人居环境。唯有完整、准确、全面贯彻习近平生态文明思想,牢牢把握全面推动高质量发展的根本遵循,探索以生态优先、国土空间与人居环境和谐发展为导向的高质量发展新路子,方能实现中华民族的伟大复兴,也方能成为全球生态文明建设的重要参与者、贡献者、引领者。
|
||||||
|
|
||||||
|
第1章生态文明下的人居环境和国土空间 1
|
||||||
|
|
||||||
|
1.1我国人居环境发展历程 1
|
||||||
|
|
||||||
|
1.1.1原始时期 1
|
||||||
|
|
||||||
|
1.1.2古代时期 1
|
||||||
|
|
||||||
|
1.1.3近代时期 2
|
||||||
|
|
||||||
|
1.1.4现代时期 3
|
||||||
|
|
||||||
|
1.2我国传统人居环境的理念与人居环境学科发展 9
|
||||||
|
|
||||||
|
1.2.1人居环境的核心理念 9
|
||||||
|
|
||||||
|
1.2.2人居环境的科学发展 11
|
||||||
|
|
||||||
|
1.3生态文明演进与我国国土空间的生态安全问题 12
|
||||||
|
|
||||||
|
1.3.1生态文明的演进 12
|
||||||
|
|
||||||
|
1.3.2我国生态资源的基本情况 13
|
||||||
|
|
||||||
|
1.3.3我国生态资源环境挑战 13
|
||||||
|
|
||||||
|
1.4生态文明建设的基本观念 15
|
||||||
|
|
||||||
|
1.4.1历史思维:“生态兴则文明兴,生态衰则文明衰” 16
|
||||||
|
|
||||||
|
1.4.2全球思维:“共谋全球生态文明建设” 16
|
||||||
|
|
||||||
|
1.4.3底线思维:“坚守资源上限、环境底线、生态红线” 17
|
||||||
|
|
||||||
|
1.4.4民本思维:“良好生态环境是最普惠的民生福祉” 17
|
||||||
|
|
||||||
|
1.4.5整体思维:“坚持人与自然和谐共生” 17
|
||||||
|
|
||||||
|
1.4.6系统思维:“山水林田湖草是生命共同体” 18
|
||||||
|
|
||||||
|
1.4.7辩证思维:“绿水青山就是金山银山” 18
|
||||||
|
|
||||||
|
1.4.8法制思维:“用最严格制度最严密法治保护生态环境” 19
|
||||||
|
|
||||||
|
1.5生态文明建设的总体路径 19
|
||||||
|
|
||||||
|
1.5.1高水平保护——框定生态资源环境安全底线 20
|
||||||
|
|
||||||
|
1.5.2高质量发展——促进要素向优势地区集聚 20
|
||||||
|
|
||||||
|
1.5.3高品质生活——满足人民美好生活需要 21
|
||||||
|
|
||||||
|
1.5.4高效能治理——推进空间治理体系和治理能力现代化 21
|
||||||
|
|
||||||
|
1.6生态文明下的人居环境与国土空间和谐共存 21
|
||||||
|
|
||||||
|
1.6.1探索以生态优先、人居环境绿色发展为导向的新路子 22
|
||||||
|
|
||||||
|
1.6.2生态文明下的国土空间导向 22
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
时期 & 特征 & 人居环境情况 & 影响 \\
|
||||||
|
\hline
|
||||||
|
先秦 & 源起与发轫 & 中国人居雏形初现;“聚——邑——都”的居住演化;人居环境整体经营体系已大致形成 & 先秦时期人居的演进,从聚落到国家,反映出“世界”的变迁以及“世界观”的形成与发展,在中国人居史上,先秦时期具有开篇的意义 \\
|
||||||
|
秦汉 & 统一与奠基 & “天下人居”支撑体系构建形成。中国开始在世界人居体系中发挥影响和作用(丝绸之路与世界沟通),人居发展中文化的影响加大 & 秦汉时期在前所未有的大一统的局面下,在人居建设的多方面进行了探索和创造,奠定了此后两千年中国人居环境的大框架和基础 \\
|
||||||
|
魏晋南北朝 & 交融与创新 & 重视自然之美、多元人文融合创新。都城人居模式继承秦汉,并进行新的探索(轴线在都城中的使用) & 魏晋南北朝时期虽然社会动荡不安,但是南方与北方不同民族在分裂中进行人居文化的交融与创新,呈现出勃勃生机 \\
|
||||||
|
隋唐 & 成熟与辉煌 & 中国人居走向成熟与辉煌。逐步形成“建筑——规划——园林”制度。“礼乐教法”营造秩序成熟 & 隋唐人居是在秦汉开创的基础上,继承了魏晋南北朝的遗产,广泛吸纳世界优秀文明的人居精华后,进行了新的融合,中国人居走向成熟与辉煌,走向世界,影响深远 \\
|
||||||
|
宋元 & 变革与涌现 & 古代科技经济的快速发展,市民第一次作为城市的主导。人口增长影响城市格局 & 宋代是古代文化发展的黄金时代或“文艺复兴”时代,政治、经济、文化、科技等多重变化共同激发人居环境建设发生变革,从都城到地方都涌现出新的格局,达到了新的高度,整体呈现出“分水岭”的局面 \\
|
||||||
|
明清 & 博大与充实 & 农业社会人居环境成就达到巅峰。中心文化交流,激发原有人居活力 & 明清时期是统一的多民族国家形成和巩固的重要时期,中国人居按照汉唐奠定的框架,继承宋元人居成就,进一步充实和完善,伴随着社会文化的发展达到了极致,形成了完备的人居体系。适应统一多民族国家的天下人居空间秩序在这一时期定型 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
城市类型 & 影响因素 & 城市 \\
|
||||||
|
\hline
|
||||||
|
由帝国主义侵略、外国资本输入、本国资本的发展而产生较大变化或新兴起的城市 & 长期受某个帝国主义国家的控制 & 青岛、广州湾、哈尔滨、旅大等 \\
|
||||||
|
由帝国主义侵略、外国资本输入、本国资本的发展而产生较大变化或新兴起的城市 & 处在几个帝国主义占据下,有特殊的租界地 & 上海、天津、汉口等 \\
|
||||||
|
由帝国主义侵略、外国资本输入、本国资本的发展而产生较大变化或新兴起的城市 & 中国官僚资本或民族资本开办的新的工矿企业而兴起的新城市 & 河北唐山、河南焦作、湖南锡矿山、湖北大冶、玉门等 \\
|
||||||
|
由帝国主义侵略、外国资本输入、本国资本的发展而产生较大变化或新兴起的城市 & 现代化交通影响下的城市发展 & 郑州、徐州、石家庄、蚌埠、浦口等 \\
|
||||||
|
原来的封建城市受到帝国主义的侵入及本国资本主义的发展 & 长期作为封建统治中心的城市 & 北京、西安、成都、太原、南昌、长沙、兰州等 \\
|
||||||
|
原来的封建城市受到帝国主义的侵入及本国资本主义的发展 & 由于资本主义工商业的发展而发生变化的城市 & 南通、无锡、内江、自贡等 \\
|
||||||
|
原来的封建城市受到帝国主义的侵入及本国资本主义的发展 & 辟为商埠或设有租界的沿江沿海城市 & 南京、济南、沈阳、宁波、福州、芜湖、九江、重庆、万县、烟台等 \\
|
||||||
|
原来的封建城市受到帝国主义的侵入及本国资本主义的发展 & 传统的手工业商业中心或位于交通要道的城市 & 临清、淮阴、淮安、扬州、浏河、嘉定等 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
\chapter{自然资源与土地利用}
|
||||||
|
\section{自然资源的概念、构成与属性}
|
||||||
|
\subsection{自然资源的概念}
|
||||||
|
资源与自然资源是相互包含又有一定区别的一对概念,1972年联合国环境规划署(UNEP)把资源定义为“资源,特别是自然资源,是一定时间、一定空间条件下能产生经济价值以提高人类当前及将来福利的自然环境的因素和条件”。20世纪90年代学术界认为资源的内涵应当包括现存的各种自然要素及由其组合而生成的自然环境,既包括人类利用自然要素加工、改造、生产出的各种经济物品及由其组成的各种经济环境,也包括人类在上述基础上形成并不断增长的人口、知识、技术、文化、管理体制等均可称为资源。一般意义上认为,自然资源是指存在于自然界中,在现有生产力发展水平和研究条件下,为了满足人类的生产和生活需要而被利用的自然物质和能量。
|
||||||
|
|
||||||
|
以上对自然资源的诠释互有区别,但都包含了以下几方面的含义:一是自然资源不是脱离生产应用的抽象对象,而是在不同时空组合范围内有可能为人类提供福利的物质和能量;二是自然资源的概念、范畴不是一成不变的,随着社会的发展和科学技术的进步,人类对自然资源的理解不断加深,对自然资源开发利用的广度和深度不断提高;三是自然资源不同于自然环境,自然环境指人类周围所有的外界客观存在物,自然资源则是从人类的需用角度来理解这些因素存在的价值。从具体的对象看,它们往往是同一种物质,但又是两个不同的概念。
|
||||||
|
|
||||||
|
综合以上各种阐述,我们可以对自然资源做如下的定义:自然资源是指在一定的社会经济发展条件下,自然界中一切能够为人类所利用并产生使用价值的、能够提高人类当前或可预见未来生存质量的自然诸要素的总和。
|
||||||
|
|
||||||
|
\subsection{自然资源的类型}
|
||||||
|
由于自然资源的广泛性和多宜性,以及对自然资源理解的深度和广度的差异,学界目前还缺乏统一的自然资源分类系统。按照不同的目的和要求,自然资源有许多不同的分类方法和分类系统,下面列举几种常见的分类:
|
||||||
|
|
||||||
|
\subsubsection{1.按自然资源的地理特性分类}
|
||||||
|
根据自然资源的形成条件、组合状况、分布规律及与地理环境各圈层的关系等地理特性,通常把自然资源划分为矿产资源(岩石圈)、土地资源(地球表层)、水资源(水圈)、生物资源(生物圈)和气候资源(大气圈)五大类。随着海洋地位的日益突出,海洋资源已开始作为第六类资源进入资源科学的研究领域。
|
||||||
|
|
||||||
|
\subsubsection{2.按自然资源的赋存条件及特征分类}
|
||||||
|
按照自然资源的赋存条件及特征分类的方法将自然资源分为两大类:地下资源和地表资源。地下资源赋存于地壳中,也可称之为地壳资源,主要包括矿物原料和矿物质能源等矿产资源。地表资源赋存于生物圈中,也可称之为生物圈资源,主要包括由地貌、土壤和植被等因素构成的土地资源,由地表水、地下水构成的水资源,由各种植物和动物构成的生物资源,以及由光、热、水等因素构成的气候资源等。
|
||||||
|
|
||||||
|
\subsubsection{3.按自然资源的特征分类}
|
||||||
|
按照自然资源的再生性特征进行分类的方法最为通用。按照自然资源的持续利用性,可将其分为耗竭性资源、非耗竭性资源两大类。耗竭性资源又可细分为可更新资源、不可更新资源两类。可更新资源主要是指由各种生物和非生物要素组成的生态系统,如土地资源、森林资源、水产资源等,在正确的管理和维护下,该类资源可以不断地被更新和利用;反之,则会遭到破坏乃至消耗殆尽。不可更新资源主要是指各种矿物和化石燃料;非耗竭性资源是指在目前的生产条件和技术水平下,自然界中的一些资源不会在利用过程中导致明显的消耗。非耗竭性资源又可细分为恒定性资源、易误用性资源两类,前者如风能、原子能、潮汐能、降水等,它们不会因人类活动而发生明显变异,故称之为恒定性资源;后者如大气、水能、广义景观等各种资源,当人们对它利用不当时会发生较大变异并污染环境,因此称之为易误用性资源。
|
||||||
|
|
||||||
|
上述几种分类体系中,对于自然资源研究和规划利用而言,更多地会采用自然资源的地理特性分类和自然资源的特征分类两种方法。
|
||||||
|
|
||||||
|
\subsubsection{2.1.3自然资源的特性}
|
||||||
|
自然资源具自然属性与社会属性。自然资源均具有一定的使用价值,是自然资源的天然属性,是人类开发利用自然资源的前提条件,具备了自然属性,从而形成明显的自然特征与社会特征相互依存的状态,通过人类的加工并使其成为商品进入流通领域,从而产生一定的经济价值。
|
||||||
|
|
||||||
|
\subsubsection{1.自然特征}
|
||||||
|
(1)有限性
|
||||||
|
|
||||||
|
自然资源具有一定的有限性,也可称之为稀缺性,也就是自然资源的数量供给与人类不断增长的需求存在着持续的矛盾。从这个意义上讲,世界上任何一种自然资源都存在相对有限性。不可再生的自然资源的有限性是绝对的;可再生的自然资源虽然可随时间的推移不断地再生或更新,但在一定的时间和空间内也是有限的,如一个地方单位面积上年平均太阳辐射量是一定的,一条河流上的水力资源是一定的,每亩地的粮食产量在一定时间及空间上也是一定的,过度开发和利用却会带来一定的问题,因此,从可持续发展的角度出发,合理利用和保护自然资源就显得尤为重要。
|
||||||
|
|
||||||
|
(2)系统性
|
||||||
|
|
||||||
|
各种自然资源在自然界中互相依存、互相制约,构成了完整的资源生态循环系统。系统中每个要素都承担着特定的作用,都是系统不可或缺的组成部分。其次,系统各要素之间互相联系,任何一种资源的改变都会影响到其他资源,子系统的变化不可避免地引起大系统的动态变化。对自然资源的开发利用要充分认识自然资源系统的系统性和整体性特点,不能只考虑某一要素或局部地区,要从区域资源的整体出发,树立系统论的思想,使系统结构比较稳定均衡地朝着有利于人居环境生活和生产的方向发展。
|
||||||
|
|
||||||
|
(3)地域性
|
||||||
|
|
||||||
|
地域性可称为自然资源具有的空间属性。地球生态环境中各自然资源在空间分布上都是不均匀的,在数量上或质量上都有显著的地域差异。各种自然资源都有其自身的地域分布规律,既受地带性因素影响,又受非地带性因素影响,有的同时受地带性、非地带性两种因素影响,如气候、水文、土壤和生物的地域分布主要受地带性因素的影响,但同时也受非地带性因素的制约;地质条件、矿产、地形与地貌等主要受非地带性因素的影响。在历史因素影响下,自然资源开发利用的社会经济条件和技术工艺水平也具有地域性差异,因此,自然资源系统的开发与利用要从资源的区域特点出发,因地制宜,发挥优势,形成具有区域特色的资源可持续开发利用系统。
|
||||||
|
|
||||||
|
(4)多元性
|
||||||
|
|
||||||
|
自然资源都具有多种文化和使用功能,各类用途很广。以森林资源为例,森林具有保护环境的功能,可以提供多种原料,产生多种不同的货币效益和土地利用效益,也具有文化旅游价值,它还是重要的物种基因库,在自然界物质和能量的循环交换中具有重要的生态作用等等,同时也要认识到,并非自然资源所有的功能及用途都具有同等重要的地位。在自然资源开发利用时要全面权衡,必须按经济效益、社会效益和生态效益相统一的原则,通过科学的优化方式选择最佳的综合利用方案,做到物有所值、地尽其利。
|
||||||
|
|
||||||
|
\subsubsection{2.社会特征}
|
||||||
|
(1)动态性
|
||||||
|
|
||||||
|
自然资源系统的动态性,一方面表现为自然资源在大自然各种因素下数量、空间、组成、时间的动态变化;另一方面表现为人类开发利用过程中产生的动态变化。为此,对自然资源系统的开发过程中要随时掌握其动态变化情况,适当的评价并采取相应的干预措施维护自然系统正常运转。
|
||||||
|
|
||||||
|
(2)社会性
|
||||||
|
|
||||||
|
自然资源与人居环境相互融合促进是人类持续利用自然、改造自然的结晶,是自然资源中的社会因素。人类通过生产活动,把自然资源加工成有价值的物质财富,从而使自然资源具有广泛的社会属性。自然资源是与一定的社会经济、技术水平相联系的,人类对自然资源的认识、评价和开发利用,都受特定时间、特定空间所制约,使自然资源的社会属性愈加突出。
|
||||||
|
|
||||||
|
\section{土地定义与土地管理制度}
|
||||||
|
\subsection{土地的定义}
|
||||||
|
\subsubsection{1.土地的定义}
|
||||||
|
“土地”是一个使用范围广、外延广泛的概念,自然科学(如地理学、农学等)认为广义的土地概念泛指整个自然界的自然资源,狭义的土地概念指地球表层陆地,上、下一定幅度内的三维空间;经济学认为未经人类加工过的自然土地资源以及人类在开发、改造、利用土地过程中形成的土地固定资产都是土地资产。
|
||||||
|
|
||||||
|
“土地”通常包括土地及其上生长的树木和多年生植物,也包括建造于土地之上并长久定着于土地上的人工构筑物(包括桥梁、涵洞、隧道、道路、各种建筑物等)。
|
||||||
|
|
||||||
|
综上所述土地的概念,认识“土地”的角度不同,对土地的定义和认识差别很大,到目前为止,关于“土地”的概念,得到认同并较流行的有以下四种观点:
|
||||||
|
|
||||||
|
(1)认为“土地”是一切自然生成的及其生成源泉的环境因素。其中,自然生成的包括土壤、岩石、地貌、气候、水文和动植物等。
|
||||||
|
|
||||||
|
(2)认为“土地”是地球表层上、下幅度内的三维空间,即“土地”是由土壤、岩石、地貌、气候、水文及其动植物组成的自然综合体。
|
||||||
|
|
||||||
|
(3)认为“土地”是指地球的陆域表层,即除海水以外的地表,包括淡水资源(江、河、湖泊、沼泽等)。
|
||||||
|
|
||||||
|
(4)认为“土地”和“土壤”是同义词,其自然特性包括客观存在性、数量有限性、位置固定性、永续利用性。其经济特性包括土地所有制的重要性、土地的广阔性、造成农业生产的分散性、土地利用方向变更的困难性、供应的稀缺性。
|
||||||
|
|
||||||
|
\subsubsection{2.土地的经济特性与城市土地使用}
|
||||||
|
土地使用中积淀了丰富的城市活动内涵,是各类城乡活动的基础,社会、经济、政治、技术和环境要素等都会具体制约城乡土地使用的运行。对于城乡土地的使用,我们不能仅仅看到其各种物质空间的构成,更应看到在土地上人们所从事的丰富而复杂的活动,并透过这些活动看到其中的经济社会关系。
|
||||||
|
|
||||||
|
(1)城乡土地使用最显著的是区位性
|
||||||
|
|
||||||
|
城乡土地使用的区位揭示了城乡活动在空间地域上的相互关系,影响城乡土地使用区位的因素主要如下(见表2.1)。
|
||||||
|
|
||||||
|
表2. 1影响城乡土地使用区位的因素
|
||||||
|
|
||||||
|
(2)与土地使用密切相关的是土地使用强度
|
||||||
|
|
||||||
|
土地使用强度是单位面积的土地上承载城乡设施及各类活动数量的多少。土地使用强度不仅反映了城乡土地的环境质量,也显示了土地的可利用程度。不同的活动内容其要求的空间质量和开发程度有所不同,如办公、工业、住宅、商业、休憩等对城乡土地的使用强度都有其自身的不同要求,城乡土地使用及其区位和强度的分布,在城乡范围内形成了特定的空间关系,当其与交通路线和有关设施等相结合后,即构成了城乡的具体空间结构与形态。
|
||||||
|
|
||||||
|
\subsection{土地制度}
|
||||||
|
\subsubsection{1.土地制度及其模式}
|
||||||
|
中国传统文化认为“人脉在田、田脉在水、水脉在山、山脉在土、土脉在水,水脉在林草”。土是自然之本,土地制度是国家的政治、经济、文化、社会等制度的组成部分,也是国家各类政治经济构成制度中最为基础的制度。纵观世界,土地制度及其模式在内容上因各国历史沿革、土地所有制的性质和国情发展的不同而有所区别并无固定的模式。一般土地制度有广义和狭义之分,广义土地制度与土地所有判定、使用、管理等有关的一切制度;狭义的土地制度则指由土地所有判定、使用和管理的土地经济相关制度,以及相应法权制度所构成的土地产权管理制度。
|
||||||
|
|
||||||
|
概括而言,世界各国的土地使用制度一般有如下几种模式:
|
||||||
|
|
||||||
|
完全市场经济模式
|
||||||
|
|
||||||
|
土地主要属于私人所有,土地可以在市场上自由买卖,其价值取决于市场的供与求的关系。这种模式以一些欧洲、美国、日本等国为代表,例如美国约60\%的土地为私人所有,而日本则是约70\%的为私人所有。这种土地的完全市场模式,较为准确地体现出土地的经济价值,对活跃土地市场有利,但也给各类国土空间规划的编制管理与实施带来困难。因此,完全市场模式国家对土地一般都有规定:在某些特定原因下,依照有关程序,国家或城市政府可以依法征用私人土地,如美国区划法中的一些规定,当然其实际征用难度往往是比较大的。
|
||||||
|
|
||||||
|
非市场模式
|
||||||
|
|
||||||
|
这主要是在沿革于近代苏联为代表的社会主义国家,当然包括计划经济时期的中国施行的土地制度。土地所有权归国家或集体所有,国家或集体可对土地使用进行统一调配,不允许私自转让或对土地买卖。据了解目前朝鲜、古巴等国仍然实行的是土地非市场化模式。
|
||||||
|
|
||||||
|
国家主导下的市场模式
|
||||||
|
|
||||||
|
按照相应国家法律的规定,土地的最终所有权全部归国家或国家的象征(如皇室)所有,市场主体或私人通过土地批租获得土地的占有权和使用权,国家从总体主导控制土地市场,当然控制的方式和程度各有不同,这种模式主要是在英国及其英联邦成员国(地区)中使用,我国目前的土地制度经过系列的改革,整体上也是属于国家主导控制下的市场经济模式。
|
||||||
|
|
||||||
|
\subsubsection{2.中国的城乡二元土地制度及其问题}
|
||||||
|
回顾中国上下5000年历史,土地制度的变迁与朝代兴亡密切相关。从古至今,中国的土地制度大致经历了氏族共有制、国家所有制、私人所有制、公有制等所有制形态的变迁。新中国成立后,在计划经济时期实行的是土地非市场模式,长期实行城市土地无偿使用的行政划拨制度造成了诸多的弊端(见表2.2)。
|
||||||
|
|
||||||
|
表2. 2土地无偿使用的弊端
|
||||||
|
|
||||||
|
鉴于以上种种问题,改革开放后我国启动推行农村与城市土地使用制度的全面改革,核心改革方向就是将土地所有权与使用权相分离,实行土地的有偿使用。《中华人民共和国宪法修正案》(1988年)中明确指出,“土地的使用权可以依照法律的规定转让”。除一些因特殊需要国家行政划拨的土地以外,大部分实行有偿、有限期出让的办法。经过长期的探索,从改革开放至今已逐步形成以公有制为基础,以保护耕地和节约用地为主线,以产权保护、用途管制和市场配置为主要内容的具有中国特色的土地使用制度。但是,由于中国城市和乡村发展的巨大鸿沟和长期的制度积累,目前,土地制度仍然存在二元分割状态。
|
||||||
|
|
||||||
|
(1)土地所有制
|
||||||
|
|
||||||
|
中国现行的土地所有制是社会主义制度下的土地公有制,即全民所有制和广大劳动群众集体所有制,土地的全民所有制采取国家所有制的形式,简称土地国有制,大部分存在于城市区域内,这种所有制的土地称之为国有土地,由国务院代表国家行使土地所有权。
|
||||||
|
|
||||||
|
土地的劳动群众集体所有制采取的是农民集体所有的形式,简称土地集体所有制,大部分位于乡村地区内,这种所有制的土地称之为农民集体所有的土地,简称集体土地,由农民集体行使土地所有权。农民集体是指有一定范围的农民集体,具体分为村农民集体,村内两个以上农民集体、乡镇农民集体等三种形式。农民集体所有的土地依法属于村农民集体所有的,由村集体经济组织或者村民委员会代表集体行使土地所有权;分别属于村内两个以上农民集体所有的,由村内各该集体经济组织或者村民小组代表集体行使土地所有权;属于乡镇农民集体所有的,由乡镇集体经济组织代表集体行使土地所有权。
|
||||||
|
|
||||||
|
(2)土地使用制
|
||||||
|
|
||||||
|
首先是城市土地使用制度,目前我国城乡国有土地实行有偿、有期限使用制度,把城乡国有土地使用权从所有权中分离出来,全面开放国有土地使用权市场。
|
||||||
|
|
||||||
|
国有土地使用权流转包括以下两种方式:
|
||||||
|
|
||||||
|
方式一,土地使用权出让,即土地一级市场,可采取协议、挂牌、招标、拍卖、划拨等方式。其中,经营性建设用地、工业用地必须采用招标、拍卖、挂牌方式出让。
|
||||||
|
|
||||||
|
方式二,土地使用权转让、出租、抵押、授权经营、作价出资等。土地有偿使用制度的健全与完善,推动了土地市场的发展和经济发展方式的转变。
|
||||||
|
|
||||||
|
其次是农村土地使用制度,1982年发布的“中央一号文件”以及1988年《中华人民共和国土地管理法》,逐步确立了家庭联产承包责任制,农村土地实行集体所有、家庭承包的统分结合双层经营体制,这一制度有力促进了农村经济的发展,为改革开放初期的城镇化、工业化快速发展奠定了经济与社会基础。然而,随着市场经济的不断发展,农村土地制度开始面临新的问题,包括农业规模化、现代化发展的新需要,农村劳动力大量转移进入城镇、农村土地荒废问题等等。为此,2008年中共十七届三中全会确立了农村土地承包权长久不变的基调,同时农地三权分置(落实集体所有权、稳定农户承包权、放活土地经营权)的趋势加强。2019年1月1日施行的《中华人民共和国农村土地承包法》,使三权分置制度实现了从政策层面到法律层面的推进,同年通过的新《中华人民共和国土地管理法》修正了农村集体经营性建设用地进入市场的法律障碍,进一步明确了土地征收的公共利益范围,确定了征收补偿的基本原则,改革了土地征收程序,强化了农村宅基地权益保障。中共十八届三中全会要求加快建立城乡统一的建设用地市场和完善的现代市场体系,2019年中央全面深化改革委员会第八次会议审议通过《关于完善建设用地使用权转让、出租、抵押二级市场的指导意见》,对促进土地市场协调发展、加快建立城乡统一的建设用地市场具有重要意义。这些农村土地制度改革方面的重大突破,有力地促进了乡村振兴和美丽乡村战略的实施,但是破除城乡二元土地制度是一个长期、复杂的过程,需要谨慎、务实、切合国情的探索。
|
||||||
|
|
||||||
|
\subsubsection{3.土地产权特性与构成}
|
||||||
|
土地产权是指存在于土地之中的排他性权利,是以土地所有权为核心的土地财产权利的总和,土地产权问题是土地制度的核心问题。主要包括土地所有权、土地使用权、土地发展权、土地租赁权、土地抵押权、土地继承权、地役权等多项权利,具有如下特性和内容构成。
|
||||||
|
|
||||||
|
\subsubsection{4.土地产权的基本属性与构成}
|
||||||
|
(1)土地产权具有排他性
|
||||||
|
|
||||||
|
土地产权既可以是个体独立拥有,也可以是由某些人共同享有而排斥所有其他人对该项财产的权利。因此,土地的产权界定十分必要。
|
||||||
|
|
||||||
|
土地产权客体必须具有可占用性和价值性
|
||||||
|
|
||||||
|
土地产权客体是指能被占用而且可以带来经济利益的土地。在全球陆地上有近50\%的土地面积是永久冰盖物、干旱、沙漠地、岩石、沼泽、高寒地等难以利用或无法利用的土地,这些土地的财产界定问题就是价值性的问题。
|
||||||
|
|
||||||
|
土地产权的合法性
|
||||||
|
|
||||||
|
土地产权必须经过登记、得到法律的承认,才能受到法律的保护。例如,市场上土地产权合法流转时,必须依照法律规定程序到土地产权管理部门办理产权变更登记手续。
|
||||||
|
|
||||||
|
土地产权具有相对性
|
||||||
|
|
||||||
|
土地产权具有排他性,但并不代表就是绝对的权利,还要受到来自国家层面、社会层面的控制和制约。由于社会经济与政治制度的差异、法律体系的不同,每个国家都各自具有不同的财产权权利体系,但即使在土地私有制的国家,土地所有者的权利也必须受到政府一定的规制和约束,如国家重大发展规划的要求。
|
||||||
|
|
||||||
|
\subsection{土地利用规划与管理}
|
||||||
|
\subsubsection{1.土地利用规划概念}
|
||||||
|
作为一种资源,土地是以空间形态存在的。土地面积有限性和土地需求增长性是土地资源管理中永恒的主题,对土地利用实施科学的规划、管理有其客观的必要性,土地利用规划管理是为了合理利用和保护土地资源,维护土地利用的社会整体利益,组织编制和审批土地空间规划,并依据规划对城乡各项土地利用进行控制、引导和监督的行政管理活动。土地利用管理贯穿于国土空间规划编制、审批和实施的全过程,规划是管理的前提和依据,管理是规划依法科学制定和有效实施的保证。由此可见,土地利用规划管理也是面向社会的一种公共管理活动。
|
||||||
|
|
||||||
|
\subsubsection{2.土地用途管制}
|
||||||
|
土地用途管制是指国家为保证土地资源的合理利用,促进经济、社会和环境的协调发展,通过编制土地相关的空间规划,划定土地的不同功能用途分区并确定相应土地使用限制条件,土地所有者、使用者都必须严格遵守规划确定的用途来使用土地的制度体系。
|
||||||
|
|
||||||
|
土地用途管制是政府为了保障全社会的整体利益和长远利益,消除土地利用中的各种非理性现象,处理好土地利用中的各种矛盾,保证土地资源的可持续利用而采取的一种公共干预措施。从规划管理角度,土地用途管制的目标应包括以下几个方面:
|
||||||
|
|
||||||
|
土地利用整体效益最大化
|
||||||
|
|
||||||
|
不同利益相关者的土地利用的价值取向是有较大差异的,土地所有者追求的是全社会土地利用整体利益最大化,政府一般从大的行政区域,统筹生态效益、经济效益与社会效益,实质就是要达到区域土地利用结构的最优化,即土地在各种不同用途之间的布局和有效配置。土地用途管制就是要解决在各种竞争性用途之间合理分配土地资源并提高土地的利用效益问题,既要考虑具体土地使用者的切身利益,也要从宏观全盘考虑社会整体利益,通过规划谋求两者之间的平衡。
|
||||||
|
|
||||||
|
协调“粮食安全”与“城乡建设”的矛盾
|
||||||
|
|
||||||
|
我国是一个人多地少、人均土地资源相对短缺的国家,土地利用矛盾十分突出,特别是随着人口不断增加、经济的高速发展和城镇化程度的不断提高,农业与非农业各类建设之间争地的矛盾十分突出。必须通过土地用途管制,强制性地控制建设用地的数量与区位,对耕地实行特殊保护,如土地粮食生产面积红线等以保障国家的粮食安全。当然,土地利用的根本目的是满足人们的各种合理需要、对美好生活的向往,土地用途管制的目的就是要对有限的土地资源在数量上、时间上和空间上进行合理的分配,以统筹保障城乡各种用途的土地供给需求。
|
||||||
|
|
||||||
|
尽力避免土地利用中的外部性影响,保护生态环境,实现土地的可持续利用
|
||||||
|
|
||||||
|
土地在空间上互相连接在一起,不能移动和分割,但由于自然界和人为因素导致土地明显受到外部各类冲击。对于土地利用产生的积极外部性应该充分加以利用,对于不利的外部性影响必须加以避免和限制。因此,必须通过土地用途管制,来达到保护和改善生态环境、实现广大土地资源可持续利用的目标。
|
||||||
|
|
||||||
|
\section{国土空间土地用途管制}
|
||||||
|
\subsection{空间用途管制的源起与发展}
|
||||||
|
中共十九大以后,国家提出了建立国土空间规划体系并监督实施的要求,相应也就出现了国土空间用途管制。国土空间用途管制是土地用途管制的延展,从发展过程分析,可划分为土地用途管制(耕地)、生态要素用途管制、自然生态空间用途管制、国土空间用途管制四个递进式逐步全要素管制的发展阶段。
|
||||||
|
|
||||||
|
土地用途管制阶段
|
||||||
|
|
||||||
|
1980年之前,空间管制主要是土地用途管制,是对生产性耕地进行管制。
|
||||||
|
|
||||||
|
生态要素用途管制阶段
|
||||||
|
|
||||||
|
1980年代开始,在国家强化土地用途管制特别是耕地用途管制后,地方政府为规避监管或实现耕地总体占补平衡,开始占用重要的生态用地和低丘级坡地进行城乡建设,挤占了大量的绿色生态空间,危及了区域的生态环境。为解决林地、耕地、草原、湿地等生态和生产用地减少等问题,国家逐步加大了对部分生态用地按要素分门别类开展用途管制,建立生态要素用途管制制度。这种制度有利于突出管制重点,但对大范围的生态系统的整体性、全面和系统性考虑不足,用途管制政策的协调性也不够。
|
||||||
|
|
||||||
|
自然生态空间用途管制阶段
|
||||||
|
|
||||||
|
2013年中共十八届三中全会首次提出“建立空间规划体系,划定生产、生活、生态空间开发管制界限,落实用途管制”,以及“完善自然资源监管体制,统一行使所有国土空间用途管制职责”的总体要求。2017年国土资源部印发《自然生态空间用途管制办法(试行)》,提出建立覆盖全部自然生态空间的用途管制制度,加强“山水林田湖草”整体保护、系统修复、综合治理,标志着空间用途管制进入自然生态空间用途管制的阶段。
|
||||||
|
|
||||||
|
国土空间全用途管制阶段
|
||||||
|
|
||||||
|
2017年中共十九大报告首次明确要求对全部国土空间均实行用途管制。由于土地空间管理的权限分散在不同管理部门,“权责不清”“九龙治水”的现象十分普遍,导致行政效率低下,用途管制政策缺乏协同,难以对空间进行有效管理。为了解决生态要素管制的部门分割,提高国土空间用途管制的效能,中央决定设立国有自然资源资产管理和自然生态监管机构,统一行使所有国土空间用途管制和生态保护修复职责。2018年将国土资源部的主要职责、住房和城乡建设部的城乡规划管理职责、国家发展和改革委员会的主体功能区规划职责、水利部的水资源调查和确权登记管理职责、农业部的草原资源调查和确权登记管理职责、国家林业局的森林和湿地等资源调查和确权登记管理职责、国家海洋局的职责、国家测绘地理信息局的职责等进行整合,新组建自然资源部,统一对自然资源开发利用和保护进行监管,建立空间规划体系并监督实施。国土空间规划以空间治理和空间结构优化为主要内容,是实施国土空间用途管制和生态保护修复的重要依据。2019年5月中共中央、国务院印发《关于建立国土空间规划体系并监督实施的若干意见》,进一步指出要以“空间规划体系为基础,国土空间用途管制为手段,实现国土空间治理体系与治理能力的现代化”,“多规合一”使国土空间用途管制的机构、依据、权责等内容基本明确。
|
||||||
|
|
||||||
|
\subsection{国土空间用途管制的方法}
|
||||||
|
针对不同利益群体之间国土空间开发与保护目标取向的多重性,政府会使用行政权力介入国土开发利用的各环节,保证国土空间规划意图和管控要求的传导,实现保护资源环境、保障经济发展的目标。国土空间用途管制,是指在国土空间规划确定的空间用途、开发利用限制条件等的基础上,在国土空间准入许可、用途转用许可、开发利用监管等环节对各类国土空间用途或功能进行监管。用途管制是实施国土空间规划的核心机制和手段。国土空间用途管制是在摸清国土空间范围内自然资源现状的基础上,划定“三区三线”管控要求,实施差别化的区域准入制度,明确城乡全域各种土地用途转换规则,最终建立起从国土空间资源现状到规划蓝图的管理机制,国土空间用途管制要用规则规范各主体的行为和利益约束,主要方法包括以下方面:
|
||||||
|
|
||||||
|
(1)设置空间准入条件。不同类型国土空间的自然与经济属性是有较大差别的,要根据保护目标和开发利用特点,制定不同的空间准入和用途转用规定。上级政府通过制定符合未来发展要求的开发利用与保护条件(如土地用途、建设性质、强度、布局、生态环境保护等),并要求各级政府、职责部门严格依法进行项目预审和审批,确保使用者具有依据管制规则开发利用国土空间的能力和意识。
|
||||||
|
|
||||||
|
(2)限制国土空间用途转用。统筹各类国土空间保护与合理利用,实现“耕地保有总量、森林覆盖率、自然岸线保有率、环境品质质量”等同步提升。通过严格限制国土空间用途转用,维护国土空间规划的严肃性,保证各类开发利用活动符合资源环境承载力和国土空间开发适宜性等基本评价要求。增强刚性约束力和弹性调节灵活性,总量严格管控与年度规模动态调整相结合,以保障国家需要的项目落地;建立严格用途转用下的弹性调节方法。对基本农田、自然资源岸线、生态红线内区域要强化其管制刚性,原则上禁止改变用途;对其他一般性农用地、生态空间等,允许各地根据所在地区社会经济发展需求进行合理调整,但必须设置严格的调整规定,同时,对土地要探索以“盘活存量”取代“占补平衡”的调节方式,鼓励以“盘活存量”的方式来满足城乡新发展空间的需求。
|
||||||
|
|
||||||
|
(3)强化土地利用监督。从实现国家国土空间开发与保护的核心目标出发,必须加强对国土开发利用的严格监管,对开发利用者各种偏离国家利益的倾向形成威慑,约束开发利用行为,使国土空间开发利用符合国土空间规划预期的目标,从区域、功能区、地块三个层次加强监管(见表2.3)。
|
||||||
|
|
||||||
|
表2. 3土地利用监管内容
|
||||||
|
|
||||||
|
\subsection{国土空间用途管制的内容}
|
||||||
|
\subsubsection{1.城镇开发边界内外的管控}
|
||||||
|
城镇开发边界是一条有形的界限,边界内属于城镇空间,是实施城市规划、建设和管理的主要区域;边界外属于生态空间和农业空间,是我国实施乡村振兴战略、落实生态保护与国家耕地保护政策的主要区域。城镇开发边界概念源于20世纪70年代美国提出的“城市增长边界”,为了抑制城市无序蔓延、实现精明增长而采取的一种“增长管理”手段,可被视为一种城市土地管理和空间治理的政策集成工具,划定和实施城镇开发边界的目的,在于控制城市发展的规模和引导其空间发展方向,保护自然资源和生态环境,进而实现城镇与整个国土空间的可持续发展。划定城镇开发边界是实施新型城镇化战略的有效举措,是推动生态文明建设、落实最严格耕地保护制度和节约集约用地制度的重要保障,亦是国土空间规划管制的重要内容。
|
||||||
|
|
||||||
|
(1)管控要求
|
||||||
|
|
||||||
|
城镇开发边界内的管控应以“严控增量、盘活存量、集约复合、弹性适应”为原则,建设用地管理应符合城镇规划用途管制要求,协调刚性管控与弹性管理的关系。城镇开发边界外,除因规划需要确需建设的线性工程用地(交通、水利)、能源等基础设施用地、特殊用地,原则上不能组织开展城市市政基础设施和公共服务设施建设,不得颁发城镇建设用地规划许可,不可安排土地征转、提供建设用地指标,另外,城镇开发边界外的农村建设活动,应符合村庄规划和农民建房的相关规定。
|
||||||
|
|
||||||
|
城镇开发边界内外建设用地的差异化政策。对于城镇开发边界内的建设用地管控,实施刚性约束和弹性管控相结合。对边界内城镇空间规模的刚性约束是管控的重点内容;同时,城镇规划建设用地规模边界作为弹性控制线被引入,即允许城镇规划用地的布局在规模不变的前提下根据实际发展情况进行一定的微调,以提高城镇规划建设用地的弹性;建设项目在有条件建设用地内的选址,应符合一定的程序和条件,确保选址的合理性。
|
||||||
|
|
||||||
|
对城镇开发边界外的建设用地实施分类管控:一方面,对于现状建设用地原则上应逐步迁出、不再新建;另一方面,对于新的建设需求严格管控。由于未来发展具有不确定性,有些项目如物流建设等项目有可能需要在城镇开发边界外进行,对于这类项目应可制订差别化的管控措施,依据其开发建设需要对规模、功能、程序等进行规定,一定规模以下的特定功能区的建设,可以经法定程序批准即可建设;而超过一定规模的建设活动,则需先修改城镇开发边界才能进行。
|
||||||
|
|
||||||
|
(2)管控机制。城镇开发边界内应建立对用地使用主体的发展引导机制。根据土地的自然条件、区位条件、经济社会条件和城市各类发展规划,将城镇开发边界内的可开发建设区域按主要功能区的方式划分为优先发展区、允许发展区、限制发展区等;不同的发展区域在用地指标供给、建设条件控制、转让交易限制、管理税费等方面实施差异化政策,以促进开发边界内的可利用土地空间更为有序、有效利用。而在城镇开发边界外,依据生态保护、耕地保护优先原则,积极构建生态补偿机制。一方面,通过分区管控,将城镇开发边界外的区域划分为核心生态保护区、一般农林资源区、其他用地区、城市发展备用区等,既体现了生态保育的底线思维,也为城镇开发边界机动有序地调整做好准备;另一方面,城镇开发边界以外的地区应在保护之外更强调生态奖励政策,如通过生态补偿奖励跨地区转移支付等政策措施,平衡一些地区因生态保护、耕地保护而导致的发展权损失,激发地方生态保育、保护耕地、优化格局的积极性。
|
||||||
|
|
||||||
|
\subsubsection{2.永久基本农田管控}
|
||||||
|
耕地即专门种植农作物并能够正常收获的土地,广义上是指维持人类生存及农业可持续发展的基本资源,我国《土地利用现状分类》(GB/T21010-2017)中,将其划分为熟地、新开发、复垦、整理地和休闲地等不同类型。土地管理中一般将农田分为永久基本农田与一般农田。
|
||||||
|
|
||||||
|
永久基本农田,指能保障一定时期人口及经济社会稳定发展的农产品需求,在国土空间规划中所确定的不得占用的耕地,相应的,除永久基本农田以外的其他农田则为一般农田。永久基本农田是优质、连片、稳定、永久的耕地,一旦划定就要实施永久性保护,是保障国家粮食安全、促进绿色农业和精品农业发展,以及实施乡村振兴战略的重要载体。我国的永久基本农田与许多国家的优质农地、重要保护农地等概念相似,皆在农业生产保护中发挥重要作用。
|
||||||
|
|
||||||
|
划定永久基本农田并实行高标准保护,是我国贯彻落实最严格的耕地保护制度的基本要求,是维护国家层面粮食安全和社会稳定的关键举措。加强永久基本农田控制线管控,对生态文明建设阶段实施,乡村振兴战略和优化城镇空间布局等方面都具有重要意义,对于永久基本农田的保护与管制,主要包括统筹管控性保护、建设性保护、激励约束性保护等方面。
|
||||||
|
|
||||||
|
(1)管控性保护,即要求落实严格保护永久基本农田要求,从严管控非农建设活动占用永久基本农田。永久基本农田一经划定,任何单位和个人不得擅自占用或者擅自改变用途,禁止破坏和闲置荒芜永久基本农田。
|
||||||
|
|
||||||
|
坚决防止永久基本农田“非农化”,除法律规定的国家重点能源、交通、水利、军事设施等建设项目选址无法避让的之外,其他任何建设都不得占用。加强永久基本农田保护红线管控,在开展城镇建设活动和基础设施布局等相关规划过程中,不得突破永久基本农田保护红线;确有重大工程、特殊项目无法避让永久基本农田的,必须经过充分的可行性论证和依法审批,按照“保护优先、布局优化、优进劣出、提升质量”的原则和永久基本农田保护调整程序进行。
|
||||||
|
|
||||||
|
(2)建设性保护,加大永久基本农田及其配套设施的建设力度,开展高标准永久基本农田建设,改良土壤质量,提高永久基本农田的质量和品质等级,完善耕地质量监测体系,开展相关的耕地质量评定与评价工作。因地制宜地划定永久基本农田整备区,将土地整治补充的优质耕地、新建成的高标准农田优先纳入永久基本农田补划储备库,为永久基本农田补划和布局微调整创造条件。
|
||||||
|
|
||||||
|
(3)激励约束性保护,即要求完善永久基本农田保护激励约束机制,落实永久基本农田保护责任。重点是落实政府考核评价机制和耕地保护激励机制,严格考核审计,严肃执法监督。建立和完善耕地保护激励机制,充分调动农村集体经济组织、农民管护和建设永久基本农田的积极性,建立健全永久基本农田社会共管体系。
|
||||||
|
|
||||||
|
\subsection{城乡建设用地的统筹管控}
|
||||||
|
城乡建设用地涉及内容、类型十分广泛,其规模、分布与地区经济社会发展状况密切相关,同时也受到规划用途管制,并存在总量和增量的约束性限制。城乡建设用地是区域发展、城镇与乡村人口、经济社会发展的重要载体,其总量和增量规模既受到人口规模和经济发展水平的影响,又会反向促进或制约人口规模和经济发展。目前,虽然我国总体上已度过城镇规模扩张阶段,进入城镇更新阶段,处于城乡扩展与存量土地低效并存的情况,因此,城乡建设用地节约集约利用仍然是我国建设用地用途管制的重点。
|
||||||
|
|
||||||
|
当城乡建设用地总规模接近甚至突破土地利用规划限定的“天花板”时,城乡发展必须由“外延式扩张”转向“内部更新式发展”,通过实施“存量”用地挖潜和盘活机制(诸如城乡建设用地增减挂钩、农村建设用地整治、城市更新等),以控制建设用地“总量”、减少建设用地“增量”盘活城乡建设用地“存量”并提升用地“质量”、用好城乡建设用地“流量”,即通过“五量”协同,多途径共同实施对城乡建设用地的有效管控,以实现建设用地节约集约利用的目标。
|
||||||
|
|
||||||
|
实施城乡建设用地的用途管制,实行城市建设用地总量、增量、存量、流量和质量“五量”协同管控(见表2.4)。
|
||||||
|
|
||||||
|
表2. 4城市建设用地管控原则和措施
|
||||||
|
|
||||||
|
\subsection{土地用途分区}
|
||||||
|
\subsubsection{1.土地用途分区}
|
||||||
|
国土空间规划是实施土地用途管制的依据,土地用途管制是落实和实施国土空间规划的手段。土地用途分区是土地用途管制的重要内容和核心。土地用途分区,是对土地利用类型区的划分,一般分为地域分区、用地分区。地域分区是指依据规划区域的自然条件、资源的区域特征、土地利用现状、社会经济发展水平与发展前景差异等情况,确定不同区域土地利用的方向、结构与布局的宏观分区;用地分区则是指依据具体土地的适宜性特点,结合国民经济和社会发展的需求与条件,确定具体地块未来用途的微观分区(一般是在城镇开发边界内与村庄建设区进行)。划分土地用途分区时,应该考虑如下原则:
|
||||||
|
|
||||||
|
原则一:宏观分区应针对各地的具体情况,留有余地,使分区具有一定的战略性、灵活性。
|
||||||
|
|
||||||
|
原则二:微观分区应以主导用地类型为主,同时有条件、有限制地允许一些其他利用类型的存在,以保证实际操作中有一定的应变弹性。
|
||||||
|
|
||||||
|
原则三:应当体现空间层次性,用途类型的划分要在同一尺度、同一层次上进行。按土地主导用途的一致性进行一级分区,将同一主导用途下的差异性在二级分区中体现出来。
|
||||||
|
|
||||||
|
原则四:应当体现并满足政府对土地利用控制的需要,保证分区控制具有法律化、制度化的支撑。
|
||||||
|
|
||||||
|
针对不同用途的分区制定相应的管制规则,是土地用途管制的重要内容。要依据分区管制规则,制定不同层面、不同类型的国土空间规划。城镇用地的分区管制规则,应包括土地用途的规定、地块规模限制、土地利用强度(容积率、建筑密度、建筑物高度、人口容量等)限制、环境条件(空地率、绿化率)限制、安全间距(防火间距、消防通道等)限制、相邻关系限制、红线(道路红线、建筑后退红线)限制等。乡村土地的分区管制规定,应包括土地用途的规定、用途变更的规定、非主导用途使用规定、地块面积规模与设施水平限制、土地利用中的禁止行为(如农业用地分区内不得建窑、建坟、挖沙、采石、取土、堆放固体废弃物等)。
|
||||||
|
|
||||||
|
\subsubsection{2.土地用途管制的实施管理}
|
||||||
|
所谓土地用途转变,是指土地利用从一种现状用途转变为另一种用途的过程。对于符合分区管制目标的用途应予以引导,对于不符合者应加以限制或否决,土地用途转变要执行规划许可制度,也就是采取颁发规划许可证的办法以控制土地用途的改变。
|
||||||
|
|
||||||
|
农用地转移的管制可分为限制转移管制、许可转移管制两类。所谓限制转移管制,即依据国土空间规划,划定一定数量的农用地(主要是耕地)作为特殊保护区域严格加以管制,如划定永久基本农田保护区,对于这类特殊保护区域不得进行转用,或必须经过严格审批程序并满足占补平衡等要求后方可转用,以保持农用地保有量的平衡和稳定,所谓许可转移管制,即根据规划布局的要求,允许一部分农用地进行规定用途的转用。
|
||||||
|
|
||||||
|
非农用地用途管制主要包括增量非农业建设用地、存量非农业建设用地的用途管制,其中增量非农业建设用地的用途管制与农用地许可转移管制密不可分,存量非农业建设用地的用途管制是指存量建设用地土地利用结构调整和土地利用方向置换的管制。当前存量土地用途管制的对象主要有以下几个方面:
|
||||||
|
|
||||||
|
一是土地使用用途因用地功能的改变而发生的调整,如旧城改造、污染企业搬迁、产业“退二进三”等;二是土地使用用途和方式因土地使用、经营方式的改变而发生的调整,如原地翻建等;三是因土地资产处置方式发生变化的调整,如划拨土地入市、企业改制土地资产的处置等;四是对低效使用或闲置土地的再利用。
|
||||||
|
|
||||||
|
除此之外,对生态用地也应该实行用途管制,以明确保护和适当使用的规则。需要指出的是,土地用途管制并不意味着规划的土地用途是绝对不可改变的。因为,影响土地利用的经济社会条件及其他条件随着时间的推移而在不断地发展变化,人们对未来发展的预见能力、认识能力是有限的,规划内容与实际发展之间的偏差不可避免,客观上对国土空间规划、土地用途管制也提出了必要的动态调整要求。
|
||||||
|
|
||||||
|
第2章自然资源与土地利用 24
|
||||||
|
|
||||||
|
2.1自然资源的概念、构成与属性 24
|
||||||
|
|
||||||
|
2.1.1自然资源的概念 24
|
||||||
|
|
||||||
|
2.1.2自然资源的类型 24
|
||||||
|
|
||||||
|
2.2土地定义与土地管理制度 26
|
||||||
|
|
||||||
|
2.2.1土地的定义 27
|
||||||
|
|
||||||
|
2.2.2土地制度 28
|
||||||
|
|
||||||
|
2.2.3土地利用规划与管理 31
|
||||||
|
|
||||||
|
2.3国土空间土地用途管制 32
|
||||||
|
|
||||||
|
2.3.1空间用途管制的源起与发展 32
|
||||||
|
|
||||||
|
2.3.2国土空间用途管制的方法 33
|
||||||
|
|
||||||
|
2.3.3国土空间用途管制的内容 34
|
||||||
|
|
||||||
|
2.3.4城乡建设用地的统筹管控 36
|
||||||
|
|
||||||
|
2.3.5土地用途分区 37
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 特征 & 构成 \\
|
||||||
|
\hline
|
||||||
|
1 & 空间环境 & 每一项土地使用都会对城市及特定位置的自然条件和人文环境提出特有的要求 \\
|
||||||
|
2 & 空间可达性 & 这些土地位置在城市活动中的交通方便程度,不同的活动会选取特定的交通方式,对空间可达性的具体要求也不一样 \\
|
||||||
|
3 & 费用 & 从事一项土地使用活动所需花费的成本,包括区域配套的基础设施的投入以及资金的时间成本 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 问题 & 表现 & 影响 \\
|
||||||
|
\hline
|
||||||
|
1 & 土地产权模糊 & 土地名义上归国家或集体所有,划拨使用以后却归各用地单位实际上所有 & 导致土地的所有权与使用权相模糊、混淆 \\
|
||||||
|
2 & 土地资源浪费 & 计划经济体制由于实行行政划拨土地,否定了土地价值与价格的差异,因此往往造成用地单位“圈而不用”“少用多圈”等极为浪费土地的行为 & 进一步加剧了土地资源的紧张局面 \\
|
||||||
|
3 & 土地资源配置不当 & 土地的经济特性无法在行政划拨中表现出来 & 导致土地资源配置不当,各种用地行为相互掺杂,功能分区混乱,低效益的用地往往占据着城市中优越的区位,土地效能发挥不出来 \\
|
||||||
|
4 & 土地所有者管理主体不明确,土地收益流失 & 由于否认城市土地价值的存在,且土地所有权与使用权不清,国家无法获得由土地带来的相应收益,而这部分收益却往往成为用地单位的无偿所得 & 进一步导致了城市建设资金无法得到有效的保障,城市配套设施难以改善,环境质量恶化 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 层面 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 区域 & 加强对各级行政区域范围内城镇建设、农业生产、生态保护三类国土空间的综合监管,侧重对约束性指标数量和质量的双重考核 \\
|
||||||
|
2 & 功能区 & 加强对各类功能区内开发与保护现状的监管,尤其是对城镇空间、农业空间、生态空间的实际开发与保护绩效进行评价和监管 \\
|
||||||
|
3 & 地块 & 重视对项目落地实施情况的监管,完善建设项目用地或用海控制指标,加强对使用者执行空间准入前置条件的考核(包括建设项目容积率、投资强度、绿地率等具体指标,以及各类生态修复项目的实施成效等) \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 原则 & 措施 & 备注 \\
|
||||||
|
\hline
|
||||||
|
1 & 总量控制 & 在不突破土地利用规划总量的前提下,可适度增加区域建设用地规模并用于城乡基础设施建设、区域产业发展和生态环境保护等功能 & 在发展过程中,应合理确定各区域土地开发强度控制目标,严格落实建设用地空间管制制度,科学划定城镇开发边界、优化空间结构转变,有效控制建设用地总量 \\
|
||||||
|
2 & 增量递减 & 利用差别化与市场化方式配置新增建设用地,优先支持重大基础设施建设、保障性住房和战略性新兴产业发展 & 加大存量用地在供地中的比重,建立增量用地计划与存量用地盘活的指标配比机制,逐渐降低年度增量用地,稳定总量规模 \\
|
||||||
|
3 & 存量优化 & 查清城镇低效用的范围,明确可盘活存量用地规模、空间分布、利用方向和开发时序;着力开展闲置土地专项清理,依法加大查处力度或予以收回,有效提高建设用地批后供地率和使用率 & 依法依规有序开展农村集体经营性建设用地的流转,逐步建立城乡统一的建设用地市场,通过存量用地盘活促进区域内涵发展 \\
|
||||||
|
4 & 流量增效 & 流量增效。通过建设用地“减量瘦身”来倒逼城市功能提升,鼓励城镇空闲土地再开发,盘活农村建设用地和推进工矿废弃地复垦利用 & 规范有序开展城乡建设用地增减挂钩与城市更新工程,确保城乡建设用地总量不增加、结构更优化;探索农村宅基地退出条件和补偿机制,促进城乡协调发展 \\
|
||||||
|
5 & 质量提升 & 健全城镇建设用地产业准入和建设项目用地节地评价制度,依据节地评价结果确定供地规模 & 探索土地使用权退出机制,以及工业用地长期租赁、先租后让、租让结合等供地方式;引导土地立体开发、功能复合利用,逐步实现区域零增量供地和空间高效发展 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
\chapter{城乡空间规划思想与理论发展}
|
||||||
|
阳光是万物之源,土地是万物之本,土地资源是人类生存的基础,也是生态环境可持续发展的重要保障。伴随着人类认识和科学技术的发展,对土地的了解和研究也在不断地深化,随着国土空间规划的推进,土地资源管理与利用成为国家生态文明建设的重要组成部分之一,对于土地资源的认识和学习是国土空间规划原理的重要构成。
|
||||||
|
|
||||||
|
\section{我国城乡规划思想与理论发展}
|
||||||
|
长期以来,基于我国城乡发展规划与各类建设管理的需要,各级各类部门及相关法律法规政策衍生了一系列自上而下的规划体系,其中最具有代表性的除城乡规划体系和土地利用规划体系外,尚有主体功能区规划和生态环境保护规划类型,从“多规合一”需求出发,有必要理清各类规划在历史沿革核心理论与时间上的异同及核心目标。
|
||||||
|
|
||||||
|
\subsection{中国古代城市规划思想与理论}
|
||||||
|
\subsubsection{1. 雏形城市时期}
|
||||||
|
考古证实,中国最早的城市雏形产生于原始社会末期的龙山文化时代。这一时代出现了中国第一批古城邑——由夯土墙或石墙围绕起来的大型聚落,但是由于它并不具备城市的实际职能与文化形态,因此只是一种“带围墙的村庄”。直到公元前1500年左右,随着奴隶社会的较大发展,商代产生了第一批相对完整意义上的早期城市。
|
||||||
|
|
||||||
|
(1)居民点产生于第一次劳动大分工——农业、畜牧业的发展
|
||||||
|
|
||||||
|
原始社会,人类主要依附于自然生活,过着穴居、树居等群居生活,然而这时并没有形成固定的居民点。在原始社会后期,随着社会生产力以及农业、畜牧业的发展,人类不再需要长期的迁徙奔波,可以定居生活,因此原始群落中就产生了从事农业与从事畜牧业的分工,也就是人类的第一次劳动大分工。到了新石器时代的后期农业成为主要的生产方式,此时逐渐产生了固定的居民点。人们的生活与农业均离不开水,所以原始的居民点大都靠近河流、湖泊,大都处于“山之阳”、“水之阴”的台地上,为了防御野兽的侵袭和其他部落的袭击,往往在原始居民点外围挖筑壕沟,或用石、土、木等材料筑成墙及栅栏,这些沟、墙是一种防御性构筑物也是城池的雏形,我国的黄河中下游、埃及的尼罗河下游、西亚的两河流域都是农业发展较早的地区,在这些地区的农业居民点以及在居民点的基础上发展起来的城市也出现得最早。
|
||||||
|
|
||||||
|
(2)城市形成于第二次劳动大分工——手工业、商业的发展
|
||||||
|
|
||||||
|
金属工具的使用,进一步提高了生产力,原始部落生产水平的提高,也促进了需求的多样化,使手工业从农业中分化出来,即产生了第二次劳动大分工,出现了一些专门的手工业者。与此同时,随着交换量的增加及交换次数的频繁逐渐出现了专门从事交易的商人以及物品交换的场所,也就是固定的市场。原来的居民点也逐渐融合,人口更加聚集,逐渐发展成为城市,居民的生活条件逐渐提升从而有了剩余产品,有了剩余产品就产生私有制,原始社会的生产关系也就逐渐解体,出现了阶级分化,人类开始进入奴隶社会。所以也可以说,城市是伴随着私有制和阶级分化在原始社会向奴隶制社会过渡时期出现的。世界上几个古代文明的地区城市产生的时期有先有后,但都是这个社会发展阶段中产生的。
|
||||||
|
|
||||||
|
\subsubsection{2.古典城市时期}
|
||||||
|
中国古代文明中有关城镇修建和房屋建造的论述,总结了大量生活实践的经验,其中经常以阴阳五行和堪舆学的方式出现。虽然至今尚未发现有专门论述规划和建设城市的中国古代书籍,但有许多理论和学说散见于《周礼》、《商君书》、《管子》和《墨子》等政治、伦理和经史书中。
|
||||||
|
|
||||||
|
夏代(公元前21世纪起)就对“国土”进行全面的勘测,国民开始迁居到安全处定居,居民点开始集聚,向城镇方向发展。夏代留下的一些城市遗迹表明,当时已经具有了一定的工程技术水平,如陶制的排水管的使用及夯打土坯筑台技术的采用等,但总体上,在居民点的布局结构方面都尚原始,夏代的天文学、水利学和居民点建设技术为以后中国的城市建设规划思想的形成积累了物质基础。
|
||||||
|
|
||||||
|
在西周至战国时期(约公元前11世纪至公元前2世纪末),中国城市逐步形成了“城郭分野相依”的古典形态格局。春秋战国是中国历史上第一个“筑城运动时期”,也是第一次城市建设的高潮。西周作为中国奴隶制社会发展的重要时代,形成了完整的社会等级制度和宗教法礼关系,对于城市形制也形成了相应的严格规则。《周礼·考工记》记载的“匠人营国,方九里,旁三门,国中九经九纬,经涂九轨,左祖右社,前朝后市,市朝一夫”。成为影响中国几千年古代城市营建的基本范式,对中国传统城市的礼制风格产生了重大的影响。《周礼·考工记》中对于不同城市营建的等级也有严格的规范,如“建国立城邑,有定所,高下大小,存乎王制”、 “大都不过三国之一,中五之一,小九之一”等等,这同样也是礼制规范的体现。
|
||||||
|
|
||||||
|
春秋战国时代是古代中国从奴隶制向封建制的过渡时期,群雄纷争和战乱频繁,也是各种社会变革思想蓬勃发展的“诸子百家”时代,这个时代也是中国古代城市规划思想的多元化时代,既有以《周礼·考工记》为代表的礼制营城规范;也有以《管子·乘马》为代表的山水有机营城思想,强调“因天材,就地利,故城郭不必中规矩,道路不必中准绳”的自然至上理念。例如,后来明代都城南京的规划就充分体现了管子的山水营城思想。在管仲的论著《管子·乘马第五》立国中有这样的描述,“凡立国都,非于大山之下,必于广川之上。高毋近旱而水用足,下毋近水而沟防省。因天材,就地利,故城郭不必中规矩,道路不必中准绳”。从中可知,当时已经非常重视城池规划选址。会注意利用山川地利之便,让城市有险可依,除了根据具体的地理条件因地制宜,城池建设还要讲究经济性。这些理论对后来城市规划都有着重要的影响。
|
||||||
|
|
||||||
|
\subsubsection{3.中期传统城市时期}
|
||||||
|
中期传统城市时期大约从公元前2世纪末的秦汉到唐代末期。汉武帝时代为巩固皇权统治,“废黜百家,独尊儒术”,儒家的核心思想就是社会等级、宗法关系,从此,《周礼·考工记》所代表的礼制思想开始对中国封建社会城市的规划建设产生了主体性的影响。从曹魏邺城、唐长安城到元大都、明清北京城,《周礼·考工记》对城市形制尤其是都城的影响极其深远。这一时期城市规划建设的主要特征有三点城市的整体性加强,组合成一个紧密的整体;受“尊儒崇礼”思想的影响,城市内部各功能要素在布局上呈现出尊卑有别的礼制秩序;具有严格的功能分区和“里坊”制度。
|
||||||
|
|
||||||
|
\subsubsection{4.后期传统城市时期}
|
||||||
|
后期传统城市时期大约从北宋到 19世纪末的晚清。由于商品经济的发展,城市的功能日渐增多,尤其是经济、生活功能的强化,使得城市的世俗化特征日益明显,这一时期的城市规划一方面表现为对传统城市主体礼制风格的继承和发展,另一方面也开始出现了对严格的里坊结构、封闭的城垣形制的重大变革,这在一定程度上形成了适应商品经济发展而相对自由的城市风格。北宋时期,商品经济和世俗生活的发展开始冲破《周礼·考工记》的礼制约束,首都汴梁城(开封)出现了如《清明上河图》所描绘的那样熙熙攘攘的商业大街,封闭的“里坊”制度开始解体。
|
||||||
|
|
||||||
|
总体而言,中国古代城市规划建设的思想、理论主要受到以下因素的影响:
|
||||||
|
|
||||||
|
(1)早期的耕作制度。“井田制”奠定了中国早期城市方格网状的空间格局的基本形式,如《周礼·考工记》中所用的“夫”就是井田的丈量单位。
|
||||||
|
|
||||||
|
(2)传统的营建技法。如中国早期的城市大多出现在北方平原地区,受当时当地的气候与建筑材质的影响,出现了封闭的院墙体系及方正平直的格局。
|
||||||
|
|
||||||
|
(3)特有的文化观念。如天圆地方说、天人感应等思想,表现在城市中象征性的构图和布局;阴阳五行思想和易学说,表现在城市布局中对方位、数字、对偶等的应用;相土形胜风水说,尤其表现在城市规划的选址与总体格局中。
|
||||||
|
|
||||||
|
(4)封建政治制度。封建政治制度所形成的特有文化价值观念也左右着城市规划的思想,如《礼记·乐记》中讲道:“礼者,天地之序也;乐者,天地之和也。序,故群物兼别;和,故百物兼化。”所以,我国古代城市规划尤其强调等级尊卑、序列感。
|
||||||
|
|
||||||
|
(5)社会经济发展形态。不同经济社会发展时期,城市功能的变化会导致城市形态,营建风格的相应变化。例如,北宋汴梁城出现里坊解体和沿街买卖的现象,就是市场经济和市民阶层发展壮大的结果。
|
||||||
|
|
||||||
|
\subsection{中国近代城市规划思想与理论}
|
||||||
|
近代中国受到西方社会经济与思想文化的强烈冲击,具有典型的半殖民地、半封建社会的特征,城市规划发展围绕两条基本脉络展开:一是西方外来资本主义社会经济形态及其相伴随的新的城市物质要素和结构形式的渗入,由点及面、由渐及盛的扩展过程;二是本土传统的封建社会经济形态在外来资本主义社会经济形态的强大影响下逐步解体,并被动地重构新型城市的艰难过程——表现为对西方模式的照搬或“中国化处理”。可以说,中国近代城市规划的发展演变始终围绕着中国传统城市规划的近代化,以及西方城市规划思想的本土化而展开。
|
||||||
|
|
||||||
|
这一时期,中国城市规划开始受到西方城市规划思想的广泛影响。起初主要是向租界、殖民城市学习,洋务运动后开始直接派遣中国留学生学习西方城市建设技术与规划方法,诞生了第一批具有西方留学背景的市政、公共卫生、建筑与城市规划专业人才,极大地推动了中国城市规划向近代科学的转型,在半殖民地半封建的时代背景下,一部分中国城市的发展与建设始于帝国主义的殖民活动,殖民者在其长期占领的城市中围绕其掠夺和侵占目的展开规划建设,这一类型的典型城市包括青岛、哈尔滨、大连和长春等;近代交通的快速发展催生了一批因区位优势而得到发展建设的城市,以郑州、蚌埠、石家庄等为代表;此外,还有随着民族资本工商业的发展而进行的城市规划与建设活动,如南通、无锡、汕头等。
|
||||||
|
|
||||||
|
近代中国城市规划的理论与实践表现为两大类:一类完全套用当时西方所流行的规划思想和手法,甚至直接由西方人进行规划,例如大量的租界城市;另一类引入西方的学说并进行“中西结合”的应用,哈尔滨早期俄国人做的“新城规划”在世界上代表了最先进的规划理念,它融合了霍华德的规划思想和巴洛克风格,形成的方格网与放射状的路网格局、有轨交通、绿化系统以及独有的建筑特色使哈尔滨成为具有多元文化特色的国际性城市。
|
||||||
|
|
||||||
|
\subsection{新中国成立后城市规划思想与理论}
|
||||||
|
新中国成立后,社会体制的巨大变革影响到社会经济发展的方方面面,城市规划与建设也进入一个新的发展阶段,但是过程曲折,中国的城市规划根据国家和社会的需要在逐渐演进,以推进生产力布局为核心目标,城市规划思潮的演变可以划分为以下五个阶段:
|
||||||
|
|
||||||
|
\subsubsection{1.建国初期的城市规划}
|
||||||
|
1949年至1960年代初,新中国刚刚成立,百废待兴,这时城市规划的核心任务是落实生产力的布局。这一时期国家经济由于多年战争濒临崩溃,又同时受到西方资本主义阵营的孤立与排挤,内忧外患的发展环境迫使新中国选择全面倒向苏联,在政治、经济、社会等各个领域建立了以自上而下计划为特征的运行体制,中央政府通过统一的计划对国民经济社会发展实行全面管理,并通过城市规划向地方政府分配建设资源。在城市规划的理论与实践中,停止引用西方规划理论,转而全面学习苏联规划理论与模式。将城市作为主要用于工业生产的载体,强调“变消费型城市为生产型城市”,生活空间被极度压缩,城市生活设施被作为工业生产的配套则按照最简单、最经济的原则进行,以尽可能地降低工业化、城市化的成本。这一时期的规划被作为在空间上落实国民经济建设计划和重大项目布局的工具,其主要职责是服务于工业生产和计划落地的需要,规划思维体现出强烈的自上而下、计划性、指标性和工程技术性色彩。
|
||||||
|
|
||||||
|
在新中国成立初期的10年间,城市规划被纳入计划经济体系,成为国民经济和社会发展计划的延续与空间落实,作为指导生产项目布局和城市建设的技术工具,与国家经济社会发展需求紧密结合,在支撑短时间内建立社会主义工业体系方面做出了巨大贡献。
|
||||||
|
|
||||||
|
\subsubsection{2.文化大革命时期的城市规划}
|
||||||
|
1960年代初至“文化大革命”结束这段时期,我国的城市规划深受极左主义思潮的影响。1960年代后,在“极左”思想影响下政治运动席卷全国,经济社会事业多处于废弛停顿状态。城市规划被视为“扩大城市差别”的“修正主义”而受到了批判,曾经作为落实发展计划重要工具的城市规划也走向低潮,只有在应对国家重大工业项目建设需求时,才有限地开展城市规划工作。这一时期的城市规划思想仍然深受苏联模式的影响,计划经济色彩依旧较为明显。
|
||||||
|
|
||||||
|
\subsubsection{3. 改革开放初期的城市规划}
|
||||||
|
1970年代末至1990年代初,我国的城市规划向科学理性主义回归。“文化大革命”结束后,国家迎来改革开放浪潮,西方国家的规划思想与理论随同资本、技术等一并进入中国,科学主义的社会思潮在当时的中国蔚然成风,城市规划研究与实践也显著表现出对“科学化”的强烈追求。城市空间被抽象为点、线、面组合而成的系统,数学模型等计量方法被运用于对城市空间演化过程及规律地模拟,在城市规划领域得到积极应用。这一时期的城市规划非常注重对城市空间演化规律的研究,引入了西方许多关于城市研究、城市规划的理论和方法。尤其是经济地理(城市地理)等理科力量的介入,将综合思想、科学分析思维引入中国规划界,为城市规划提供了崭新的研究领域、研究视角和研究方法,开启了中国城市规划多元化发展的局面。
|
||||||
|
|
||||||
|
\subsubsection{4.全球化初期的城市规划}
|
||||||
|
1990年代中期至2010年代初,随着全球化进程的快速发展,我国的城市规划更加趋近于用地扩张的增长主义思潮。随着全球化的深度和广度不断拓展,1990年代初中国顺应时代浪潮进一步扩大对外开放,明确提出建立社会主义市场经济体制。1990年代推进了分税制、分权化、城乡土地使用制度、住房市场化等一系列重大的改革,中央政府在赋予地方政府更多权力、更多可支配资源的同时,也将更多的增长压力转移至地方,促成了“增长主义”政策体制与城市扩张式的发展特征。国家和社会治理思想的变化、央地关系的调整及市场化转型,都深刻地影响了这一时期中国城乡规划的理论与实践选择,这一时期的城乡规划在相当程度上扮演了支撑政府实现增长主义目标的手段,成为地方政府经营土地等城市各类资产、管控空间秩序和营造景观环境的重要工具,更承担了提高城市竞争力、促进城市发展的重要任务。
|
||||||
|
|
||||||
|
单一增长目标导向下的经济高速发展与快速的城镇化进程,迅速暴露出种种问题:城市空间无序扩张,城乡之间、区域之间发展不平衡加剧,经济增长与社会、文化、生态等多元发展目标之间极度失衡……有鉴于此,2003年后中央提出了“科学发展观”“五个统筹”“和谐社会”等一系列思想,对GDP 为中心的经济发展方式进行修正,促进经济、社会、生态等各个方面的统筹,更加重视民生、区城和乡村等方面,规划的工作领域也真正开始从“城市规划”拓展至“城乡规划”。在2008年新实施的《中华人民共和国城乡规划法》中,城乡规划的属性明确实现了从“工程技术”向“公共政策”的转变。然而,增长主义模式的巨大惯性难以在短期内根本扭转,地方政府仍然奉行增长主义的发展策略,中央政府与地方政府之间的发展价值取向、关注的核心利益产生了越来越大的偏差,城乡规划在服务国家新发展理念与满足地方增长主义诉求之间艰难平衡,缺乏一以贯之的内在价值体系和行为准则,表现出比较明显的“价值观—行动分裂”。
|
||||||
|
|
||||||
|
代表性纲领:北京宪章1999年,由吴良镛起草的《北京宪章》,以人居环境科学理论为基础,在国际建筑师协会第20届世界建筑师大会上通过,标志着人居环境学说被世界建筑学界普遍接受和推崇,扭转了长期以来西方建筑理论占主导地位的局面。
|
||||||
|
|
||||||
|
《北京宪章》指出“可持续发展”是人类共同的选择,它不仅表现在它对环境问题的科学分析,更重要的是提出了可持续发展的城市规划原则,即将“规划建设、新建筑设计、历史环境保护、一般建筑的维修与改建、古旧建筑合理地重新使用、城市和地区的整治、更新与重建、以及地下空间的利用和地下基础设施的持续发展等纳入一个动态的、生生不息的循环体系之中”。
|
||||||
|
|
||||||
|
《北京宪章》发挥了东方系统思想的优势,在深入了解未来的发展态势、广泛吸收现代科学理论的基础上,提出了“广义建筑学”理论框架。它认为:对建筑学有一个广义的、整合的定义是新世纪建筑学发展的关键; 反对将建筑学这一“共同的问题”分割成单独论题的做法,它提倡“从局部走向整体,并在此基础上进行新的创造”思维方式。运用科学的认识论和方法论揭示了建筑历史发展规律,建立了当代建筑科学理论体系,它将发挥重大的理论指导作用,提出用辩证系统思维作为基本方法论,既肯定了分析的作用,更强调有机综合的科学价值。
|
||||||
|
|
||||||
|
《北京宪章》提出了符合生态原理的建筑设计原则,即用新陈代谢的客观规律和循环体系的观念,将建筑的生命周期的概念,融入人居环境建设过程之中——不仅结合建筑的生产与使用阶段,还要基于“最小的耗材、少量的‘灰色能源’消费和污染排放、最大限度地循环使用和随时对环境加以运营、整治”。
|
||||||
|
|
||||||
|
\subsubsection{5. 新型城镇化时期的城市规划}
|
||||||
|
2010年后,全球进入后金融危机时代,全球生产、贸易分工格局及金融体系发生深刻变化,中国发展的内外环境日益严峻,中共十八大、十九大做出中国经济步入“新常态”的判断,提出深化改革、美丽发展、创新发展等一系列新目标,以及“五大发展理念”生态文明建设、高质量发展、国内国际双循环等新要求。国家发展的价值取向、模式与路径都发生了重大转变,持续了近20年的增长主义模式走向终结,新时代的国家发展纲领不仅仅是应对和消化增长主义时期所累积的种种问题,而且要在更高的目标征程中解决“美好生活需要和不平衡不充分的发展之间的矛盾”,实现中华民族伟大复兴的中国梦。在此背景下,城乡规划的功能角色、社会认知及其思想方法均发生了显著的变化,并开始启动与国土空间规划相融合的工作。
|
||||||
|
|
||||||
|
(1)需要从治国理政的高度来认识、理解城乡规划。城乡规划不仅从工程技术明确转向了公共政策,而且成为国家实现治理体系和治理能力现代化的重要组成部分,要在国家多层级治理架构下发挥对国土空间的规范、协调等重要作用,同时也将在“政府—市场—社会关系”调整中发挥更加突出的作用。
|
||||||
|
|
||||||
|
(2)城乡规划的价值取向发生重大转变。从长期以来强调(或者事实上)促进城市经济增长、城镇化数量水平提高等“数字目标的增长”,转变为关注以人为中心的新型城镇化,促进高质量发展、区域协调发展、城乡可持续发展、社会和谐共享等一系列“综合目标”的实现,国土空间规划要求中央、地方各级政府承担起更加综合有力的资源与空间管控职能,实现对资源的更好保护与更高效利用,促进形成更加均衡、美丽、永续的国土空间格局。
|
||||||
|
|
||||||
|
(3)城乡规划的内容重点发生重大调整。从长期保障城市发展增长的需求,转变为生态优先、引导发展、刚性管控,不再是“以需求定空间供给”,而是首先要明确生态环境保护的基底、耕地保护的底线,以有限的空间供给来约束无限的空间增长需求。
|
||||||
|
|
||||||
|
(4)更加强调中国本土规划的理论与实践探索。由于过去长期照搬苏联和西方的理论、经验,中国城乡规划在过去发展中出现了“一手的规划实践,二手的规划理论”的尴尬现象。中共十八大以后,在“四个自信”的指引下,中国城乡规划的理论与实践发生了明显转向,强调构建中国本土规划的理论体系,增强民族自信、传承中国文化、总结中国经验,进而向世界贡献解决问题的“中国方案”,在雄安新区规划、北京通州新行政中心区规划等工作中,得到了鲜明的体现。
|
||||||
|
|
||||||
|
(5)更加强调城乡规划的引领地位、科学性、严肃性与持续性。中央对城乡规划高度重视,提出要认识、尊重、顺应城市发展规律,科学决策、科学规划;明确了城乡规划(国土空间规划)要发挥“战略引领、刚性管控”的作用;提出要坚守规划要求,要坚持“一张蓝图干到底”,要有“功成不必在我”的持之以恒的精神等等。
|
||||||
|
|
||||||
|
\subsubsection{6.全面推进国土空间规划以来的城市规划}
|
||||||
|
2019年5月,中共中央、国务院印发《关于建立国土空间规划体系并监督实施的若干意见》,标志着我国开始全面推进并实施国土空间规划。这是我国未来推进生态文明建设、实现高质量发展和高品质生活的关键举措,也是促进国家治理体系和治理能力现代化的必然要求,国土空间规划的实施不但要实现“多规合一”,更提出了全域全要素的管控要求,国土空间规划与传统意义上以发展建设为主体导向的城乡规划有所差异,也不同于注重管控思维的土地利用规划,而是在生态文明理念下对空间规划的重构,将传统规划的内容从建成环境扩展到全域全要素,体现了国土空间规划的战略性、约束性、系统性和权威性等特征。
|
||||||
|
|
||||||
|
改革开放以来,中国工业化和城镇化取得了举世瞩目的成就,但也带来了一系列可持续发展的问题,如社会经济和资源环境的矛盾日益突出、区域发展差距不断扩大、土地利用粗放严重等,尤其是随着城乡建设用地不断扩张,农业和生态用地空间受到挤压,城镇、农业、生态空间矛盾加剧,不同空间尺度都存在着人和自然之间、生产和生活活动之间、自然生态系统内部关系不尽协调的矛盾。在新型城镇化与城乡统筹发展的背景下,解决经济高速增长、社会快速转型中存在的国土开发秩序混乱和资源环境代价沉重等问题一直是可持续发展领域的重大科学命题。
|
||||||
|
|
||||||
|
2018年4月,中华人民共和国自然资源部正式成立,城乡规划的相关职责也由建设部调整到自然资源部之中。2019年5月23日,中共中央、国务院《关于建立国土空间规划体系并监督实施的若干意见》正式公布,标志着国土空间规划体系顶层设计和“四梁八柱”基本形成。机构合并为国土空间规划体系的构建奠定了基础,但体系的构建还需要思维方式转变和过往经验总结的有效结合,要做到水乳交融、知行合一,才能真正确保构建“全国统一、责权清晰、科学高效”的国土空间规划体系。
|
||||||
|
|
||||||
|
构建国土空间规划体系是我国推进生态文明建设的客观要求,是关系到国民经济与社会能否长期、持续、健康发展的重要工作,是国家推进生态文明建设、全面统筹经济社会发展、合理高效配置资源、协调发展与保护及解决规划“打架”问题的重要手段,也是实现国家治理体系和治理能力现代化的重要路径。
|
||||||
|
|
||||||
|
\section{西方城市规划思想与理论发展}
|
||||||
|
人类经济社会发展的阶段性特征使得城乡规划思想与理论同步发展。一般将自古希腊到18世纪下半叶工业革命前称为“古代城市规划时期”,其中又将古希腊、古罗马时期称为“古典城市规划时期”,将中世纪及文艺复兴前后称为“中古城市规划时期”;将工业革命以后至20世纪初称为“近代城市规划时期”,这是城乡规划思想理论层出不穷并逐步走向独立学科建设的重要时期;将20世纪初以后至今的时期统称为“现代城市规划时期”。
|
||||||
|
|
||||||
|
\subsection{西方古代城市规划思想与理论}
|
||||||
|
从公元前5世纪到公元17世纪,欧洲先后经历了从以古希腊和古罗马为代表的奴隶制社会,到封建社会的中世纪、文艺复兴和绝对君权等几个重要的历史时期。随着经济社会和政治背景的变迁,不同的政治势力占据着社会主导地位,不同的思想、价值观占据着文化主导地位,不仅带来了不同城市的兴衰,而且也深刻地影响着城市规划的思想和实践,透过西方古代城市发展的复杂过程,可以看出城市规划思想演化的一条基本脉络,即人文主义思想与君权(神权)思想的穿插交锋。
|
||||||
|
|
||||||
|
\subsubsection{1.古希腊时期的城市规划思想}
|
||||||
|
古希腊是欧洲古典文明的圣地,也是西方文明的基础。在爱琴海边的希腊半岛及其周边地中海沿岸等地方,逐步形成了数十个相对稳定的奴隶制城邦国家,其中最繁荣的有雅典(Athens )、斯巴达(Sparta )、米利都(Miletus )、科林斯(Corinth )等。在公元前5世纪古希腊经历了奴隶制的民主政体,形成一系列城邦国家,并从政权体制上确立了民主共和的思想,这一思想甚至深刻影响着现代西方的社会与政治体系,古希腊的城市规划思想体现出如下一些特征:
|
||||||
|
|
||||||
|
(1)城邦与公共空间精神。古希腊人认为社区的规模和范围应当使其中的居民既有节制而又能自由自在地享受轻松的生活。古希腊人在城市的艺术、文化、体育等领域的全面拓展,建设了卫城(圣地),成为希腊城邦精神的化身和有形体现。公元前5世纪,雅典卫城就已成为希腊人宗教与公共活动的中心,希波战争胜利以后其更被视为国家、民族的象征场所,强调给公民以平等的居住条件,包括雅典在内的许多希腊城市都以方格网划分街坊,贫富住户混居在同一街区,仅在用地大小与住宅质量上有所区别。古代希腊人基本的生活方式以公共生活为特征,这种生活方式塑造了古代希腊城市的基本特征,反映在空间格局上,是每个城邦的中心都是一个开放的中心广场。以雅典为例,它的中心广场位于卫城的北坡下面,城市中还要体育场、剧场等的公共空间。各种公共场所一道构成了丰富多彩的城市公共空间体系,成为希腊人多姿多彩的户外生活的载体,进一步激发了古希腊人的公共意识与思辨精神。
|
||||||
|
|
||||||
|
(2)宗教与人本思想并存。在古希腊的宗教中,表现的是对多个自然神的崇拜,神、人“同形同性”。古希腊人在崇拜众神的同时,更承认人的伟大和崇高,笃信人的智能和力量,重视人的现实生活。城市中大量的公共活动促进了市民平等、自由和荣誉的增长。雅典卫城以及其他圣地建筑并非膜拜神灵的禁地,而更是市民公共活动的中心,是古希腊人本主义的象征。
|
||||||
|
|
||||||
|
古希腊建筑中通过柱廊所围合出的半公共场所、许多开敞的城市广场空间一道构成了丰富多彩的城市公共空间体系,成为希腊人多姿多彩的户外生活的载体。特别是圣地建筑的重要性,已经远远超越了传统的宗教祭祀职能、防御职能而成为城邦公民举行礼仪活动的场所和公共活动的中心,成为古希腊公共生活的反映、公众鉴赏的对象。
|
||||||
|
|
||||||
|
\textbf{代表性城市规划:}\textbf{希波丹姆斯模式}
|
||||||
|
|
||||||
|
在古希腊的城市规划中,随着古希腊美学观念的逐步确立和自然科学、理性思维发展的影响,也产生了另一种显现强烈人工痕迹的城市规划模式—希波丹姆斯模式。
|
||||||
|
|
||||||
|
希腊哲学家Pythagoras(前580一前500)认为:“数为万物的本质,宇宙的组织在其规定中是数及其关系的和谐体现。”亚里士多德则说:“美是由度量和秩序所组成的”,建筑物各部分间的度量关系就是比例,他主张对城市的规模和范围应加以限制,使城市居民既有节制又能自由自在地享受轻松的生活。基于柏拉图、亚里士多德等人有关社会秩序的理想,公元前5世纪的法学家希波丹姆斯在希波战争后的城市规划建设中,提出了一种深刻影响后来西方二千余年城市规划形态的重要思想—希波丹姆斯模式,他因而也被誉为“西方古典城市规划之父”。希波丹姆斯模式遵循古希腊哲理,探求几何与数的和谐,强调以棋盘式的路网为城市骨架并构筑明确、规整的城市公共中心,以求得城市整体的秩序和美。在历史上,希波丹姆斯模式被大规模地应用于希波战争后城市的重建与新建以及后来古罗马大量的营寨城,古希腊的海港城市米利都城(Miletus),普南城等都是这一模式的典型代表,影响了近代西方许多殖民城市的规划形态。
|
||||||
|
|
||||||
|
\subsubsection{2.古罗马时期的城市规划思想}
|
||||||
|
古罗马时代是西方奴隶制发展的繁荣阶段,古罗马先后经历了城邦时代、共和时代与帝国时代,在此过程中,古罗马是完全依靠强大的武力(罗马军队为代表的国家行政机器)而存在的,所有这些都保障了一个强大的中央集权国家的建立,城市风格明显表现出君权化、军事化的特征。
|
||||||
|
|
||||||
|
罗马共和国后期和帝国建立以后,城市更成为统治者、帝王宣扬他们功绩的工具,广场、铜像、凯旋门和纪功柱等成为城市空间秩序组织的核心和焦点,古希腊时期那种纯粹的市民公共活动,已经基本让位于有组织渲染的种种歌颂“伟大罗马”的整体性纪念活动,诸多广场也由最初的集会场所演变成了纯粹的纪念性空间。罗马城是君权化特征最为集中体现的地方,重要公共建筑的布局、城市中心的广场群乃至整个城市的轴线体系,一起透射出王权至上的理性与绝对的等级、秩序感,象征着君权神圣不可侵犯,这与东方帝国的城市特征有着本质的一致。
|
||||||
|
|
||||||
|
古罗马城市规划、建筑设计的指导思想和重要任务之一,就是体现罗马国家强大的政治力量和严密的社会组织性。彰显繁荣与力量的大比例模数建设思想,是为了使城市和建筑显现出一种具有征服力的崇高感和震撼感,罗马人在实践中通常热衷于选择大比例模数,许多建筑的空间尺度与规模远超其功能需要。例如古罗马的许多广场、斗兽场、公共浴室、宫殿等都达到了超人的空间尺度和规模。古罗马在城市的总体空间创造方面重视空间的层次、形体和组合,并使之达到宏伟与富于纪念性的效果,高超的空间设计手法以及对建筑群体秩序的把握,成为了后世城市规划设计的典范。
|
||||||
|
|
||||||
|
\textbf{代表性著作:}\textbf{《建筑十书》}
|
||||||
|
|
||||||
|
维特鲁威是古罗马杰出的规划师、建筑师,公元前27年其撰写的《建筑十书》力求依靠当时的唯物主义哲学和自然科学的成就,对古罗马城市建设的辉煌业绩、大量先进的规划建设理念和技术进行历史性总结。《建筑十书》分十个篇章,分别总结了自古希腊以来的城市规划、建筑经验,对城址选择、城市形态、城市布局、建筑建造技术等方面提出了精辟的见解,是一本百科全书式的成果。《建筑十书》奠定了欧洲建筑科学的基本体系,在文艺复兴以后更作为西方建筑学的基本教材达三百余年之久。维特鲁威继承了古希腊的许多哲学思想和城市规划理论,提出了他的理想城市模式,在这个理想城市模式中,他把理性原则和主观感受结合起来,把理想的美和现实生活的美结合起来,把以数的和谐为基础的毕达哥拉斯学派的理性主义同以人体美为依据的希腊人文主义思想统一起来,强调建筑物整体、局部以及各个局部之间和整体之间的比例关系,充分考虑城市防御和方便使用的需要。
|
||||||
|
|
||||||
|
\subsubsection{3.中世纪欧洲城市的规划思想}
|
||||||
|
西罗马帝国灭亡后,欧洲进入漫长的中世纪,欧洲分裂成许多小的封建领主王国,封建割据和战争不断,社会生活中心转向农村,手工业和商业萧条,城市处于衰落状态。在这一时期,教会势力变得十分强大,教堂占据了城市的中心位置,教堂的庞大体量和高耸尖塔成为城市空间布局和天际轮廓的主导因素。在中世纪的欧洲,宗教神权思想占据了绝对统治地位,人性受到压抑,君权也受到牵制,战争频繁,城市建设极为有限,人们一般称之为“黑暗的中世纪”。
|
||||||
|
|
||||||
|
由于中世纪的城市发展缓慢,缺乏大规模的人工规划干预,所以形成了十分自然有机的城市形态、亲切的空间尺度、宜人的景观环境,使得中世纪的欧洲城市景观具有独特的魅力,常常得到我们今天的赞誉。10世纪以后,西欧的手工业和商业逐渐兴起,一些区位优越、工商业经济发达的城市逐渐摆脱了封建领主的统治。在这些城市中,公共建筑(如市政厅、关税厅、行业会所等)开始占据城市空间的主导地位,中世纪后期,随着手工业和商业的持续繁荣,一些市民的思想与精神在逐步复苏。
|
||||||
|
|
||||||
|
\subsubsection{4.文艺复兴与绝对君权时期的城市规划思想}
|
||||||
|
14世纪初开始的文艺复兴是欧洲资本主义,人本主义的萌芽时期,艺术、技术和科学都得到飞速发展,文艺复兴实际上是早期资本主义对其价值观的一种宣扬,但限于当时政治力量的对比格局,只得打出复兴“古希腊、古罗马”文化的旗帜,其核心是宣扬人性解放,实质是为了建立资产阶级的价值观和秩序,但由于资产阶级此时还没有真正登上统治社会的舞台,因此,在城市规划建设中主要还是集中于对单个建筑或城市片区的小规模营建与改造。
|
||||||
|
|
||||||
|
在17世纪后半叶,新生的资本主义迫切需要强大的国家机器提供庇护,而君主政权又需要利用资产阶级强大的物质力量和积极的斗争精神来约束和对抗教会。资产阶级与国王结成联盟,共同反对封建割据和教会势力,这一时期在欧洲建立了一批统一而强大的中央集权、绝对君权国家,进行了大规模的城市改造和城市建设运动。随着君主政权的强大,古典主义与唯理主义在欧洲的文学、艺术等方面占据绝对统治地位,城市规划思想也追求抽象的对称和协调,寻求纯粹几何结构和数理关系,强调轴线、放射和主从关系,以凸显永恒、王权至上的主题。
|
||||||
|
|
||||||
|
\subsection{西方近代城市规划思想与理论}
|
||||||
|
\subsubsection{1.近代城市规划产生的历史背景}
|
||||||
|
16世纪末至17世纪初,欧洲爆发的资产阶级革命将整个西欧都推上了资本主义制度的发展轨道,资本主义制度代表了一种更为先进的生产关系,带来17世纪后半叶至18世纪生产力的大飞跃,并最终引发了18世纪下半叶席卷欧洲的工业革命。到了19世纪,整个西方社会基本上都建立了资本主义制度并迎来了机器大生产的时代,人类的文明与社会发展从此掀开了新的历史篇章。
|
||||||
|
|
||||||
|
由于工业生产方式的改进和交通技术的发展,传统的古典城市空间结构已经无法适应新的发展现实,农业生产劳动率的提高和资本主义制度的建立,导致大量破产农民向城市集中,各大城市都面临着人口的爆发性增长,人口的快速增长使得城市原有的居住设施严重不足,旧的居住区不断沦为贫民窟,提供给工人的新建廉价住房更是粗制滥造,不仅设施严重缺乏,基本的通风、采光不能满足,而且居住密度极高,服务配套设施不全,导致了传染疾病的大范围流行,这种糟糕的城市环境卫生状况加剧了社会矛盾,引起了社会各阶层人士的关注,从19世纪中叶开始,西方国家出现了大量有关寻求解决城市问题方案的讨论,诸如霍华德的“田园城市”、玛塔的“带形城市”、戈涅的“工业城市”、西谛的城市形态研究等等,成为现代城市规划思想和理论形成的重要基础。
|
||||||
|
|
||||||
|
\subsubsection{2.近代城市规划的探索实践}
|
||||||
|
(1)空想社会主义的启蒙。早在资本主义社会早期,面对资本家对农民、工人极端残酷的剥削,很多怀有社会良知的先驱们已经开始质疑资本主义制度的合理性,并思考和探索理想的国家、城市形态,他们普遍认为,推翻、埋葬资本主义制度,建立以公有制为主体、消灭剥削的民主社会,是解决剥削问题的根本途径,这些思想家们的各种理论与概念被统称为“空想社会主义”。
|
||||||
|
|
||||||
|
近代历史上的空想社会主义思想最初起源于英国人文主义者托马斯·莫尔(Thomas More,1478-1535)的“乌托邦”概念,随后又影响了圣西门(Saint-Simon, 1760-1825)、查尔斯·傅立叶(Charles Fourier,1772-1837)、罗伯特·欧文(Robert Owen,1771-1858)等多位空想社会主义者。这些空想社会主义者不仅通过著书立说来宣传和阐述他们对理想社会的坚定信念,同时还通过一些实验来推广和实践自己的理想。虽然空想社会主义者的理论、实践在当时的西方世界中几乎没有产生实际的影响,但是这种先进的思想和理念却对后来城市规划思想理论(包括霍华德的“田园城市”理论)的发展产生了重要的作用。
|
||||||
|
|
||||||
|
(2)英国关于城市卫生和工人住房的立法。1842年英国政府提出了《关于英国工人阶级卫生条件的报告》,这一报告成为政府开始关注城市卫生状况和工人住房问题的转折点。1848年英国通过《公共卫生法》,规定了地方当局对污水排放、垃圾堆积、供水、道路等方面应负的责任。由此开始,英国通过一系列卫生法规建立起一整套对城市卫生问题的干预和控制手段。对工人住宅的重视促成了如1868年的《贫民窟清理法》、1890年的《工人住房法》等一系列法规的出台。英国改善城市居住环境的行动对欧洲国家产生了巨大影响,19世纪中叶后“公司城”作为资本家就近解决工人的居住需求,提高工人的生产力而出资建设和管理的小型城镇,开始在西方各国大量出现。
|
||||||
|
|
||||||
|
(3)城市美化运动。通常所说的“城市美化运动”,主要是指19世纪末至20世纪初欧美许多城市为缓解日益严峻的城市病、恢复城市的良好环境和吸引力而进行的一系列景观改造活动。城市美化运动首先开始于美国,其前奏是1850年代末开始的“公园运动”,在奥姆斯特德(Olmsted)的率领下,纽约在1859年首先建设了第一个现代意义的城市开敞空间—纽约中央公园。城市美化运动的目的是通过创造一种新的物质空间形象和秩序,恢复城市中由于工业化的破坏性发展而失去的视觉美与和谐生活,从而改进人们的生存环境。后在芝加哥等地持续进行了大量实践,这种景观环境改造理念改善了城市运行机能,开创了促进城市中人与自然相融合的新纪元,并催生了后来景观建筑学、园林规划和城市绿地规划等学科的兴起与发展。
|
||||||
|
|
||||||
|
\subsubsection{3.现代城市规划的奠基:田园城市理论}
|
||||||
|
在19世纪中期以后的种种社会改革思想和实践的影响下,英国人霍华德(E. Howard)于1898年出版了《明天:通往真正改革的平和之路》(Tomorrow:a Peaceful Pathto Real Reform)的论著,提出了著名的“田园城市”(Garden City)理论。“田园城市”的提出,既标志着近现代城市规划学科出现了比较完整的理论体系和实践框架,也标志着现代城市规划的诞生。
|
||||||
|
|
||||||
|
针对当时城市(尤其是像伦敦这样的大城市)所面对的城市问题,霍华德提出用一个兼有城市和乡村优点的理想城市—“田园城市”作为解决方案。“田园城市”是为健康、生活以及产业而设计的城市,它的规模足以提供丰富的社会生活,但不应超过这一程度;四周要有永久性的农业地带围绕:城市的土地归公众所有,由委员会受托管理。霍华德“田园城市”理论的整体思路形成过程大致如下(见表3.1)。
|
||||||
|
|
||||||
|
表3. 1田园城市理论整体思路形成过程
|
||||||
|
|
||||||
|
“田园城市”是一个综合的城市规划、发展、建设模式,不同于我们常说的以景观营建为主的“花园城市”。“田园城市”也不同于“卫星城市”,虽然“田园城市”是“卫星城市”的思想渊源,而且结构有类似之处,但本质区别在于:“卫星城市”中的中心城市与卫星城市的规模、功能相差极为悬殊,是对大城市、特大城市空间与功能进行疏解的一种手段;而“田园城市”是一组中心城市与周边田园城市规模、功能相差不大的平衡组群,强调的是城乡统筹发展形态。
|
||||||
|
|
||||||
|
概要而言,霍华德的“田园城市”对近现代城市规划发展的重大贡献在于以下方面:
|
||||||
|
|
||||||
|
(1)在城市规划指导思想上,摆脱了传统规划用来显示统治者权威或张扬规划师个人审美情趣的旧模式,提出了关心人民利益的宗旨,这是城市规划思想立足点的根本转移。
|
||||||
|
|
||||||
|
(2)针对工业社会中所出现的严峻、复杂的城市问题,摆脱了就城市论城市的狭隘观念,从城乡结合的角度将其作为一个体系来解决。
|
||||||
|
|
||||||
|
(3)设想了一种先驱性的模式,一种比较完整的规划思想与实践体系,对现代城市规划思想及其实践的发展都起到了重要的启蒙作用。
|
||||||
|
|
||||||
|
(4)首开了在城市规划中进行社会研究的先河,以改良社会为城市规划的目标导向,将物质规划与社会规划紧密地结合在一起。
|
||||||
|
|
||||||
|
\subsection{ 20世纪以来西方现代城市规划思想与理论}
|
||||||
|
\subsubsection{1.西方现代城市规划思想与理论发展的总体分期}
|
||||||
|
20世纪以来,西方现代城市规划思想、理论发展可以划分为四个时期。
|
||||||
|
|
||||||
|
(1)1900年代至二战前,这是一些精英分子对现代城市规划思想进行各种探索、实践的时期,为战后功能主义思想垄断地位的确立奠定了基础。
|
||||||
|
|
||||||
|
(2)二战后至1960年代末,以现代建筑运动为支撑的功能主义规划思想,在战后西方城市重建和快速发展过程中发挥了积极且重要的作用,从而最终完成了现代城市规划思想体系的确立并达到其认知的顶峰。
|
||||||
|
|
||||||
|
(3)1970年代至1980年代末,西方社会在这个时期经历了巨大的社会转型,也就是进入了通常所说的“后现代社会”,社会价值观体系处于混沌交织的过程中,社会文化论在城市规划思想中占据了主导地位。
|
||||||
|
|
||||||
|
(4)1990年代后,西方社会基本恢复了平静和秩序,但是随着经济、政治全球化的深入以及通信、互联网等技术的发展,人们不得不深刻地思考一些至关人类未来发展的重大问题,例如全球化的影响、可持续发展、增长与发展、以人为本、治理(Governance)、智慧城市、生态城市等等,城市规划思想的探索面对着一幅崭新的社会图景。
|
||||||
|
|
||||||
|
\subsubsection{2.二战前西方重要的城市规划思想与理论}
|
||||||
|
19世纪末至20世纪初是西方城市规划思想与理论繁盛发展的时期,下面主要介绍几个重要的规划思想与理论:
|
||||||
|
|
||||||
|
(1)格迪斯的区域规划思想及学说
|
||||||
|
|
||||||
|
苏格兰生态学家格迪斯注意到工业革命、城市化对人类社会的影响,他通过对城市进行生态学的研究,强调人与环境的相互关系。在1915年出版的著作《进化中的城市》中,他通过周密分析地域环境的潜力和限度对于城市布局形式与地方经济体系的影响关系,突破了当时常规的城市概念,提出把自然地区作为规划研究的基本框架。他指出,工业的集聚和经济规模的不断扩大已经造成了一些地区的城市发展显著集中,使城市结合成巨大的城市集聚区(Urban Agglomeration)或者形成组合城市(Conurbation)。在这样的条件之下,原来城市规划应当首先是城市地区的规划,即将城市、乡村的规划纳入统一的体系之中,使规划包括若干个城市以及它们周围所影响的整个地区。
|
||||||
|
|
||||||
|
格迪斯认为城市规划要取得成功,就必须充分运用科学的方法来认识城市。他综合运用哲学,社会学和生物学的观点,揭示了城市在空间和时间发展中所展示的生物学和社会学方面的复杂性。他强调在进行城市规划前要进行系统的调查,取得第一手的资料,通过勘察了解所规划城市的历史、地理、社会、经济、文化、美学等因素,把城市的现状和地方经济、环境发展潜力以及限制条件联系在一起进行研究,在此基础上进行城市规划工作。他的名言是“先诊断后治疗”,由此而形成了影响至今的现代城市规划经典过程,即“调查—分析—规划”。格迪斯被公认为现代区域综合研究和区域规划的创始人,是使城市、区域研究由分散走向综合的第一人。
|
||||||
|
|
||||||
|
(2)作为微观社区组织的邻里单位理论
|
||||||
|
|
||||||
|
美国建筑师C.佩里很早就认识到居住地域作为一种场所空间的内在社会文化含义,他借用社会学中的“社区”思想,于1929年提出了“邻里单位”(Neighbourhood Unit)的概念,将其作为构成居住区乃至城市的细胞。“邻里单位”以一个不被城市道路分割的小学服务范围作为邻里单位的尺度,讲求空间宜人景观的营建,强调内聚的居住情感,重视居民对居住社区的整体文化认同和归属感。佩里认为这不仅是一种创新的设计概念,而且是一种社会工程,它将帮助居民对所在的社区和地方产生一种乡土观念。邻里单位模式对后来直至今天世界各国的居住区规划(或社区规划)都产生了重大的影响。
|
||||||
|
|
||||||
|
(3)分散主义、集中主义的争论与统一
|
||||||
|
|
||||||
|
针对大城市、特大城市因为过度聚集而产生的城市问题,许多人给出了不同的解决方案,其中最主要的两种思想冲突就是应该采取“分散主义”还是“集中主义”。霍华德的田园城市体现了一种分散主义的思想,而美国建筑师赖特提出的“广亩城市”更是分散主义思想的代表。广亩城市依托小汽车、通信等技术支撑而彻底解体了城市,发展出一种完全分散的、低密度的生活居住形态。这种空间形态虽然满足了中产阶级、高收入人群对田园环境的向往,但是却牺牲了城市的规模经济和集聚活力,更对资源环境造成了巨大的压力和破坏,是一种并不值得提倡的规划思想。
|
||||||
|
|
||||||
|
与分散主义思想相反,现代建筑与城市规划运动的领军人物柯布西埃则希望通过对大城市结构的重组、内部改造,使这些城市能够重新适应社会发展的需要。1922年柯布西埃发表了“明日城市”的规划方案,从功能合理性角度出发阐述了集中主义城市的解决方案:城市的平面是严格的几何形构图,矩形和对角线的道路交织在一起,核心思想是提高市中心的建设强度,建立大运量、立体化的交通系统,全面改造老城区,提供充足的绿地、空间和阳光。1931年的“光辉城市”规划方案是集中主义城市的进一步深化。柯布西埃认为城市是必须集中的,只有集中的城市才有生命力,由于拥挤而带来的城市问题是完全可以通过技术手段进行改造而得到解决的,所有的城市应当是“垂直的花园城市”,而不是水平向的田园城市。
|
||||||
|
|
||||||
|
作为现代城市规划原则的倡导者和执行的中坚力量,柯布西埃的上述设想充分体现了他对现代城市规划的一些基本问题的理解,并形成了理性功能主义的城市规划思想。这种思想集中体现在由他主导撰写的《雅典宪章》(1933年)之中,深刻地影响了二战后全世界的城市规划和城市建设,而他本人的实践活动一直到了1950年代初应邀主持印度昌迪加尔规划时才得以充分施展,该项规划当时由于严格遵守《雅典宪章》的原则、布局规整有序而得到普遍的赞誉。1960年代以后,随着城市规划领域对人文、社会因素的重视,柯布西埃的功能理性规划思想受到了越来越多的批判。
|
||||||
|
|
||||||
|
集中主义、分散主义这两种规划思路,也显示了两种完全不同的规划思想和规划体系:霍华德的规划理念基于社会改革的理想,在其论述的过程中更多地体现出人文关怀和对社会经济的关注;柯布西埃则从建筑师的角度出发,对工程技术的手段更为关心,并希望以物质空间的改造来改造整个社会。集中主义、分散主义这两种规划思路的冲突,直到沙里宁的“有机疏散理论”Organic Decentration)出现才得以统一。
|
||||||
|
|
||||||
|
1943年美国建筑师伊利尔·沙里宁在著名的《城市:它的发展、衰败和未来》一书中详尽地阐述了有机城市、有机疏散的思想。沙里宁认为,城市与自然界的所有生物一样,都是有机的集合体,因此城市规划建设应努力实现有机的秩序。为了缓解城市机能过于集中所产生的弊病,使城市逐步恢复有机的秩序,沙里宁提出了有机疏散理论,认为城市作为一个有机体,和生命有机体的内部秩序一致,不能任由其无限集聚,而要把城市的人口和工作岗位有机分散到合理的地方。他将城市活动划分为日常性活动和偶然性活动,通过“对日常活动进行功能性的集中”和“对这些集中点进行有机地分散”,使原先密集的城市得以实现有机疏散。他指出,前一种方法能给城市的各个部分带来适于生活和安静的居住条件,而后一种方法则可以给整个城市带来功能秩序和工作效率。换个角度讲,有机疏散就是把传统大城市的拥堵区域分解成若干个集中单元,并把这些单元组织成为“在活动上相互关联的有功能的集中点”,再将它们彼此之间用保护性的绿化地带隔离开。有机疏散思想对二战后欧美各国改善大城市问题,尤其是通过卫星城建设来疏散特大城市的功能与空间产生了重要影响。
|
||||||
|
|
||||||
|
\subsubsection{3.二战后至1960年代末西方主要的城市规划思想与理论}
|
||||||
|
(1)卫星城理论与新城运动
|
||||||
|
|
||||||
|
1920年代,恩温(R.Unwin)进一步推进霍华德的思想并提出了“卫星城”的概念(见图8)。卫星城既是一个经济上、社会上、文化上具有现代城市性质的独立城市单位,同时又是从属于某个大城市的派生产物。卫星城的概念强化了其与中心城市(又称母城)的依赖关系,在功能上强调对中心城的疏解,因此往往被视作中心城市某一功能疏解的接受地,由此出现了工业卫星城、科技卫星城甚至卧城等不同功能类型,并成为中心城市功能组的一部分。1944年阿伯克隆比(P,Abererombie)完成的大伦敦规划,通过在伦敦周围率先建设卫星城以疏解伦敦的人口和职能,对现代城市规划产生了深远的影响。从二战后的战后恢复重建,到西方经济和城市快速发展时期,大多数国家都有不同规模的卫星城建设,其中尤以英国最为典型(一般称之为“新城运动”)。如今,卫星城(新城)已经成为分散大城市过于集聚的功能和人口,在更大的区域范围内优化城市空间结构、解决环境问题、实现功能协调的重要规划手段。
|
||||||
|
|
||||||
|
(2)环境行为研究与城市设计
|
||||||
|
|
||||||
|
1960年代以后,随着城市大规模物质空间建设的结束,人们对空间内在社会、文化、精神方面的要求不断提高,生态环境保护、历史文化保护城市更新等成为西方城市规划的重要内容。城市规划中越来越多地引入环境科学、行为科学的内容,这与现代科学尤其是人文科学的发展有着重要的关系,反映了人们对城市发展、城市规划的理解愈趋综合化。城市环境不再仅仅被视为一个视觉艺术空间的问题,而且更被理解为一种综合的社会交往场所。1960年代在美国出现了现代“城市设计”(Urban Design)的概念,城市设计将城市视作一个包括三维空间、时间变化在内的四维空间,强调人与空间的内在互动,强调景观设计对人们活动、心理感知的重要意义。《大不列颠百科全书》中对城市设计的定义是:“城市设计是对城市环境形态所做的各种合理处理和艺术安排。”但正如当时城市设计的提出者所言:“城市设计的出现并不是为了创造一门新的学科,而是对以前忽视空间人性关怀的一种弥补”。城市设计作为一种观念,应该渗透到城市规划建设的全过程中去。
|
||||||
|
|
||||||
|
\subsubsection{4.1970年代至1980年代末西方主要的城市规划思想与理论}
|
||||||
|
1960年代末以后,西方资本主义社会发生了深刻的变化,这种深刻的转变与经济发展的阶段、产业结构的调整、社会结构的变动、人们需求的转变、国际形势的变化等等都密切相关,集中体现为社会生活的各个领域变化节奏加快,冲突加剧、不确定性增强。这一时期,西方资本主义社会矛盾异常复杂,引发了西方思想家们对人、对社会、对未来的深切关注和思考,并形成和发展了丰富多元的现代(后现代)社会思潮。
|
||||||
|
|
||||||
|
总体上说,1970年代至1980年代是一个西方社会生活各个领域思潮都处于混沌交锋的大转型时期。在对现代主义的反思和批判过程中,城市规划由单纯的物质空间塑造逐步转向对城市社会文化的关注;由城市景观的美学考虑转向对具有社会学意义的城市公共空间及城市生活的创造;由巴洛克式的宏伟构图转向对普遍环境感知的心理研究。总之,开始从社会、文化、环境、生态等各种视角,对城市规划进行新的解析和研究。新马克思主义热潮在城市研究、城市规划领域再度兴起,强调运用政治经济观来深入分析资本主义社会的结构性矛盾,主要表现为:对规划中社会公正问题的关注;对社会多元性的重视;强调人性化的城市设计;注重对城市空间现象背后的制度性思考。
|
||||||
|
|
||||||
|
按照新马克思主义的视角来理解,城市规划的本质更接近于政治,而不是技术或科学,城市规划被视为以实现特定价值观为导引的政治活动。由此,西方的城市规划学科,研究与实践也开始了从工程技术向公共政策的重大转向。
|
||||||
|
|
||||||
|
\subsubsection{5.《雅典宪章》与《马丘比丘宪章》}
|
||||||
|
现代城市规划发展基本经历了两个阶段:第一阶段从霍华德的“田园城市”理论开始,经过1920年代、1930年代现代建筑运动的推进,以《雅典宪章》(1933年)的发布为代表,其实践活动主要集中于战后西方城市重建和快速发展阶段;第二阶段自1960年代末以来,以《马丘比丘宪章》(1977年)的发布为代表,逐渐建立了新的规划思想与方法。
|
||||||
|
|
||||||
|
《雅典宪章》依据科学理性主义的思想方法,对城市中普遍存在的问题进行了全面分析,提出了城市规划应当处理好居住、工作、游憩和交通的功能关系,并把该宪章称为现代城市规划的大纲。1970年代后期,面对世界城市化趋势和城市规划过程中所出现的新内容、新问题,发布了《马丘比丘宪章》。该宪章申明,《雅典宪章》仍然是这个时代的一项基本文件,它提出的一些原理今天仍然有效;但是随着时代的进步,城市发展面临着新的环境,而且人类对城市规划也提出了新的要求,《雅典宪章》的一些指导思想已不能适应当前形势的发展变化,因此需要进行修正。而《马丘比丘宪章》所提出的内容,正是“理性派所没有包括的,单凭逻辑所不能分类的种种一切”,《雅典宪章》和《马丘比丘宪章》都是对当时的规划思想进行总结,并对未来发展指出方向的现代城市规划发展历史纲领性文件。通过这两部文件内容的对比,我们可以总览现代城市规划发展的基本趋势。
|
||||||
|
|
||||||
|
(1)由物质空间规划走向综合空间规划
|
||||||
|
|
||||||
|
《雅典宪章》的思想方法是奠基于物质空间决定论基础之上的。这一思想认为,在城市规划中通过对物质空间变量的控制,就可以形成良好的环境,而这样的环境就能自动地解决城市中的社会、经济、政治问题,促进城市的发展和进步,这是《雅典宪章》所提出来的功能分区及其机械联系的思想基础。虽然认识到影响城市发展的因素是多方面的,但《雅典宪章》仍将城市规划视为一种“基于长、宽、高三维空间……的科学”。《马丘比丘宪章》则摒弃了《雅典宪章》机械主义和物质空间决定论的思想基石,宣扬社会文化论的基本思想。《马丘比丘宪章》认为,物质空间只是影响城市生活的一项变量,而且这一变量并不能起决定性的作用,起决定性作用的应该是城市中各人类群体的文化、社会交往模式和政治结构。城市规划“必须对人类的各种需求作出解释和反应”,并“应该按照可能的经济条件和文化意义,提供与人民要求相适应的城市服务设施和城市形态”。
|
||||||
|
|
||||||
|
(2)由功能分割走向系统综合
|
||||||
|
|
||||||
|
《雅典宪章》最为突出的内容就是提出了城市的功能分区,将城市中的各种活动划分为居住、工作、游憩和交通四大类,各自都有其最适宜发展的条件,以便给生活、工作和文化分类与秩序化。功能分区在当时有着重要的现实意义和历史意义,它对于当时大多数城市无计划、无秩序发展过程中所出现的问题,确实可以起到缓解和改善的作用。《马丘比丘宪章》则指出,《雅典宪章》所崇尚的功能分区“没有考虑城市居民人与人之间的关系,结果使城市患了贫血症,在那些城市里建筑物成了孤立的单元,否认了人类的活动要求流动的、连续的空间这一事实”。过度强调纯粹功能分区,导致人情冷漠、空间单调、缺乏生气等等社会问题。因此,《马丘比丘宪章》明确提出,“在今天,不应当把城市当作一系列的组成部分拼在一起考虑,而必须努力去创造一个综合的、多功能的环境”。
|
||||||
|
|
||||||
|
(3)由描绘终极状态步入动态循环过程
|
||||||
|
|
||||||
|
《雅典宪章》认为,城市规划的基本任务就是制定规划方案,而这些规划方案的内容都是关于各功能分区的“平衡状态”和建立“最合适的关系”,鼓励对城市发展终极状态下各类用地关系进行描述,并“必须制定必要的法律以保证其实现”。《马丘比丘宪章》则认为城市是一个动态系统,要求“城市规划师和政策制定人必须把城市看作在连续发展与变化过程中的一个结构体系”。1960年代以后,系统思想和系统方法在城市规划中得到了广泛的运用,直接改变了过去将城市规划视作对终极状态进行描述的观点,更强调城市规划的过程性和动态性。《马丘比丘宪章》在对这一系列理论进行总结的基础上做了进一步的发展,提出“区域和城市规划是个动态过程,不仅要包括规划的制定,而且要包括规划的实施。这一过程应当能适应城市这个有机体的物质和文化的不断变化”。城市规划就是一个不断模拟、实践、反馈的循环过程,只有通过这样不间断的连续过程才能更有效地与城市发展相协同。
|
||||||
|
|
||||||
|
(4)由表达专家意志转向鼓励公众参与
|
||||||
|
|
||||||
|
受传统精英主义思维的影响,《雅典宪章》对规划师、专家等社会精英的主导作用尤为重视,强调“规划师必须以专家所做得准确研究为依据”。在此思想的指导下,城市规划就成为一种少数专业人员表达他们意志,并以此来规范城市社会各类群体和个人行为的手段。1960年代中期以后,随着西方社会的转型,公众参与成为城市规划的一个重要内容。大卫多夫在1960年代初提出的“规划的选择理论”和“倡导性规划”概念,成为城市规划公众参与的理论基础:规划不应当以一种价值观来压制其他多种价值观,而应当为多种价值观的体现提供可能,规划师就是要表达这些不同的价值判断并为不同的利益团体提供技术帮助。在规划的过程中要让广大的市民参与规划的编制和讨论,并让公众能真正参与到规划的决策过程之中。《马丘比丘宪章》不仅承认公众参与对城市规划的极端重要性,而且更进一步提出“城市规划必须建立在各专业设计人员、城市居民以及公众和政治领导人之间的系统的、不断地互相协作配合的基础上”,并“鼓励建筑使用者创造性地参与设计和施工”,指出“人民的建筑是没有建筑师的建筑”。如今,公众参与被包括中国在内的世界许多国家的城市规划立法和制度所保障。
|
||||||
|
|
||||||
|
\subsubsection{6. 1990年代以来的多元规划观}
|
||||||
|
进入1990年代,国际环境的转变、技术与生产方式的变化、生活方式的转型等等,都使得城市问题变得更加复杂、变化莫测,已经没有一种理论、方法能够被用来整体地认识城市、改造城市,多元思潮蓬勃兴起,城市规划的理论与实践探索已经进入了一个更为广阔的背景之中。全球化、治理、生态、可持续、文化、智慧等等,成为主导新时期城市规划思想的关键词。泰勒(Taylor)曾经将这段时期西方城市规划领域所关注的重要议题列为五个方面:城市经济的衰退和复苏;超出传统阶级视野并在更广范围内讨论社会的公平;应对全球生态危机和响应可持续发展要求;回归对城市环境美学质量以及文化发展的需要;地方的民主控制和公众参与要求。其中既有新环境催生的对新规划思想的探索,也有对传统规划思想、规划价值观的螺旋性上升认识。世纪之交,美国的《规划专员杂志》(Planning Commissioners Journal)(1999年)提出了21世纪现代城市规划发展的九大趋势,主要包括以下方面:
|
||||||
|
|
||||||
|
(1)开发者与环境保护主义者的合作。城市规划由以前的开发型规划走向环境整治型的规划,如划定各种鼓励开发区、引导开发区、限制开发区、禁止开发区等等,强调开发与保护相结合。
|
||||||
|
|
||||||
|
(2)对公众参与的日益重视。随着城市社团力量的壮大,非政府力量对城市规划的干预作用增强,城市治理(Urban Governance)等思潮日益壮大。
|
||||||
|
|
||||||
|
(3)网络空间对土地利用的影响。随着信息网络技术的发展,城市空间正在发生着新的、根本性的演变,这对传统的城市空间、城市规划提出了巨大的、全新的挑战。
|
||||||
|
|
||||||
|
(4)更加紧凑的开发与混合使用的空间。随着资源环境的趋紧,紧凑发展成为越来越主动的需求。随着城市复兴、创新创意活动的发展,传统单一的商业中心转变为综合中心,传统单一的用地与空间组织模式日益被混合功能用地与空间所取代。
|
||||||
|
|
||||||
|
(5)开放空间网络与绿色通道。网络化的空间、开放空间、绿色通道可以给城市发展、布局带来更大的弹性。郊区化、逆城市化的过程及信息、交通技术的发展,也使得开放空间网络成为可能,并成为一种主动的需求。
|
||||||
|
|
||||||
|
(6)交通和土地利用整体规划的拓展。交通是构成城市物质环境结构的框架,现代城市受交通及其方式的发展而变化,必须在规划中将交通与城市土地利用、空间规划进行整体的协同考虑。
|
||||||
|
|
||||||
|
(7)贫困人群和老龄人的需求不断增长。随着社会极化的加剧以及老龄化社会的到来,城市规划必须考虑这种社会环境的变化,并满足不同人群尤其是弱势群体产生的种种要求。
|
||||||
|
|
||||||
|
(8)城市中心区的复苏。1980年代以后,一些国家实施了有力的“再城市化”策略,通过对原有市中心地区的功能与环境改造,努力复苏、创造一个充满活力的城市中心。
|
||||||
|
|
||||||
|
(9)区域合作不断受到重视。在经济全球化的今天,城市要增强竞争力,就必须通过与其他城市的协作来实行双赢、多赢,区域合作更加受到重视。
|
||||||
|
|
||||||
|
\subsection{ 21世纪的城市规划思想与理论变革}
|
||||||
|
\subsubsection{1.由单向的封闭型思想转向复合开放型思想}
|
||||||
|
封闭型思想主要包含了两层涵义:其一,思维的单向性,它否定了思维过程中后一阶段成果对前一阶段成果的作用;其二,封闭型,就是指思想过程中单系统的思维方式,它否定了该系统外的环境对系统的作用。
|
||||||
|
|
||||||
|
城市的开发、改造成效很大程度上取决于管理部门的组织,同时管理工作会对规划设计工作起到反馈作用,这才能使规划设计工作的成果得以实现。思维的单向性使人们忽视了管理工作对规划工作本身的作用,造成规划成果与实际需求脱离。与此同时,城市规划受到社会、经济等诸多因素的共同作用,因此需要必要的弹性,然而封闭型思想使得部分规划忽视了本系统之外的因素,造成了规划编制的不合理。城市规划需要更加复合开放的思想,需要广泛地听取社会学、心理学、经济学、管理学等方面的建议。
|
||||||
|
|
||||||
|
\subsubsection{2.由最终理想状态的静态思想转向过程导控的动态思想}
|
||||||
|
所谓最终理想状态的静态思想就是忽视了发展过程中的协调性,缺乏运行概念,使规划成为了“乌托邦”。这种最终理想状态的静态思想干扰着规划的发展,使规划脱离了城市建设发展的实际。
|
||||||
|
|
||||||
|
城市规划的目的就是要使城市在发展的各个阶段上其整个系统运行保持良性运转,不应该只是强调最终的理想状态。在规划执行的若干年内,城市各系统之间的关系是否协调、合理运行,城市经济效益、社会效益和环境效益是否提高,这是规划中需要重视的问题。动态过程的思想方法要求把城市规划工作的对象确定为动态过程,规划成果是一种动态的过程的控制和引导方法,城市规划管理的控制手段也是一种动态过程。
|
||||||
|
|
||||||
|
\subsubsection{3.由刚性规划的思想转向弹性规划的思想}
|
||||||
|
刚性规划思想缺乏多种选择性,欲求唯一的最佳方案。然而,由于刚性的规划成果很难适应城市这个综合复杂的巨系统。造成刚性规划思想的原因是机械的社会观,以机械性代替社会的综合性,同时是把规划与设计混为一谈,以设计工作的思想方法代替规划工作的思想方法。
|
||||||
|
|
||||||
|
弹性规划思想首先需要明确城市的发展是一个社会发展过程。在社会发展进程中,构成社会的各系统之间是互相作用的,其中由社会经济水平决定的社会意识形态具有最重要的决定性意义。规划是否合理根本上取决于整个社会意识形态和社会经济水平,所以说城市规划只是以政府意愿形式出现的反映社会经济水平的,维护城市社会发展过程平衡中的诸多力量之一。城市社会意识和社会经济水平构成的多样性、发展时间上的摆动决定了为其服务的城市规划必须是提供多种的可能性和选择性即弹性的规划思想方法。
|
||||||
|
|
||||||
|
\subsubsection{4.由指令性的思想转向引导性的思想}
|
||||||
|
指令性的思想方法认为城市系统的发展是由某一中心枢纽控制,而城市规划编制及管理就是这个枢纽,它控制了整个城市诸多系统的发展。这种思想方法使城市规划工作从城市诸系统中孤立出来。规划并不是城市发展中起指令性控制作用的中心枢纽。规划编制阶段应该集思广益,广泛综合各方面的分析成果。在指令性思想方法指导下的总体规划,容易随意地变动城市用地现状,不顾客观情况,造成规划成果肤浅、脱离实际。
|
||||||
|
|
||||||
|
引导性的思想方法强调各系统发挥自身的选择性,强调规划在城市发展进程的引导性控制作用,城市规划是向各系统提供正确的发展选择的引导者。例如城市发展方向的选择就要受到城市的经济效益的检验,经济效益和社会效益也起着重大作用。因此,引导性的思想方法首先要了解城市发展的需求以及开发者的价值观,其次根据布局结构关系拟定出城市发展的引导性措施,充分利用经济规律、社会规律等将城市的发展引入良性轨道。
|
||||||
|
|
||||||
|
\section{我国土地利用规划理论与实践}
|
||||||
|
|
||||||
|
我国古代已出现土地利用规划的萌芽,《禹贡》是中国古代文献中最古老和最有系统性的地理观念的著作,也是中国历史上最早的“土地利用规划”。《禹贡》以自然地理实体为标志,将全国划分为九州,并对疆域、山脉、河流、植被、土壤、物产、贡赋、少数民族、交通等自然和人文地理现象作了简要的描述。井田制是中国古代社会的土地国有制度,出现于商朝,井田规划是我国早期土地利用规划的雏形,它反映了当时田赋管理对组织土地利用的需要。《周礼》创立了“土会”“土宜”“土均”“土圭”的工作方法,进行土地规划、土壤研究和管理等方面的工作。
|
||||||
|
|
||||||
|
新中国成立后,随着与苏联全面合作,以“土地整理”为代表的现代土地利用规划引入我国,主要理论是承袭计划经济模式,1950—1970年代末,以农业土地利用为主,围绕国营农场和人民公社的发展提供土地条件和政策保障,1950年代后期改称为“土地规划”。
|
||||||
|
|
||||||
|
1950年代—1970年代末,我国的土地利用规划理论基本沿袭苏联的计划模式;1980年—1986年,国家土地管理局成立,全面开展了土地资源调查、农业区划、土地利用总体规划、农村土地利用规划等工作,土地利用规划理论与实践进入新的发展时期;1987年,《中华人民共和国土地管理法》颁布实施,正式确立了土地利用规划的法律地位,逐步建立起国家、省、地(市)、县、乡(镇)五级土地利用规划体系,全面落实“十分珍惜和合理利用每寸土地,切实保护耕地”的基本国策。
|
||||||
|
|
||||||
|
改革开放以后全国进行了三轮国家级土地利用规划,土地利用规划理念与实践不断丰富完善。
|
||||||
|
|
||||||
|
\subsection{第一轮土地利用总体规划(1986-1996年)}
|
||||||
|
规划编制正处于市场经济发展初期,全国城市建设处于全面发展的高峰时期,为服务经济发展,本轮土地利用规划围绕保障各类建设,初步确定了土地利用规划的体系、内容、方法与编制审批程序。
|
||||||
|
|
||||||
|
\subsection{第二轮土地利用总体规划(1996-2006年)}
|
||||||
|
第一轮土地规划编制完成后,我国城镇化发展迅速,经济全面开花的同时,耕地面积减少超过2000万亩,人口众多,耕地质量不高,后备资源紧缺逐步显现,1997年,《进一步加强土地管理切实保护耕地的通知》提出了实施基本农田保护和耕地总量平衡制度,以此为指导,本轮土地利用规划,以耕地总量动态平衡为目标,对耕地现状、建设占用总量逐级控制,确定了“指标+分区”的土地利用规划模式,土地利用规划编制模式与审批程序进一步完善。
|
||||||
|
|
||||||
|
\subsection{第三轮土地利用总体规划(2006—2020年)}
|
||||||
|
2006年,基于保障耕地总量,加强土地管理的目标,以当时土地普查的情况,提出了“18亿亩”保护底线的概念,这是国家统计局与原农业部按当时全国人口、粮食单产与复种指数等综合因素计算提出来的目标,确保2010—2030年粮食自给率达到95\%。
|
||||||
|
|
||||||
|
2006年启动的本轮土地利用规划更加突出了“节约和集约用地”的核心理念,突出以下几点:
|
||||||
|
|
||||||
|
(1)土地利用规划更加强调公共政策属性。强调编制与实践并重,重视保障措施与政策设计,开展了土地利用战略研究,提出了土地利用的约束性和预期性两类指标。
|
||||||
|
|
||||||
|
(2)土地利用规划更加强调空间管制与指标并重,针对性地提出了各类用地空间、基本农田、建设用地布局与管制分区的要求,更增加了规划弹性思想。
|
||||||
|
|
||||||
|
(3)土地利用更加强调综合性,以保护耕地和土地节约与集约为核心,从控制各类用地出发,兼顾经济发展、生态环境保护与土地利用之间的关系,同时,引入了环境影响评价的内容。
|
||||||
|
|
||||||
|
2018年起,随着建立国家统一的国土空间规划体系,土地规划利用与城乡规划和主体功能区等规划一道纳入国土空间规划中,我国土地规划工作理念内容和方法取得了良好的连续与发展。
|
||||||
|
|
||||||
|
\section{我国主体功能区规划理论与实践}
|
||||||
|
我国主体功能区划的发展历史进程较短,在“十一五”期间提出了主体功能区划的概念,即指在不同区域的资源环境承载能力、现有的开发密度和发展潜力等要素进行综合分析的基础上,以自然环境要素、社会经济发展水平、生态系统特征以及人类活动形式的空间分异为依据,划分出具有某种特定主体功能的地域空间单元。
|
||||||
|
|
||||||
|
主体功能区划的内容基于是否适宜或如何进行大规模高强度工业化城镇化开发为基准,大致分为以下四类:优化开发区域、重点开发区域、限制开发区域和禁止开发区域。按照主体功能定位调整完善区域政策和绩效评价,规范空间开发秩序,以形成合理的空间开发结构。主体功能区划的意义是促进区域协调发展、实现人口与经济合理分布的有效途径,是实现可持续发展、提高资源利用率的迫切需求,是坚持以人为本、实现公共服务均衡的有利保障。主体功能区划弥补了国土规划对于土地利用规划方面的不足,对于新型城镇规划和国土空间规划起到战略综合的作用。
|
||||||
|
|
||||||
|
\subsection{主体功能区的发展历程}
|
||||||
|
我国主体功能区总体上进行了一轮的发展过程,从2005年提出了主体功能区的思想到2011年国务院《全国主体功能区规划》的发布,大致经历了六年的时间。《全国主体功能区规划》是我国国土空间开发的战略性、基础性和约束性规划,对于后来的国土空间开发具有重大战略意义。
|
||||||
|
|
||||||
|
主体功能区划是我国首创的一种国土空间开发制度。作为一种重要的区域统筹协调发展的思想,主体功能区划基于“效率”与“公平”的规划目标,建立了空间类型和差异政策划分空间的方法,根据一个空间单元的自然资源禀赋、生态环境状况、经济社会发展水平及潜力、现有开发建设强度及情况等,并结合国家和区域未来的发展战略,综合确定该空间单元的主体功能定位,以及确定其开发利用方式,因地制宜,分级分类划分为不同的主体功能区,其主要的发展历程如下所示(见表3.2)。
|
||||||
|
|
||||||
|
表3. 2我国主体功能区发展历程表
|
||||||
|
|
||||||
|
\subsection{主体功能区的核心思想}
|
||||||
|
功能区原属于建筑学的概念,是指根据房屋内部空间的使用功能和各共有建筑部位的服务范围而划分的区域。主体功能区是功能区概念的延伸。其作用主要是促进各地区分工协作,形成合理的空间经济布局和结构,有效解决人与自然和谐发展问题。主体功能区的目的是统筹谋划未来中国人口分布、国土利用、城市化和经济布局,根据不同区域的资源环境承载力,现有开发密度和发展潜力,按区域协调、环境友好、资源节约等原则划定的具有某种主体功能的规划区域。《全国主体功能区规划》将中国国土空间分为4类(见表3.3)。
|
||||||
|
|
||||||
|
表3. 3全国主体功能区划基础划分表
|
||||||
|
|
||||||
|
\subsection{主体功能区的理论与技术方法}
|
||||||
|
为实现区域协调发展的目标,我国在主体功能区规划中将开发与保护的两个基本指标融入“主体功能”这一概念当中。将开发程度高的地区作为开发主导型区域,将保护程度高的作为保护主导型区域。在主体功能区规划传导市县发展规划,推动多规合一的过程中,核心内容就是如何合理准确的划分出城镇、农业、生态三类空间。具体分为以下五个步骤(见表3.4)。
|
||||||
|
|
||||||
|
表3. 4主体功能区划定步骤
|
||||||
|
|
||||||
|
\subsection{主体功能区的相关实践}
|
||||||
|
在国家颁布《全国主体功能区规划》之后,各省市也相应进行了各地的规划实践。作为国家层面的优化开发区域之一,广东省是全国较早开展主体功能区规划研究的省份,在区划方法、规划内容体系以及规划的贯彻与落实等方面进行了诸多探索与尝试。2009年底,广东省的《广东省主体功能区规划(2010-2020)》出台,既符合国家要求又体现广东的科学发展,既有战略高度又具备可操作性,对于其他省区的规划发展具有一定的示范意义。《广东省主体功能区规划(2010-2020)》在区划方法上总体遵循国家颁布的《省级主体功能区划分技术规程》,采用全国统一的10项指标进行综合评价,特别强调战略选择对于区划的决定作用,强化了国土开发战略格局的研究。规划内容体系上,强调规划的战略高度与可操作性,一方面,专门增加了国土开发战略格局的章节,对全省的国土开发总体格局以及城镇化、农业开发、综合交通、生态安全等进行了宏观的战略部署,另一方面,在主体功能区下进一步按流域和地域概念划分出若干片区,制定了各片区以及各地级市的开发指引,大大增强了规划的可操作性。为促进规划在市、县、区层面的贯彻与落实,省委省政府将清远市作为全省主体功能区规划试点,组织编制了清远市主体功能区规划实施纲要,以镇乡、街道为基本单元,进一步细化全市空间布局,并探索全省分类调控的政策经验;清远市的阳山县也围绕其限制开发区域(广东省称生态发展区域)的功能定位,开展了生态发展规划实践,探索了限制开发区生态发展的路径与政策保障,由此在全省形成了“ 省—地级市—县(区)”的主体功能区规划层级体系。
|
||||||
|
|
||||||
|
贵州省是我国西南地区较早进行主体功能区规划编制的省份,2011年《贵州省国民经济和社会发展第十二个五年规划纲要》明确要求按主体功能区划分和定位来科学规范空间开发。为转变贵州经济的发展方式,促进经济协调发展,贵州省于2013年7月发布了贵州省主体功能区规划方案。贵州省同样结合民族地域资源特点和空间差异,将主体功能区划分为重点开发、限制开发、禁止开发三类,其发展理念主要遵循科学开发理念、构建城镇化战略布局、促进农业战略格局形成、注重生态安全战略格局形成的思路展开。但是贵州省在主体功能区发展的过程中也出现了一些问题,如主体功能区规划与传统规划的重叠与冲突、转移支付不足及生态补偿机制缺失、不同主体功能区经济发展失衡冲突。为有效应对这些问题,贵州省完善了主体功能区划的制定标准,落实财政转移支付,建立生态补偿价格机制及积极探索协调主体功能区域之间的冲突关系。
|
||||||
|
|
||||||
|
2012年4月,黑龙江省印发首个省级主体功能区规划,标志着主体功能区规划进入了地方推进阶段,各省(区、市)于此后两年内陆续完成本省(市、区)主体功能区规划编制工作。《黑龙江省主体功能区规划》推动形成全省主体功能区,是贯彻落实科学发展观、加快老工业基地振兴的重大举措,是实现地区间公共服务均等化、构建和谐黑龙江的重要途径。规划根据省域内不同区域的资源环境承载能力、现有开发强 度和发展潜力,以县级行政区为基本单元,将全省国土空间按开发 方式划分为重点开发区域、限制开发区域和禁止开发区域;按开发 内容划分为城市化地区、农产品主产区和重点生态功能区;按层级 划分为国家层面和省级层面。确定各区域主体功能定位,明确开发 方向,控制开发强度,规范开发秩序,完善开发政策,推进形成人 口、经济、资源环境相协调的空间开发格局。黑龙江省主体功能区规划是全省国土空间开发的战略性、基础性和约束性规划,是推进形成主体功能区的基本依据,是科学开发全省国土空间的行 动纲领和远景蓝图。
|
||||||
|
|
||||||
|
\section{我国生态规划理论与实践}
|
||||||
|
随着19世纪工业革命的不断发展,周边乡村的居民不断涌入城市,导致城市规模持续扩大,人们过度重视生产效能的发展却忽视了对生态环境的保护以及二战之后过度发展经济开发资源更是忽略了生态环境的重要性。20世纪60年代后人们开始重视生态规划的建设及环境保护的问题,生态学引入大众的视野并受到广泛关注,至此之后生态思想在各个城市规划中均有体现。
|
||||||
|
|
||||||
|
\subsection{国内外城市生态理论研究与实践新动向}
|
||||||
|
国内外关于城市生态方面的理论和实践研究虽开始较晚,但是发展较快且研究内容也较为丰富。1981年苏联生态学家亚尼茨基提出了生态城市的理想城市模式,按照生态学原理将生态城市的设计和建造分为时间—空间、社会—功能、文化—历史三种知识层次,以及基础研究、应用研究、设计计划、建设实施和有机组织结构的形成5种行动阶段。他认为生态城市应是自组织的建设过程,应由原来的被动变为主动、自发地拥有生态环境意识并实施建造。《绿色城市》于1990年由高尔敦(David Gordon)出版,该书中收录了许多学者关于绿色城市的规划思想,目的在于想要探寻一条有效的能在城市实施的生态建设路径。
|
||||||
|
|
||||||
|
美国生态学家理查德·雷吉斯特是国际生态城市运动的创始人,1975年创建了“城市生态学研究会”,领导该组织在美国西海岸的伯克利开展了一系列的生态城市建设活动。他认为生态城市应该是三维的、一体化的复合模式,而不是平面的、随意的,同生态系统一样,城市应该是紧凑的,是为人类而设计的,而不是为汽车设计的,而且在建设生态城市中,应该大幅度减少对自然的“边缘破坏”,从而防止城市蔓延,使城市回归自然。
|
||||||
|
|
||||||
|
我国的生态城市与生态城市规划的研究起步较晚,涉及的学科领域有生态学科、城市规划学科、环境科学及其他领域。目前来看,我国生态城市规划的优点在于在城市规划中整合了中国文化相关的要素,努力打造中国特色的生态城市规划。早在 1984 年,马世骏与王如松先生针对当时生态环境问题日趋严重,人与自然的关系失调等问题,在国际上首次提出了社会—经济—自然复合生态系统理论,并指出城市与区域是以人的行为为主导、自然环境为依托、资源流动为命脉、社会文化为经络的社会—经济—自然复合生态系统。
|
||||||
|
|
||||||
|
20世纪90年代我国开展了有关“山水城市”的讨论。山水城市的内涵,既包含着丰富的自然生态内容、人文社会生态内容,视山水城市为“超大型园林”等内容,又包含着构想者把建筑科学列为与自然科学、社会科学并列的学科内容。张宇星于“城镇生态空间发展与规划理论”一文中阐述了城镇生态空间发展的一般运行机制,提出应从空间形态、状态、动态和进态几方面入手建立“大规划”的研究体系;胡俊认为生态城市强调通过扩大自然生态容量,调整经济生态结构,控制社会生态规模和提高系统自组织性等一系列规划手法来促进城市经济、社会、环境的协调发展。梁鹤年认为城市规划可以按照城市紧凑度的大小来进行相应规划,若城市形态紧凑,则应适当发展自然生态要素,按照自然生态的完整性进行,如果城市形态疏松,建设用地与绿网绿带交叉纵横,城市化可以按生态或社会各自的需要来进行规划。
|
||||||
|
|
||||||
|
\subsection{中国生态城市的理论与实践}
|
||||||
|
\subsubsection{1.我国生态文明建设的发展历程}
|
||||||
|
在新的历史条件下,我国生态文明建设的理论和实践不断发展与完善。党的十六大提出确立科学发展观战略与生态文明建设,党的十八大以来不仅提出建设美丽中国的目标,这将生态文明建设提高到了一个前所未有的高度;而且党中央领导集体高度关注生态文明建设,明确指出建立系统的生态文明制度,划定生态保护红线,建立资源有偿使用和生态补偿制度等,同时国家以及相关部门出台了相应的法规政策支持生态文明建设,具体的发展历程(见表3.5)。
|
||||||
|
|
||||||
|
表3. 5我国生态文明建设部分政策
|
||||||
|
|
||||||
|
\subsubsection{2.生态文明建设的发展内涵}
|
||||||
|
生态文明是人类文明发展的一个新的阶段,即工业文明之后的文明形态;生态文明是人类遵循人、自然、社会和谐发展这一客观规律而取得的物质与精神成果的总和;生态文明是以人与自然、人与人、人与社会和谐共生、良性循环、全面发展、持续繁荣为基本宗旨的社会形态。面对资源约束趋紧、环境污染严重、生态系统退化的严峻形势,必须树立尊重自然、顺应自然、保护自然的生态文明理念,走可持续发展道路。
|
||||||
|
|
||||||
|
生态文明建设其实就是把可持续发展提升到绿色发展高度,为后人“乘凉”而“种树”,就是不给后人留下遗憾而是留下更多的生态资产。生态文明建设是中国特色社会主义事业的重要内容,关系人民福祉,关乎民族未来,事关“两个一百年”奋斗目标和中华民族伟大复兴中国梦的实现。党中央、国务院高度重视生态文明建设,先后出台了一系列重大决策部署,推动生态文明建设取得了重大进展和积极成效。
|
||||||
|
|
||||||
|
\subsubsection{3.中国生态城市建设的理论与方法}
|
||||||
|
我国的生态城市建设过程中逐步形成了属于自己的理论和技术体系,我国现代城市生态规划受启于西方,一直在引介西方先进理念和技术,在这一过程中,城市规划学、景观生态学、地理学、社会学、经济学、管理学等学科领域的知识与方法不断融入,成为现代城市生态建设的理论武器和实践工具。我国近年来已逐渐开始摸索自己的城市生态建设道路。“低碳生态城市”和“生态文明建设”既是本土化生态规划路径的重要内容,也体现了中国城市生态规划与国家发展战略的紧密关联性——这既是我国城市生态规划的特色,也是其生命力所在。
|
||||||
|
|
||||||
|
21世纪初,开始有学者注意到城市规划与生态规划融合的必要性,提出了城市规划生态学化的涵义,并对城市规划与城市生态规划的关系进行了探讨。一些学者开始在城市规划与生态规划融合的方向上作出努力。传统城市规划的价值标准和功能设置在改变,过程与方法也逐渐纳入生态容量和生态足迹等分析,城市生态规划开始向具有政策性、法规性属性的成果转变。
|
||||||
|
|
||||||
|
\subsubsection{4.中国生态城市的发展实践}
|
||||||
|
在实践层面上,我国的城市生态规划实践脱胎于城市绿地系统规划。1980年代至今,“城市绿地系统”是唯一一项基于生态理念和目标的法定规划,对于合理配置城市生态空间、改善城市生态环境起到了重要的作用。
|
||||||
|
|
||||||
|
进入21世纪后,出现了更多的城市生态规划类型,如城镇群、生态控制线、生态带、生态网络、生态功能区、非建设用地、新城及新区、街区、社区、大学城、商务区、工业园区、空港城等生态规划,或针对某一种景观类型,如城市森林、水域、湿地、流域、绿化隔离带等所做的生态规划。这些新的规划类型可能在当时尚不成熟也不成系统,但它们从不同角度对城市生态规划体系进行了探索,对我国城市生态规划的发展具有深刻的影响。
|
||||||
|
|
||||||
|
近期生态规划研究和实践开始将生态理念与控规运行体系相结合,从指标体系的角度对生态理念进行量化和细化;也有研究和实践分别从生态社区、绿色基础设施、绿色建筑等不同层面出发,从更为具体的微观视角探讨生态规划的实现方式。城市生态规划的全域视角为城市生态环境的改善起到了积极的作用。
|
||||||
|
|
||||||
|
我国较早的城市生态规划实践强调规划管理的刚性控制,即“寸土不让”地守住“红线”,管理手段具有强制性色彩,有时会造成极为激烈的矛盾冲突。例如:深圳基本生态控制线规划的实施初期实行“铁腕手段”,两年间采取了2000余次清拆行动,对于查违队伍也进行严格整治,监管不力者予以通报批评或辞退撤换,在行政责任追求方面一查到底。但结果是违法建筑屡禁不止,甚至引起了部分基层组织和个人的不满,造成对基本生态控制线的排斥和对抗。到了2010年代,屡禁不止的违章建筑和层出不穷的管理问题迫使研究者和管理者对“一刀切”的刚性管理模式进行反思,积极探索保护与经济发展共赢之路,将社区经济效益、基层民众诉求和生态补偿机制等弹性思维与弹性管制纳入考虑,尊重社区发展权,建设自下而上的反馈渠道,制定社区发展计划,重点解决民生问题。从刚性控制到弹性管制,表征了我国对城市生态规划内涵的认识经历了一个全面蜕变优化的过程。
|
||||||
|
|
||||||
|
江西省宜春市是我国第一个生态城市的试点,采用了复合的生态建设规划,它以复合生态系统学说、智力圈学说、环境科学学说为理论,以生态工程为方法,以系统工程为手段,结合经济建设、社会发展、生态保护等方面,构建一个调控自如的市政范围的复合。
|
||||||
|
|
||||||
|
\section{多规合一与国土空间规划}
|
||||||
|
习近平总书记提出:“一个城市首先看规划,规划科学是最大的效益、规划失误是最大的浪费,规划折腾是最大的忌讳。”规划是龙头,是履行政府行政职能的重要手段,是政府科学决策、可持续发展的重要保障。但长期以来由于规划政出多门,规划矛盾冲突、各类规划中存在体系混乱、层次不清,同类规划上行下效,部门规划相互矛盾,专项规划各行其是,致使规划出现编制难、实施难、考核难的多重困境。
|
||||||
|
|
||||||
|
我国发展已进入新时代,踏上新征程。传统规划方式方法、体系路径不能解决的实际问题,需要系统进行解决。我国在体制机制、体系规范、评价方法等方面进行了“多规合一”的尝试。
|
||||||
|
|
||||||
|
\subsection{“多规合一”的历程}
|
||||||
|
2003年,国家发展和改革委员会在苏州市、宜宾市、宁波市等6个城市开启规划体制改革试点工作,将国民经济和社会发展规划、城市总体规划、土地利用规划三个规划落实到一个共同的空间规划平台上。之后上海市、广州市、武汉市等城市相继开展“两规合一”、“三规合一”的探索,主要探索城乡规划、土地利用规划的融合协调,与此同时部分城市进行了规划国土相关部门的合并。
|
||||||
|
|
||||||
|
2013年11月,空间规划体系改革纳入十八届三中全会,出台了《关于全面深化改革若干重大问题的决定》。2013年12月,习近平总书记首次在中央城镇化工作会议上提出,要积极推进市、县规划体制改革,探索能够实现“多规合一”的方式方法,一个市县一本规划、一张蓝图,并以这个为基础,把一张蓝图干到底。
|
||||||
|
|
||||||
|
2014年8月,由国家发改委、国土资源部、环境保护部、住房和城乡建设部四部委联合下发《关于开展市县“多规合一”试点工作的通知》,明确了开展试点的主要任务及措施,并提出在全国28个市县开展“多规合一”试点。
|
||||||
|
|
||||||
|
2017年,国家启动开展了9个省级空间规划试点,提出以主体功能区规划为基础统筹各类空间性规划,推进“多规合一”,明确以主体功能区规划为基础作用和各类空间性规划作为合一对象。
|
||||||
|
|
||||||
|
2018年4月,自然资源部成立,作为统一管理山水林田湖草等全民所有自然资源资产的部门,国土空间规划体系确立,提出将主体功能区规划、土地利用规划、城乡规划等空间规划融合为统一的国土空间规划,也就是以上几个空间性规划的“多规合一”,国土空间规划最终正名。
|
||||||
|
|
||||||
|
纵观我国“多规合一”发展历程,多规合一试点总结阶段可以概括为三个阶段,2003—2012年为探索试点阶段,着重考虑的是战略布局和用地管控,但如何划定红线、搭建技术平台等尚未出台;2013—2015年为正式试点阶段,提出一张蓝图干到底,坚持实施主体功能区制度,落实生态空间用途管制,突出资源环境承载能力,建立规划协调机制,建立控制线体系,形成一本规划、一张蓝图;2016—2018年为深化试点阶段,多规合一试点范围逐步扩大,空间规划改革逐步展开,改革内容不断深化。
|
||||||
|
|
||||||
|
\subsection{“多规合一”的目标}
|
||||||
|
“多规合一”是在一级政府一级事权下,强化国民经济和社会发展规划、城乡规划、土地利用规划、环境保护、文物保护、林地与耕地保护、综合交通、水资源、文化与生态旅游资源、社会事业规划等各类规划的衔接,确保“多规”确定的保护性空间、开发边界、城市规模等重要空间参数一致,并在统一的空间信息平台上建立控制线体系,以实现优化空间布局、有效配置土地资源、提高政府空间管控水平和治理能力的目标。(见表3.6)
|
||||||
|
|
||||||
|
表3. 6“多规合一”主要目标
|
||||||
|
|
||||||
|
\subsection{多规合一的政策演进}
|
||||||
|
表3. 7“多规合一”政策演进
|
||||||
|
|
||||||
|
\subsection{“多规合一”与国土空间规划}
|
||||||
|
2018年《中共中央国务院关于统一规划体系更好发挥国家发展规划战略导向作用的意见》,明确了“三级四类”的规划体系,在我国规划发展史上具有里程碑的意义。新规划体系下的国土空间规划,需要以发展规划为上位遵循,落实发展规划的战略目标和重大战略任务,强化国土空间规划在新规划体系中的重要作用以及在自然资源保护开发利用上的刚性管控和指导约束作用,为发展规划确定的重大战略任务落地实施提供空间保障,并对其他规划提出的基础设施、城镇建设、资源能源、生态环保等开发保护活动提供指导和约束。
|
||||||
|
|
||||||
|
从2014年市县“多规合一”试点的多种规划合一, 到2017年省级空间规划试点的以主体功能区规划为基础的空间性规划合一,再到2019年《若干意见》将主体功能区规划、土地利用规划、城乡规划等空间规划融合为统一的国土空间规划,按照“五级三类四体系”的要求建立国土空间规划体系,国土空间规划的正名标志着我国空间发展和空间治理进入了生态文明新时代、规划体制改革进入了建立空间规划体系的新时期、国土空间规划体系建立进入了落地实施的新阶段。
|
||||||
|
|
||||||
|
第3章 城乡空间规划思想与理论发展 39
|
||||||
|
|
||||||
|
3.1我国城乡规划思想与理论发展 39
|
||||||
|
|
||||||
|
3.1.1中国古代城市规划思想与理论 39
|
||||||
|
|
||||||
|
3.1.2中国近代城市规划思想与理论 41
|
||||||
|
|
||||||
|
3.1.3新中国成立后城市规划思想与理论 42
|
||||||
|
|
||||||
|
3.2西方城市规划思想与理论发展 46
|
||||||
|
|
||||||
|
3.2.1西方古代城市规划思想与理论 46
|
||||||
|
|
||||||
|
3.2.2西方近代城市规划思想与理论 49
|
||||||
|
|
||||||
|
3.2.3 20世纪以来西方现代城市规划思想与理论 51
|
||||||
|
|
||||||
|
3.2.4 21世纪的城市规划思想与理论变革 57
|
||||||
|
|
||||||
|
3.3我国土地利用规划理论与实践 58
|
||||||
|
|
||||||
|
3.3.1第一轮土地利用总体规划(1986-1996年) 59
|
||||||
|
|
||||||
|
3.3.2第二轮土地利用总体规划(1996-2006年) 59
|
||||||
|
|
||||||
|
3.3.3第三轮土地利用总体规划(2006一2020年) 59
|
||||||
|
|
||||||
|
3.4我国主体功能区规划理论与实践 60
|
||||||
|
|
||||||
|
3.4.1主体功能区的发展历程 60
|
||||||
|
|
||||||
|
3.4.2主体功能区的核心思想 61
|
||||||
|
|
||||||
|
3.4.4主体功能区的理论与技术方法 61
|
||||||
|
|
||||||
|
3.4.5主体功能区的相关实践 62
|
||||||
|
|
||||||
|
3.5我国生态规划理论与实践 63
|
||||||
|
|
||||||
|
3.5.1国内外城市生态理论研究与实践新动向 63
|
||||||
|
|
||||||
|
3.5.2中国生态城市的理论与实践 64
|
||||||
|
|
||||||
|
3.6多规合一与国土空间规划 66
|
||||||
|
|
||||||
|
3.6.1“多规合一”的历程 67
|
||||||
|
|
||||||
|
3.6.2“多规合一”的目标 67
|
||||||
|
|
||||||
|
3.6.3多规合一的政策演进 68
|
||||||
|
|
||||||
|
3.6.4“多规合一”与国土空间规划 69
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 步骤 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 调查 & 以伦敦为对象展开了综合而深入的城市问题及原因调查 \\
|
||||||
|
2 & 分析 & 利用城市和乡村两者的优点形成一种新的城市形态——“田园城市” \\
|
||||||
|
3 & 观念 & 当城市达到一定规模以后应该停止增长,成为更大体系中的一部分,过量部分由附近的另一城市来接纳,即形成多中心复合的城镇群 \\
|
||||||
|
4 & 模式 & 包括城市和乡村两个部分:边缘地区设有工厂、企业,每个田园城市的人口限制在3万人,中心城市为5万—6万人,一组城市的总人口规模为25万人左右。若干个田园城市围绕着中心城市呈圈状布置,之间借助铁路等便捷往来,城市之间是永久性保留的绿色空间 \\
|
||||||
|
5 & 措施 & 工商业要赋予私营经济发展的条件,不能由公营垄断;城市中的所有土地必须归全体居民集体所有,城市的收入全部来自租金,在土地上产生的增值仍归集体所有 \\
|
||||||
|
6 & 实践 & 1899年组织了田园城市协会宣传他的主张。1903年组建了“田园城市有限公司”,建立了第一座田园城市——莱彻沃斯 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 发布日期 & 文件名称 & 发布部门 & 发布意义 \\
|
||||||
|
\hline
|
||||||
|
1 & 2005.10.11 & 《中共中央关于制定国民经济和社会发展第十一个五年规划的建议》 & 中共中央 & 提出了主体功能区的思想,各地区要根据资源环境承载能力和发展潜力,按照优化开发、重点开发、限制开发和禁止开发的不同要求,明确不同区域的功能定位,并制定相应的政策和评价指标,逐步形成各具特色的区域发展格局 \\
|
||||||
|
2 & 2006.03.14 & 《中华人民共和国国民经济和社会发展第十一个五年规划纲要》 & 全国人大 & 第一次提出了主体功能区的概念,即将国土空间划分为优化开发、重点开发、限制开发和禁止开发四类主体功能区,按照主体功能定位调整完善区域政策和绩效评价,规范空间开发秩序,形成合理的空间开发结构 \\
|
||||||
|
3 & 2011.06.08 & 《全国主体功能区规划》 & 国务院 & 是我国国土空间开发的战略性、基础性和约束性规划,对于推进形成人口、经济和资源环境相协调的国土空间开发格局具有重要意义 \\
|
||||||
|
4 & 2017.10.26 & 《关于完善主体功能区战略和制度的若干意见》 & 中共中央 & 进一步明确了主体功能区的科学内涵,即主体功能区既是一种战略,又是一种制度,为之后的主体功能区规划发展提供了相关保障 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 开发类型 & 适用范围 & 解决方式 \\
|
||||||
|
\hline
|
||||||
|
1 & 优化开发区域 & 是指经济比较发达,人口比较密集、开发强度较高、资源环境问题比较突出的区域 & 向重点开发区域转移产业,减轻人口、资源大规模跨区域流动和生态环境的压力 \\
|
||||||
|
2 & 重点开发区域 & 是指有一定经济基础、资源环境承载能力较强、发展潜力较大、集聚人口和经济条件较好的区域 & 促进产业集群发展,增强承接限制开发和禁止开发区域超载人口的能力 \\
|
||||||
|
3 & 限制开发区域 & 分为两类,一类是农产品主产区,即耕地较多、农业发展条件较好的区域;一类是重点生态功能区,即生态系统脆弱或生态功能重要,资源环境承载能力较低的区域 & 农产品主产区必须把增强农业综合生产能力作为发展的首要任务的区域;重点生态功能区必须把增强生态产品生产能力作为首要任务的区域 \\
|
||||||
|
4 & 禁止开发区域 & 是依法设立的各级各类自然文化资源保护区域,以及其他禁止进行工业化城镇化开发,需要特殊保护的重点生态功能区 & 限制开发、禁止开发区域要通过生态建设和环境保护,提高生态环境承载能力,逐步成为全国或区域性的生态屏障和自然文化保护区域 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 步骤 & 具体内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 划定生态空间 & 和环保部门划定生态空间,包括生态保护红线及禁止开发区域,同时考虑生态缓冲区域,具有水质净化功能的重要湿地(大水面)以及重要生态功能区域联系通道都划入生态空间中,加以保护 \\
|
||||||
|
2 & 空间开发适宜性评价 & 进行空间开发适宜性评价,基于地形图构建基础地理信息数据库,以网格为单元,开展资源环境承载力、发展潜力评价,对空间开发适宜程度进行分类 \\
|
||||||
|
3 & 初划城镇空间 & 根据资源环境承载力评价和人口及用地情景初划城镇空间,设定城镇开发的禁止边界(政区边界、50米等高线、水库基线等),门槛边界(一般河流、交通干道和铁路)和弹性边界 \\
|
||||||
|
4 & 城市总体规划方案调整 & 城市总体规划方案调整,规模城市建设用地压缩,对规划城镇用地进行相应布局调整 \\
|
||||||
|
5 & 土地利用规划允许建设区和有条件建设区调整 & 结合国土部门土地利用规划调整完善和永久基本农田划定工作,依据发展规划和城市总体规划确定的城镇空间范围调整允许建设区和有条件建设区边界,根据与各类规划的协调,与各板块的对接和征求意见,形成城镇、农业、生态三类空间布局总图 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 发布日期 & 文件名称 & 发布部门 & 发布意义 \\
|
||||||
|
\hline
|
||||||
|
1 & 2011.10.17 & 《国务院关于加强环境保护重点工作的意见》(国发〔2011〕35号) & 国务院 & 这是我国首次以国务院文件形式出现“生态红线”概念并提出划定任务 \\
|
||||||
|
2 & 2013.05.24 & 《坚持节约资源和保护环境基本国策,努力走向社会主义生态文明新时代》 & 中共中央 & 提出要按照人口资源环境相均衡、经济社会生态效益相统一的原则,整体谋划国土空间开发,给自然留下更多修复空间,要牢固树立生态红线的观念 \\
|
||||||
|
3 & 2013.11.12 & 《中共中央关于全面深化改革若干重大问题的决定》 & 中共中央 & 将划定生态保护红线提升为国家战略 \\
|
||||||
|
4 & 2015.04.25 & 《中共中央国务院关于加快推进生态文明建设的意见》 & 中共中央 & 提出把生态文明建设放在突出的战略位置,以健全生态文明制度体系为重点,优化国土空间开发格局,加大自然生态系统和环境保护力度 \\
|
||||||
|
5 & 2015.08.17 & 《党政领导干部生态环境损害责任追究办法(试行)》 & 中共中央 & 对于违反主体功能区定位或者突破资源环境红线、城镇开发边界、不顾资源环境承载能力盲目决策造成严重后果的实行生态环境损害责任终身追责制 \\
|
||||||
|
6 & 2015.09.21 & 《生态文明体制改革总体方案》 & 中共中央 & 提出平衡好发展和保护之间的关系,按照主体功能定位控制开发强度,调整空间结构 \\
|
||||||
|
7 & 2017.02.07 & 《关于划定并严守生态保护红线的若干意见》 & 中共中央 & 文件提出了生态保护红线的总体要求和内容以及组织保障体系 \\
|
||||||
|
8 & 2020.03.03 & 《关于构建现代环境治理体系的指导意见》 & 中共中央 & 提出了构建现代环境治理体系的准则 \\
|
||||||
|
9 & 2021.10.20 & 《关于进一步加强生物多样性保护的意见》 & 中共中央 & 提出要持续优化生物多样性保护空间格局,推进重要生态系统保护与修复及完善生物多样性迁徙保护体系 \\
|
||||||
|
10 & 2021.10.21 & 《关于推动城乡建设绿色发展的意见》 & 中共中央 & 指出我国要促进区域和城市群绿色发展,建立人与自然和谐共生的美丽城市和乡村 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 主要目标 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 完成“一张蓝图” & 统一城市发展目标、发展战略及功能布局,深入梳理并协调消除市县各类规划之间的矛盾,实现市县域“多规合一”,将各类生态管控红线、城乡建设和产业区块、基础设施和公共服务设施、重大项目用地和历史文化保护范围等落实到一张蓝图上 \\
|
||||||
|
2 & 构筑管理“一个平台” & 搭建区域统一的信息共享和管理平台,利用卫星遥感等技术实现与规划信息平台结合,对开发边界、自然资源和生态环境状况进行全天候的监测,推动综合执法,为简化行政审批提供重要支撑 \\
|
||||||
|
3 & 推行审批“一张表格” & 按照一张蓝图规划、依托一个平台管理,转变审批理念,简化项目审批程序,实施审批制度改革,实现“一份办事指南、一张申请表单、一套申报材料”完成审批 \\
|
||||||
|
4 & 完善配套“运行机制” & 建立法律保障机制,将多规合一划定的生态红线、建设用地增长边界等控制线纳入地方立法;以政府规章形式明确“多规合一”控制线管理主体、管理办法,统一技术标准。完善相关配套机制,建立部门业务联动制度、优化建设项目审批制度、建立监控考核制度、建立动态更新维护制度,改进绩效激励机制 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 日期 & 法规与会议名称 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 1991年1月 & 《中华人民共和国土地管理法》 & 对经济社会发展规划、城乡规划、水利规划与土地利用总体规划的协调进行了规定 \\
|
||||||
|
2 & 2008年1月 & 《中华人民共和国城乡规划法》 & 对城乡规划与其他规划的协调进行了规定 \\
|
||||||
|
3 & 2010年12月 & 《中华人民共和国水土保持法》 & 明确了水土保持规划应当与土地利用总体规划、水资源规划、城乡规划和环境保护规划等相协调 \\
|
||||||
|
4 & 2013年11月 & 《中共中央关于全面深化改革若干重大问题的决定》 & 要求建立空间规划体系,划定生产、生活、生态空间开发管制界线,落实用途管制 \\
|
||||||
|
5 & 2014年3月 & 《国家新型城镇化规划(2014-2020年)》 & 加强城市规划与经济社会发展、主体功能区建设等规划的相互衔接,推动“多规合一” \\
|
||||||
|
6 & 2014年8月 & 《关于开展市县“多规合一”试点工作的通知》
|
||||||
|
(发改规划[2014]1971号) & 从全国范围内遴选确定了28个“多规合一”试点市县 \\
|
||||||
|
7 & 2015年5月 & 《2015年深化经济体制改革重点工作意见》(国发〔2015〕26号) & 要完善国土空间开发,加强生态文明制度顶层设计,开展市县“多规合一”试点 \\
|
||||||
|
8 & 2015年10月 & 《生态文明体制改革总体方案》 & 要构建国土空间开发保护制度和空间规划体系;要整合各类空间性规划,编制统一的空间规划;要支持市县推进“多规合一”,明确开发边界和保护边界 \\
|
||||||
|
9 & 2015年12月 & 中央城市工作会议 & 要以主体功能区规划为基础,统筹各类空间性规划,推进“多规合一” \\
|
||||||
|
10 & 2016年2月 & 《中共中央国务院关于进一步加强城市规划建设管理工作的若干意见》 & 要推进两图合一;要实现一张蓝图干到底 \\
|
||||||
|
11 & 2016年3月 & 《2016年深化经济体制改革重点工作意见》(国发〔2016〕21号) & 以主体功能区规划为基础统筹各类空间性规划,推进“多规合一” \\
|
||||||
|
12 & 2016年7月2日 & 《中华人民共和国水法》 & 流域综合规划和区域综合规划以及与土地利用关系密切的专业规划,应当与国民经济和社会发展规划等相协调 \\
|
||||||
|
13 & 2016年12月 & 《“十三五”生态环境保护规划》(国发〔2016〕65号) & 要强化“多规合一”的生态环境支持,积极推动建立国家空间规划体系,统筹各类空间规划,推进“多规合一” \\
|
||||||
|
14 & 2017年2月 & 《全国国土规划纲要(2016-2030年)》(国发〔2017〕3号) & 要统筹各类空间性规划,推进“多规合一”,要编制国家级、省级国土规划,并与城乡建设等规划相协调,要推动市县层面“多规合一” \\
|
||||||
|
15 & 2017年4月 & 《国务院办公厅关于同意建立省级空间规划试点工作部际联席会议制度的函》(国办函〔2017〕34号) & 同意建立由国家发改委牵头的省级空间规划试点工作部际联席会议制度 \\
|
||||||
|
16 & 2019年5月9日 & 《关于建立国土空间规划体系并监督实施的若干意见》(中发〔2019〕18号) & 从七大方面阐述了国土空间规划体系构建与监督实施的总体安排,是开展国土空间规划的国家层面上的政策文件 \\
|
||||||
|
17 & 2019年07月18日 & 《关于开展国土空间规划“一张图”建设和现状评估工作的通知》自然资办发〔2019〕38 号 & 依托国土空间基础信息平台,全面开展国土空间规划“一张图”建设和市县国土空间开发保护现状评估工作 \\
|
||||||
|
18 & 2019年9月17日 & 《自然资源部关于以“多规合一”为基础推进规划用地 “多审合一、多证合一”改革的通知》(自然资规[2019]2号) & 合并规划选址和用地预审,合并建设用地规划许可和用地批准,推进多测整合、多验合一,简化报件审批材料 \\
|
||||||
|
19 & 2020年5月22日 & 《关于加强国土空间规划监督管理的通知》(自然资办发〔2020〕27号) & 切实把“多规合一”改革精神落到实处。在多规合一基础上全面推进规划用地“多审合一,多证合一” \\
|
||||||
|
20 & 2021年9月1日 & 《中华人民共和国土地管理法实施条例》 & 国家建立国土空间规划体系。经依法批准的国土空间规划是各类开发、保护、建设活动的基本依据 \\
|
||||||
|
21 & 2021年09月27日 & 《国土空间规划技术标准体系建设三年行动计划(2021—2023年)》(自然资发〔2021〕135号) & 加快建立国土空间规划技术标准体系,研制一批标准,创新标准制定工作机制 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,774 @@
|
|||||||
|
\chapter{国土空间规划的技术工具与方法}
|
||||||
|
技术工具与方法是国土空间规划的基础,通过调查勘测、系统评价、预测和标准化的制度是开展国土空间规划的前提,在此基础上运用以土地信息技术和遥感技术,实现空间决策支持及数据分析是国土空间准确、详实目的的必要手段,将各阶段、各级工作成果以“一张图”和与之相关的管理信息平台建设相结合,是国土空间体系规范化和标准化的保障。
|
||||||
|
|
||||||
|
\section{国土空间规划基础方法}
|
||||||
|
\subsection{调查勘测方法}
|
||||||
|
\subsubsection{1.调查勘测主要类型}
|
||||||
|
各级各类现状情况调查、分析研究是各类空间规划工作最基础性的步骤。在国土空间规划战略及体系规划中,同样各级各类要对开发利用现状信息资料进行分析和研究。国土空间规划量大面广,涉及土地、城市、乡村、田野等各类空间及社会、政治、经济等多个领域,它主要包括以下几种类型(见表5.1)。
|
||||||
|
|
||||||
|
表5. 1调查勘测类型表
|
||||||
|
|
||||||
|
\subsubsection{2.国土资源信息调查}
|
||||||
|
国土信息资源调查包括内容,来源和使用方式三方面工作。
|
||||||
|
|
||||||
|
(1)国土资源信息调查的内容(见表5.2)。
|
||||||
|
|
||||||
|
表5. 2国土资源信息调查内容
|
||||||
|
|
||||||
|
(2)国土资源信息来源
|
||||||
|
|
||||||
|
国土资源信息来源丰富,类型多样,具体内容(见表5.3)。
|
||||||
|
|
||||||
|
表5. 3国土资源信息来源
|
||||||
|
|
||||||
|
(3)国土资源信息的使用原则方式
|
||||||
|
|
||||||
|
国土空间规划阶段的国土资源信息获取,主要不应采取实地调查、实时监测、测绘勘探等一切从零开始的手段进行,而应主要采取收集、汇总、分析各有关部门、行业、单位和个人的调查研究成果的方式进行。但是,对于国土空间规划的底线控制指标,必须采取实地调查勘测的方法进行落地。例如,永久基本农田划定,就需要应用土地利用现状调查成果,建立已有基本农田划定成果与土地利用现状调查成果对应关系,将基本农田保护专题信息落到土地利用现状调查成果上,由基本农田划定部门进行核实、认定:依据土地利用总体规划成果,确定拟调出、调入的地块,并到实地察看定界,应用农用地分等成果,核实拟调出、调入基本农田的空间位置、数量、质量等级、地类等现状信息。
|
||||||
|
|
||||||
|
\subsection{系统评价方法}
|
||||||
|
\subsubsection{1.概述}
|
||||||
|
(1)新时期国土空间规划的技术要求
|
||||||
|
|
||||||
|
在旧的规划体系下,城乡、主体功能区和生态功能区等不同的规划由不同的部门负责编制,经常出现规划目标相抵触、内容相矛盾等问题。对于“多规”衔接复杂、部门协调困难、规划立法薄弱等难题。随着国家机构改革方案颁布、自然资源部成立,落实的一系列新规指引规划行业朝变更与发展的新方向迈进,规划学科正在经历前所未有的由传统工程学科向新型交叉学科、由单一主导向多规合一、由面状规划向空间规划转变的变革期(如图5.1所示)。
|
||||||
|
|
||||||
|
在国家空间规划体系改革和规划管理机构调整的背景下,进行国土空间规划基础知识教育,有助于了解学科发展的动态和方向俨然已成为相关学科教育的刚性需求。
|
||||||
|
|
||||||
|
学科发展带来了一系列知识体系上的变更,传统的城市规划已经不能满足当下以生态文明建设为主导的空间规划需求,规划类教育体系需要顺应时代的发展进行相应的调整与革新;规划类学科应当以学科知识体系的更新为基础拓展学科边界,在课程中渗透学科交叉的范畴。接受多元化的背景知识教育,才有助于拓宽学术眼界,丰富知识储备;行业的变革将不止于学术,逐步渗透到社会各个层面。
|
||||||
|
|
||||||
|
\textbf{图}\textbf{5.}\textbf{ }\textbf{1}\textbf{国土空间规划}\textbf{评价流程内容解析(}\textbf{笔者自会)}
|
||||||
|
|
||||||
|
(2)技术评价体系的重要作用与意义
|
||||||
|
|
||||||
|
从规划层级体系上来说,以前主体功能区规划、城市规划、土地利用规划、乡村规划和各类专项规划等均涉及国土空间规划内容,统一的新的国土空间规划体系将其全部整合为统一的国土空间规划,分为全国、省、市、县、乡镇五级,真正建立了从全国到省、市、县、乡镇国土空间规划管控的完整规划体系。
|
||||||
|
|
||||||
|
《省级国土空间规划编制指南》和《市级国土空间总体规划编制指南》中对国土空间规划评价体系不同层级适用的评价方法做出了详细的分类和说明,这些不同的评价方法共同完善了国土空间规划评价体系。评价体系是国土空间规划的重要环节,是优化国土空间开发格局、合理布局建设空间的依据,也是对国土空间规划成果的支撑和预判。
|
||||||
|
|
||||||
|
(3)国空评价体系的主要内容
|
||||||
|
|
||||||
|
省级国土空间规划评价体系中包括三方面的评价分析:生态功能重要性评价、农业功能适宜性评价和城镇建设适宜性评价;市级国土空间规划评价体系包括三项,其中两项(资源环境承载力评价和国土开发适宜性评价)属于国土空间规划“双评价”的范畴,另外还包括灾害风险评估。
|
||||||
|
|
||||||
|
\subsubsection{2.省级国土空间规划的评价方法}
|
||||||
|
通过资源环境承载力和国土空间开发适宜性评价,分析区域资源环境禀赋特点,识别省域重要生态系统,明确生态功能极重要和极脆弱区域,提出农业生产、城镇发展的承载规模和适宜空间。具体评价方法及评估流程详见市级国土空间评价技术。
|
||||||
|
|
||||||
|
(1)生态功能重要性评价
|
||||||
|
|
||||||
|
主要内容
|
||||||
|
|
||||||
|
国土空间生态功能指生态系统与生态过程形成的、维持人类生存的自然条件及其效用,包括气候调节、水调节、土壤保持等。生态功能重要性指生态系统在发挥这些功能时的重要程度。生态功能重要性评价为全域评价,从生态保护底线、生态系统服务重要性、生态敏感性和生态修复必要性等四个方面评价生态功能重要性。其中,生态保护底线是生态功能重要性最高等级,而生态系统服务重要性、生态敏感性和生态修复必要性则根据其程度来进行重要性的评估。
|
||||||
|
|
||||||
|
作用与适用范围
|
||||||
|
|
||||||
|
生态功能重要性评价是对自然生境作出评价、实施措施的重要依据,是国土空间规划对生态系统与生态过程演进所作出的重要回应,对于构建景观生态安全格局、维持用地平衡、协调公共发展与自然关系有着重要的意义和作用。
|
||||||
|
|
||||||
|
(2)农业功能适宜性评价
|
||||||
|
|
||||||
|
主要内容
|
||||||
|
|
||||||
|
国土空间的农业功能是指以利用土地资源为生产对象,培育动植物产品从而生产食品及工业原料的一种功能。农业功能适宜性指农业空间构建过程中不同土地用于农业生产功能的适合程度。
|
||||||
|
|
||||||
|
农业功能适宜性评价是指农业生产适宜性,重点对耕地、园地、牧草地和其他适合农业种植业生产的土地利用类型,考虑其现状土地利用情况,对农业耕作的适宜程度,再结合土壤污染、土层厚度、障碍层、坡度等对农业耕作的限制程度进行评价。最终,将全域国土空间划分为农业功能适宜、较适宜、较不适宜、不适宜四个等级。
|
||||||
|
|
||||||
|
作用与适用范围
|
||||||
|
|
||||||
|
农业功能适宜性评价最重要的意义在于在农业生产中协调环境与经济二者的平衡,对农业面源污染治理、发展模式探索、问题及对策、政策体系构建等方面都具有支撑和预判作用,对于农业发展与城市建设之间关联性的探索也有着重要的意义。
|
||||||
|
|
||||||
|
(3)城镇建设适宜性评价
|
||||||
|
|
||||||
|
主要内容
|
||||||
|
|
||||||
|
城镇建设适宜性指土地用于建设开发的适合程度。城镇建设适宜性为全域评价,主要从地形坡度、生态敏感性、岩土稳定性、矿山占用、地质灾害等方面考虑城镇开发建设的自然适宜性。
|
||||||
|
|
||||||
|
作用与适用范围
|
||||||
|
|
||||||
|
城镇建设适宜性评价最终目的是服务于城市发展中最关键一环的城镇化建设, 区分城市范围内不同区域的城镇建设适宜性等级。打造五位一体战略布局,落实绿色、共享、创新、协调和开放的发展理念。
|
||||||
|
|
||||||
|
(4)案例:广东省实践
|
||||||
|
|
||||||
|
以广东省为例介绍实践经验,结合广东省资源环境本底特征。落实国家重大战略,明确省域重点区域的引导方向和协调机制,综合考虑数据可获取性,精选指标,构建广东省指标体系。
|
||||||
|
|
||||||
|
在生态保护重要性评价方面,生态系统服务功能重要性评价采用水源涵养、水土保持、生物多样性维护和海岸防护等指标,不采用防风固沙指标;农业生产适宜性评价采用土地资源、水资源、土壤环境、光热资源和气象灾害等指标,不采用盐渍化指标,同时在土地资源和气象灾害指标评价中增加考虑石漠化地区土层厚度、沿海地区台风灾害危险性的影响;城镇建设适宜性评价采用土地资源、水资源、气候舒适度、环境、地质灾害危险性和区位优势度等指标,在环境指标中增加放射性偏高场所的影响。
|
||||||
|
|
||||||
|
\subsubsection{3.市级国土空间规划评价方法}
|
||||||
|
市级总规要体现综合性、战略性、协调性、基础性和约束性,落实和深化上位规划要求,为编制下位国土空间总体规划、详细规划、相关专项规划和开展各类开发保护建设活动、实施国土空间用途管制提供基本依据。
|
||||||
|
|
||||||
|
(1)资源环境承载力评价
|
||||||
|
|
||||||
|
基本概念与意义
|
||||||
|
|
||||||
|
资源环境承载力探讨的是人类及其社会经济活动与资源环境协调发展的关系。资源环境承载力源于早期生态学领域所提出的承载力概念,逐渐演变为反映资源环境本底和经济社会活动间交互程度的科学度量概念。资源环境承载力泛指在自然环境和生态系统不受危害的前提下,一定地域空间的资源禀赋和环境容量所能承载的人口与经济规模。也可以将资源环境承载力理解为:基于一定发展阶段、经济技术水平和生产、生活方式,一定地域范围内资源环境要素能够支撑的农业生产、城镇建设等人类活动的最大规模。开展资源环境承载力评价具有3方面重要意义(见表5.4)。
|
||||||
|
|
||||||
|
表5. 4资源环境承载力评价的重要意义
|
||||||
|
|
||||||
|
评价内容
|
||||||
|
|
||||||
|
根据资源环境承载主体的涵盖范围划分,可将承载力评价分为两类:第一类是以某一具体的自然要素作为研究对象,即单要素承载力评价,主要包括土地、水、环境、生态等;另一类则是从要素整合的角度出发进行的综合承载力评价,如区域承载力等。资源环境承载力具有地域性、限制性、外部性、非线性和不确定性等显著特征,且越来越强调综合性与系统性。
|
||||||
|
|
||||||
|
如今,资源环境承载力评价已不再是仅仅关注某一单项资源或单一环境要素约束的可承载水平,而是强调人类发展对区域资源开发与利用、生态退化与破坏、环境损益与污染等多维度的综合影响,即对资源环境承载力的综合评估与集成评估。
|
||||||
|
|
||||||
|
评价原理
|
||||||
|
|
||||||
|
资源环境承载力评价旨在衡量区域资源环境本底条件对人类特定生产、生活的承载水平,其科学基础一方面在于资源可得性、最大持续产量与资源支持力;另一方面则是环境容量、环境吸收或同化能力,以及环境支撑力。资源环境承载力评价将经济社会同人口、资源、生态环境予以集成,探索经济社会与资源环境要素间的相互作用机理,建立要素间的定量关系。
|
||||||
|
|
||||||
|
资源环境承载力评价的核心内容之一,在于评价指标体系构建。科学、合理的资源环境承载力评价指标体系,不仅应涵盖特定区域经济社会、自然环境、资源生态系统中诸多要素的现状,还可以在时间和空间维度上进行比较,反映区域环境承载力的变化状况,以辅助决策。国际学界和诸多机构都提出了具有代表性的评价体系,其中最具影响力的为联合国环境规划署所提出的集合驱动力、压力、状态、影响和响应五大概念框架的DPSIR 模型。同时,基于评价指标体系研发评价模型亦是资源环境承载力评价的重要方面,当前较为常用的方法理论包括多要素叠置分析法、比较法、短板原理法等(见表5.5)。
|
||||||
|
|
||||||
|
表5. 5资源环境承载力评价方法
|
||||||
|
|
||||||
|
评价流程与方法
|
||||||
|
|
||||||
|
资源环境承载力评价侧重于区域综合承载力评价,即在多要素单项评价的基础上,运用空间叠加、线性加权等方法展开资源环境承载力集成评价(见表5.6)。
|
||||||
|
|
||||||
|
表5. 6资源环境承载力评价流程与方法
|
||||||
|
|
||||||
|
(2)国土空间开发适宜性评价
|
||||||
|
|
||||||
|
基本概念与意义
|
||||||
|
|
||||||
|
所谓国土空间开发适宜性,是指在资源环境承载力评价的基础上,在维系生态系统健康可持续的前提下,综合考虑资源环境要素、区位条件以及经济社会发展情况等,判定具体国土空间进行农业生产、城镇建设等人类活动的适宜程度。我们也可以将国土空间开发适宜性评价理解为:依据国土空间的自然、生态和社会经济属性,评价国土空间对预定功能用途的适宜与否、适宜程度以及限制状况。
|
||||||
|
|
||||||
|
国土空间开发适宜性反映了人类对国土空间的开发和建设用地的占用,强调以土地空间承载的多宜性来满足人类对国土空间开发的多样化、多层次需求。主要包括三个方面的内涵(见表5.7)。
|
||||||
|
|
||||||
|
表5. 7国土空间开发适宜性评价内涵
|
||||||
|
|
||||||
|
评价方法
|
||||||
|
|
||||||
|
国土空间开发适宜性评价是面向国土空间用途管制的综合评价,鉴于此,国土空间开发适宜性评价具有空间尺度大、评价目标多两大特点。首先,国土空间开发适宜性评价往往以行政区全域为评价范围,涉及国家、省域、市域等大尺度空间,侧重于宏观尺度的评价;其次,国土空间开发适宜性评价是一种多维度、多目标的技术手段,是集成建设用地适宜性评价、农业用地适宜性评价、生态适宜性评价等多功能用途的全域国土空间的综合性评价。开展国土空间开发适宜性评价需先进行土地资源、水资源、环境、生态以及灾害等自然要素单项评价,以确定农业、城镇和生态等不同适宜功能的承载等级,并划分各功能的备选区域,最后综合各修正因素以确定不同的适宜性等级(如图5.2所示)。
|
||||||
|
|
||||||
|
国土空间开发适宜性评价旨在衡量国土空间对支撑人类生产、生活的适宜程度,同样基于指标选取和模型构建。一方面,国土空间开发适宜性指标体系具有综合性、多维度、系统性等特征;另一方面,随着地理信息系统(GIS)技术、人工智能算法等新兴模型方法的引入,国土空间开发适宜性评价方法呈现出多元化特征。国土空间开发适宜性评价主要包括以下三个方面(见表5.8)。
|
||||||
|
|
||||||
|
表5. 8国土空间开发适宜性评价内容
|
||||||
|
|
||||||
|
图5. 2双评价技术流程图(资源环境承载能力和国土空间开发适宜性评价指南)
|
||||||
|
|
||||||
|
应该注意的是,资源环境承载力评价、国土空间开发适宜性评价都不可能也不应该作为“精确科学”,其更重要的价值是作为一种我们必须尊重自然环境、追求空间适宜可持续用途的规划理念,其评价结果可以作为国土空间布局的重要参考,而不是僵化、固化的最终结论。
|
||||||
|
|
||||||
|
(3)“双评价”之间的内在关联
|
||||||
|
|
||||||
|
双评价间关系密不可分,既有价值取向的双向性,又有逻辑间的关联性。
|
||||||
|
|
||||||
|
“双评价”的差异性
|
||||||
|
|
||||||
|
从发展定位、管理取向和评价结果等方面来看,“双评价”之间存在着较大的差异性(见表5.9)。
|
||||||
|
|
||||||
|
表5. 9“双评价”的差异性比较
|
||||||
|
|
||||||
|
“双评价”关联性
|
||||||
|
|
||||||
|
同时,这两个评价在国空规划的体系中并列提及,说明“双评价”之间存在着密切的关联(见表5.10)。
|
||||||
|
|
||||||
|
表5. 10“双评价”的关联性分析
|
||||||
|
|
||||||
|
(4)案例:长沙市国土空间规划“双评价”
|
||||||
|
|
||||||
|
以长沙市实践成果为例。面对近年来由于经济快速发展带来的生态环境和资源问题,长沙市以摸清国土空间自然资源本底、为国土空间规划提供基础为目的,对“双评价”开展了探索。从查找问题和短板、确定空间开发保护格局、确定开发规模和空间、划定生态控制线及打造高品质生态空间等方面探索了“双评价”在国土空间规划中的应用。
|
||||||
|
|
||||||
|
首先,构建评价模型的基本框架(如图5.3所示);其次,构建指标体系。根据地方适宜性、根据地方适宜性、数据可获得性、典型代表性与科学性等原则进行调整,针对 3 个不同的功能指向选取具有代表性的指标;再次,分析评价结果。生态重要性区域占比较高,呈“两屏多点”的结构、农业开发适宜度较高,呈“一区多片”的结构、城镇开发适宜程度较高,呈“一区两翼”的空间形态:最后,充分发挥“双评价”结果在长沙市国土空间规划中的指导作用。
|
||||||
|
|
||||||
|
\textbf{图}\textbf{5.}\textbf{ }\textbf{3}\textbf{长沙市“双评价”技术路径}
|
||||||
|
|
||||||
|
(5)灾害风险评估
|
||||||
|
|
||||||
|
灾害评价主要表征区域灾害对农业生产和城镇建设的影响。灾害评估主要包括:选择气象灾害风险作为农业生产影响评价指标,通过干旱、洪涝、寒潮等灾害影响的大小和可能性综合反映(如图5.4所示);选择地质灾害危险性作为城镇建设影响评价指标,分别通过活动断层以及崩塌、滑坡、泥石流等地质灾害影响的大小和可能性综合反映(如图5.5所示);沿海地区还需进一步评价海洋灾害风险,并针对海洋牧场功能影响和滨海城镇建设影响分别遴选评价指标,通过海浪和海冰灾害危险性综合反映海洋灾害对海洋牧场功能的影响,通过风暴潮和海啸灾害危险性综合反映海洋灾害对滨海城镇建设功能的影响(如图5.6所示)。
|
||||||
|
|
||||||
|
图5. 4气象灾害评价步骤(作者自绘)
|
||||||
|
|
||||||
|
\textbf{图 }\textbf{5.5}\textbf{地质灾害评价步骤(作者自绘)}
|
||||||
|
|
||||||
|
\textbf{图 }\textbf{5.6}\textbf{海洋灾害评价步骤(作者自绘)}
|
||||||
|
|
||||||
|
\subsection{预测分析方法}
|
||||||
|
\subsubsection{1.预测分析的原理}
|
||||||
|
广义说预测,是人们对未来或不确定事件的行为和状态做出的主观判断,立足点是过去和现在,目标点是未来,狭义地说,预测的实质是预测者选择和使用一种逻辑结构使过去、现在与未来相通,以达到描述未来状态和特征的目的。预测的主要原理(见表5.11)。
|
||||||
|
|
||||||
|
表5. 11国土空间预测分析原理
|
||||||
|
|
||||||
|
在国土空间各类规划的预测过程中,每一项内容的预测、每一种方法的选择,都要看是否符合上述预测的基本原理,如果违背预测的基本原理,预测也可能会变成“陷阱”。
|
||||||
|
|
||||||
|
\subsubsection{2.预测分析的程序}
|
||||||
|
国土空间规划预测分为确定的预测任务、确定预测因素、搜集和审核资料、选择模型进行预测、误差分析与模型实验、预测结果等重要程序(如图5.7所示)。
|
||||||
|
|
||||||
|
图5.7预测分析程序
|
||||||
|
|
||||||
|
\subsubsection{3.预测分析的方法}
|
||||||
|
国土空间规划预测的内容主要包括:产业、就业、人口、住房、土地、基础设施、交通、绿色空间等。其中就业和人口预测是核心,知道了就业和人口,土地、住房、基础设施、交通需求等的预测就变得相对简单。而人口分布又与就业机会高度相关,因而就业预测就成为关键的关键。但就业机会与产业发展高度关联,因此在国土空间规划预测过程中,产业发展和就业机会的预测就变得更加重要。从产业和就业的角度看,预测方法可分为直观预测法、因果预测法和时间序列预测法。其中,直观预测法一般用于定性预测,而因果预测和时间序列预测法主要用于定量预测。在选择预测方法时,一般考虑6个基本要素:预测的应用范围、预测的资料性质、模型的类型、预测方法的精确度、适用性和使用预测费用。对于较为常用的预测方法从以上6个方面进行比较。
|
||||||
|
|
||||||
|
直观预测法
|
||||||
|
|
||||||
|
直观预测法一般用于定性预测,常用的有头脑风暴法、专家会议法、主观概率法、特尔菲法等。特尔菲预测法也称专家匿名调查征询法,是目前预测中使用最为广泛的定性预测法。特尔菲法的基本思路是由预测工作小组对每一轮意见进行整理汇总,并作为参考资料再发给每位专家,供他们分析判断,作为提出新一轮意见的参考依据,如此多次反复,专家的意见日趋一致,使结论的可靠性增大,从而取得满意的预测结果。
|
||||||
|
|
||||||
|
使用特尔菲法必须坚持如下三条原则:
|
||||||
|
|
||||||
|
第一条是匿名性。对被选中的专家要求保密,不让他们彼此互通信息,使他们不受权威、资历等方面的影响。
|
||||||
|
|
||||||
|
第二条是反馈性。在预测过程中,要进行几轮(3\textasciitilde{}5轮)征询专家意见。由于每一轮预测之间的反馈和信息沟通,可进行比较分析,因而能达到相关启发,提高预测准确度的目的。这样,征询过程通常都会呈现逐步收敛的趋势,容易集中各种正确的意见。
|
||||||
|
|
||||||
|
第三条是统计特性。特尔菲法的每次信息反馈,都要用数理统计方法进行整理分析。运用特尔菲法进行预测通常按以下程序进行(如图5.8所示)。
|
||||||
|
|
||||||
|
图 5.8特尔菲法进行预测流程
|
||||||
|
|
||||||
|
时间序列预测法
|
||||||
|
|
||||||
|
时间序列预测是以某一指标的时间序列反映的区域社会经济现象发展形态为依据,进行趋势外推,预测其未来发展趋势和水平的常用方法。时间序列预测的模型方法很多,较常用的有曲线(直线、抛物线、指数、对数)外推模型、指数平滑模型、移动平均模型、灰色预测模型等。其中曲线外推模型本质上就是回归预测法(即观测样本为时间序列数据)。
|
||||||
|
|
||||||
|
4.空间分区方法
|
||||||
|
|
||||||
|
(1)国土空间分区基本方法
|
||||||
|
|
||||||
|
国土空间分区是国土空间规划的重要基础,是国土空间优化配置的核心内容,是制定差别化国土资源管理政策的主要依据。国土空间分区一般以地域分异规律为理论基础,确定不同的理论和方法准则作为指导思想,并指导选取分区指标、建立分级系统、方法体系。针对国土空间分区的多主题集成和多尺度融合,采用自上而下的国土空间现状要素分析与自下而上国土空间功能表达相结合,形成一个有机整体,评价单元原则上不打破行政界线或产权界线的完整性,而分区实施过程中对评价单元界线与数据单元尺度不一致的情况可运用地理信息系统空间分析方法予以解决。几种常见的具体分区方法(见表5.12)。
|
||||||
|
|
||||||
|
表5. 12国土空间分区方法
|
||||||
|
|
||||||
|
在采用空间叠置法时,因不同地区情况有很大差异,在各类图件叠置时,不可能把多种图件同时叠置在一起,所以有一个前后顺序问题,叠置顺序可根据采取先重点后一般的方法进行。通常是将土地利用现状图作为叠置的底图,将同比例尺的永久基本农田保护图、生态保护红线图、城市开发边界图等以经纬网和明显地物为标志进行叠置套合,然后再叠置交通、水利、城市、乡村、旅游等专项规划图件。如果叠置后分区界线一致的,就直接作为分区界线;对于不重叠界线,需结合适宜性评价和资源环境承载力评价结果及规划要求和行业特点进行判别处理。
|
||||||
|
|
||||||
|
在采用空间叠置法进行用途分区划定的过程中,应注意以下三点:一是界线明确无争议的可直接采用,并同时在底图上标注分区名称;二是对界线不太明确或与实地有争议的,要与有关部门协商,并通过科学的研究和论证后确定;三是对一些重叠图,如风景旅游区内出现自然保护区,要根据其双重作用,以主要用途和资源保护优先的原则确定。对于某些部门为了自身利益而扩大的界线,如林业与果园之间的矛盾等,应本着生态环境优先、资源保护优先的原则,采用特尔菲法进行辅助判别。
|
||||||
|
|
||||||
|
除了上述分区方法以外,还要结合公众参与、创新理念分析和空间模型应用等综合分区思想,最终实现多主题集成和多尺度融合的国土空间分区。
|
||||||
|
|
||||||
|
(2)国土空间地域分区类型
|
||||||
|
|
||||||
|
国土空间地域分区通常是指在地市级以上大尺度的国土空间规划中,按照自然、生态、社会、经济及国土空间开发保护的一致性和管理方针的一致性,所划分的区域。地域分区方法是依据地域共轭性原理,以自然区划方法为基础,按照国土空间开发保护一致性和差异性的大小进行区域划分。这样划分的区域内部以某一类国土空间开发利用保护为主,但同时存在其他非主导的开发利用保护类型,实际上是一种不同开发利用保护类型的组合分区。地域分区中的同一区域不能在空间上断续分布,一般区域命名以地理差异为主命名。这种分区方法对认识区域内部不同地方的国土资源特点和确定不同地方国土资源开发保护方向无疑是非常有帮助的。
|
||||||
|
|
||||||
|
国土空间规划的地域分区除了按以上综合方法进行以外,还可以有以下三种类型(见表5.13)。
|
||||||
|
|
||||||
|
表5. 13国土空间分区方法
|
||||||
|
|
||||||
|
究竟应该采用何种类型地域分区,应当视国土空间规划的类型和主体任务而定。如果主要是整治自然环境,如流域治理,可以按照自然分区;如果主要是重大产业基地布局,可以采用经济分区;如果是为了更好地发布政府在国土空间规划中的作用,可以按照行政分区。但如果需要综合考虑自然生态和社会经济等的作用,可采用综合地域分区。
|
||||||
|
|
||||||
|
(3)国土空间用途分区类型
|
||||||
|
|
||||||
|
国土空间规划是全域全类型覆盖的,其用途分区类型必然不同于土地利用总体规划的用途分区类型,不同于城市总体规划的功能分区类型,也应该区别于传统的海洋功能分区。从空间覆盖范围和传统各类空间规划用途分区的深度来看,国土空间用途分区类型应该是土地、城市、海洋等各类空间规划用途分区类型的系统整合和创新。在具体操作层面上,可以土地利用总体规划的用途分区为基础,充分吸收城市总体规划功能分区和海洋功能分区的成果,相互之间优势互补,形成全新的国土空间用途分区类型。按照这种思路,国土空间用途分区类型从城市到乡村再到海洋,在一级层面至少应当包括以下基本的用途分区类型:城镇区、村落区、采矿区、农地区、林地区、自然环境保护区、水资源保护区、历史遗产保护区、观光休养区、海洋农渔业区、海洋非农利用区、海洋保护区等。在一级分类之下,可根据空间尺度和用途管制的需要,续分二级甚至三级。例如,在城镇区续分居住区、工业区、仓库区、对外交通区等,还可以有行政区、商业区、文教区、休养疗养区等;在农村地区可以续分基本农田保护区、基本草场保护区、一般农地区;林地区可以续分生态林区、生产林区等。
|
||||||
|
|
||||||
|
\subsection{系统制图方法}
|
||||||
|
\subsubsection{1.制图数学基础}
|
||||||
|
平面坐标系统采用 CGCS2000坐标系;高程基准采用1985国家高程基准;地图投影采用高斯-克吕格投影。1:2000、1:1000和1:500等比例尺成果按3°分带,图件比例尺小于1:10万,采用6°分带,平面坐标单位采用“米”。
|
||||||
|
|
||||||
|
\subsubsection{2.图件比例尺}
|
||||||
|
国土空间规划的制图比例尺从乡镇到全国依次为:1:5000、1:1万、1:2.5万、1:5万、1:10万、1:25万、1:50万、1:100万等8种,具体视各级规划区域的空间范围大小而定。通常情况下,国家规划图件比例尺为1:100万,省级规划图件比例尺为1:50万,市级规划图件比例尺为1:10万,县级规划图件比例尺为1:5万,乡镇级规划图件比例尺为1:1万,中心城区规划图件比例尺1:1万。
|
||||||
|
|
||||||
|
\subsubsection{3.图件内容}
|
||||||
|
国土空间规划图主要反映规划期内国土空间开发保护引导和调控,重点是国土空间用途分区和工程设施建设,将国土空间用途管制落实到每一个地块图斑,实现国土空间开发格局控制和引导。
|
||||||
|
|
||||||
|
\subsubsection{4.图件种类}
|
||||||
|
国土空间规划图件包括必备图件和可选择图件。必备图件作为国土空间规划必须编制的图件,可选择图件根据需要选择编制。
|
||||||
|
|
||||||
|
必备图件包括:①国土空间利用现状图;②国土空间规划图及永久基本农田、生态保护红线、城镇开发边界、重大工程设施建设等专题规划图件。
|
||||||
|
|
||||||
|
可选图件包括:规划区位图、国土空间开发适宜性评价图、资源环境承载力评价图、国土空间整治图、国土空间生态修复图、遥感影像图和数字高程模型图等。
|
||||||
|
|
||||||
|
\subsubsection{5.制图的地理要素和注记}
|
||||||
|
地理要素主要包括行政界线、政府驻地、高程特征点和等高线等;注记主要包括各级政府驻地、公路、铁路、机场、港口、水利设施和河流湖泊等,在制图过程应正确和合理标注,一般同一图像内注记字体种类以不超过4种为宜,界线和等高线按分级显示。
|
||||||
|
|
||||||
|
\subsubsection{6.图幅配置}
|
||||||
|
国土空间规划图的图幅配置内容应包括图名、图廓、地理位置示意图、指北针与风向玫瑰图、比例尺、图例、署名和制图日期等要素。制图应注意图幅的平衡和饱满。
|
||||||
|
|
||||||
|
\section{国土空间规划空间分析技术}
|
||||||
|
国土空间属于复杂空间系统,编制过程是规模巨大的系统工程,涉及大量的空间数据,如物理层和生物层包括从基础岩层到大气环境、从景观生态到植被野生生物,从自然灾害到景观资源等;经济层和文化层包括土地利用、交通运输到街区模式、从人文历史到人口分布、从基础设施到发展潜力等数据。为提高国土空间规划决策的科学性,及规划实施后的有效管理,将全球定位系统 GPS(Global Positioning System)技术、遥感技术(Remote Sensing, RS)和地理信息系统(Geographic Information System,GIS)技术和应用在国土规划中成为必然趋势。
|
||||||
|
|
||||||
|
RS技术是支撑国土资源调查的最基本技术,同时RS 技术也是国土空间规划的实施监测管理的必要技术手段之一;GPS技术主要服务于国土资源调查的定位需求,同时支撑规划实施过程中的定位需求;GIS是国土空间规划中最重要的技术支撑,空间数据的存储、统计、分析、规划、推演等都需要建立在GIS平台上的国土空间规划数据库的支撑,特别是国土空间规划“一张图”,就是以GIS数据库形式呈现的。
|
||||||
|
|
||||||
|
\subsection{地理信息系统(GIS)空间分析}
|
||||||
|
地理信息系统出现于20世纪60年代,得益于80年代和90年代UNIX工作站和个人计算机等技术的飞速发展,到20世纪末得到普遍应用,是以采集、存储、管理、描述、分析地球表面和地理分布有关的数据的信息系统(陈述彭,1999),具有数据录入、编辑、修改、信息查询和统计、测量和空间分析、模拟和专题地图制作等功能。空间分析技术是地理信息系统区别于其他信息管理系统的标志在国土空间规划工作中,运用GIS进行输入、统计、绘图外,更主要的是运用GIS进行空间分析,进行规划决策。空间分析技术主要包括叠加分析、缓冲区分析、网络分析、空间统计分析、三维空间分析等。
|
||||||
|
|
||||||
|
\subsubsection{1.空间叠加分析}
|
||||||
|
叠加分析(Overlay analysis)是GIS中的一项非常重要的空间分析功能,指在统一的坐标系下将同一区域、同一比例尺的两组或两组以上的多边形要素的数据文件进行叠置,叠加不仅产生新的空间关系,还将输入数据层的属性联系起来,产生新的属性关系,能够发现多层数据间的相互差异、联系和变化等特征,并提取具有多重指定属性特征的区域,或者根据区域的多重属性进行分级、分类。叠加分析的目标是分析在空间位置上有一定关联的空间对象的空间特征和专属属性之间的相互关系。
|
||||||
|
|
||||||
|
GIS叠加分析分为基于矢量数据的叠加分析和基于栅格数据的叠加分析两大类。矢量数据叠加分析包括视觉信息叠加,点、线、多边形与多边形叠加等,栅格数据属于数学运算的叠加运算,在GIS中称为地图代数。地图代数功能有三种不同的类型:基于常数对数据层面进行的代数运算;基于数学变换对数据层面进行的数学变换(指数、对数、三角变换等);多个数据层面的代数运算(加、减、乘、除、乘方等)和逻辑运算(与、或、非、异或等),有时简称为栅格计算器。在国土空间规划编制过程中主要运用叠加分析进行建设适宜性分析,方案比较、优化等,便于提高规划的科学性。
|
||||||
|
|
||||||
|
\subsubsection{2.缓冲区分析}
|
||||||
|
缓冲区分析(Buffer Analysis)是为了识别某一地理实体或空间物体对其周围地物的影响度而在其周围建立的具有一定宽度的带状区域。空间物体可以是点、线、面等,是用来解决邻近度问题的空间分析工具之一。邻近度描述了地理空间中两个地物距离相近的程度。缓冲区分为匀质缓冲区和非均质缓冲区两种,均质缓冲区是空间物体与邻近对象只呈现单一的距离关系,缓冲区内各点影响度相等,如服务范围的划定。非均质缓冲区是空间物体对邻近对象的影响度随距离变化而呈不同强度的扩散或衰减,如道路噪声影响范围随距离地增大而减弱的范围及程度等。
|
||||||
|
|
||||||
|
\subsubsection{3.网络分析}
|
||||||
|
网络分析(Network Analysis)是矢量数据特有的空间分析方法。网络模型是现实世界中网络系统(如生态网络、游憩网络、交通网、市政基础设施网络等)的抽象表示,目的是研究网络的结构和功能,模拟分析资源在网络上的流动和分配情况实现对网络结构及其功能等的优化。国土空间规划的应用主要包括路径分析、资源分配、连通分析、地址配置等。
|
||||||
|
|
||||||
|
\subsubsection{4.空间统计分析}
|
||||||
|
空间统计分析(Spatial Statistical Analysis)。地理信息要素的属性(面积、长度、朝向、近邻关系等)及位置是 GIS 数据的固有信息,可用于创建视觉上可分析的地图,空间统计分析就是在此固有信息的基础上,研究与空间位置相关的事物和现象的空间关联和空间关系,有助于从 GIS 数据中提取只靠查看地图无法直接获得的额外信息,例如各属性值如何分配,数据中是否存在空间趋势或者要素是否能够形成空间模式。与提供单个要素信息的查询功能不同(如识别或选择),统计分析可整体显示一组要素的特征。在国土空间规划中主要应用于景观生态学、土地利用空间自相关分析以及土地资源的适宜性研究等,是科学分析利用土地资源的重要技术手段。
|
||||||
|
|
||||||
|
\subsubsection{5.三维空间分析}
|
||||||
|
三维空间分析(3D Analysis)是指国土空间规划中的地形分析及三维空间的可视化分析技术。包括数字高程(DEM),坡度、坡向计算和剖面分析,阴影分析、水文分析,谷脊特征分析、可视域分析等,为国土空间规划中进行区域、流域空间分析,城市的天际线、视线、体量、高度、通视、淹没等规划分析,为各尺度国土空间规划提供依据。
|
||||||
|
|
||||||
|
\subsection{遥感(RS)技术}
|
||||||
|
遥感技术作为一种空间信息技术,对于国土空间规划中所面临的基础资料、人口、资源和环境等问题,都可以借助于遥感技术进行调查、监测和评价,主要用于资源的调查与评价、环境的监测与评价、规划的实施监测(吴次芳等,2003)。资源调查和评价包括用于生态资源、土地利用资源、水资源、矿产资源、资源的调查和评价;生态环境的监测与评价包括用于大气环境监测、水环境监测、土壤环境监测、城乡污染监测的遥感信息获取、定量分析、灾情预报与监测等方面;规划的实施监测包括用于土地利用变化监测、检查规划批准项目的落实情况和监测未经批准进行建设占地的案件,以及监测违反土地利用总体规划的违法用地情况。
|
||||||
|
|
||||||
|
由于遥感技术的应用,使得规划中的外业工作周期缩短、成本降低,极大地提高了工作效率。遥感技术能够提供及时、详细、准确、宏观视野、多层面的地表信息,弥补了以往规划技术方法的不足,同时遥感技术为国土空间信息系统提供了强大的查询、分析、统计的基础数据,是国土空间规划不可或缺的基础资料。
|
||||||
|
|
||||||
|
\subsubsection{1.遥感技术的概念}
|
||||||
|
遥感(Remote Sensing),从远处探测、感知物体或事物的技术。即不直接接触物体本身,从远处通过各种传感器探测和接收来自目标物体的信息,经过信息的传输及其处理分析,来识别物体的属性及其分布等特征的综合技术。按照遥感的工作平台分类可以分为地面遥感、航空遥感、航天遥感;按照探测电磁波的工作波段分为可见光遥感、红外遥感、微波、激光遥感等;按照遥感应用的目的可分为大气遥感和环境遥感(农业遥感、林业遥感、地质遥感等);按照资料的记录方式可分为成像方式、非成像方式;按照传感器工作方式可分为:主动遥感、被动遥感。
|
||||||
|
|
||||||
|
遥感技术具有全局视野、可进行重复观测、一般不会干扰对象、数据采集可避免人工采样偏差等优点,特别是还可以观测一些由于自然或政治因素无法访问的区域。但是遥感只提供一些空间、光谱和时间信息,仅提供物理、生物或社会科学研究所需要的信息,遥感信息处理过程的图像处理、解译等人为参与过程可能会引起一定的误差。
|
||||||
|
|
||||||
|
\subsubsection{2.遥感图像处理技术}
|
||||||
|
遥感图像处理技术包括彩色、图像校正、图像变换和增强、多平台数据融合技术等,为遥感信息提取做准备。
|
||||||
|
|
||||||
|
(1)彩色合成技术。为了充分利用色彩在遥感图像判读和信息提取中的优势,常常利用彩色合成的方法对多光谱图像进行处理,以得到彩色图像。单波段图像显示为黑白图像,根据色彩合成原理进行对多光谱图像进行合成,波段决定色调,可以将不同波段的图像合成为彩色图像。设定的波段影像的色调与实际地物色调是否一致,决定图像是真彩色图像和假彩色图像(伪彩色);也可以通过密度分割方法密度分割将原始图像的灰度值被分成等间隔的离散的灰度级(层),然后对每一灰度级赋新灰度值或颜色,从而得到一幅密度分割图像,形成的彩色图像为伪彩色图像。
|
||||||
|
|
||||||
|
(2)遥感图像校正技术。为减少遥感图像因传感器本身性能、地形及光照条件变化、大气散射和吸收等因素引起的辐射误差影响,二进行的校正为辐射校正。为避免卫星在运行过程中,由于姿态、地球曲率、地形起伏、地球旋转、大气折射、以及传感器自身性能所引起的几何位置偏差,图像上像元的坐标与地图坐标系统中相应坐标之间的差异引起的误差进行的矫正技术为几何校正。为将同一地区的不同特性的相关影像(如不同传感器,不同日期,不同波段或传感器在不同位置获取的同一地区地物)在几何上互相匹配,即实现影像与影像间地理坐标及像元空间分辨率上的统一而进行的校正为图像配准。将两幅或多幅影像拼在一起,构成一幅整体影像的技术过程为影像镶嵌技术。
|
||||||
|
|
||||||
|
(3)图像变换和增强。图像变换是图像空间的图像以某种形式转换到另外一些空间(如频率域,图像能量集中分布在低频率成分上,边缘、线状信息反映在高频率成分上)或称为变换域,包括傅立叶变换、统计变换、 彩色变换等,目的在于使图像处理问题简化,有利于图像特征提取,有助于从概念上加强对图像信息的理解,利用变换域中特有的性质方便地进行一定的加工,再转换回图像空间得到所需的结果。遥感图像增强是为特定目的,突出遥感图像中的某些信息,削弱或除去增强感兴趣目标和周围背景图像间的反差。增强技术不能增加原始图像的新的信息,但会使图像更易判读,属于是计算机自动分类一种预处理方法。
|
||||||
|
|
||||||
|
(4)多平台数据融合技术。遥感数据具有多波段、多时相、多平台的特点,多波段图像对应像元进行代数运算,将不同类型传感器获得的同一地区的数据进行空间配准后,将各数据中的优势或互补性有机结合起来进行融合。目的是保留光谱信息和提高几何特征。
|
||||||
|
|
||||||
|
\subsubsection{3. 遥感信息提取技术}
|
||||||
|
遥感信息提取是将空间规划数据识别为空间信息的过程,包括目视解译、自动分类,定量化提取等方法,通过专题制图过程完成空间信息的存储和表达。解译是根据遥感图像的影像特征,结合地物光谱特性、专业知识进行比较、推理、判断和综合分析,最后提取出关注的信息,并进行专题图绘制的过程。
|
||||||
|
|
||||||
|
(1)目视解译是使用眼睛目视观察,借助一些光学仪器或在计算机显示屏幕上,凭借丰富的解译经验,扎实的专业知识和手头的相关资料,通过人脑的分析、推理和判断,提取有用的信息的过程。自动分类
|
||||||
|
|
||||||
|
(2)自动分类。遥感图像分类就是利用计算机对遥感图像中各类地物的光谱信息和空间信息进行分析,选择特征,并用一定的方法将特征空间划分为互不重叠的子空间,然后将图像的各个像元划归到各个子空间中去,或者说对图像所有像元进行分类。其中非监督分类是指在没有先验类别知识的情况下,根据图像本身的统计特征对图像所有像元进行分类。其分类的结果只是对不同类别达到了区分,但并不能确定类别的属性。其类别的属性是通过分类结束后实地调查或其他资料确定的。监督分类是遥感图像上地物的类别已知的前提下,在已知类别的训练场地上提取各类别的训练样本,通过选择特征变量、确定判别函数或判别规则把图像中各个像元划归到相应的类别中去的分类方法。
|
||||||
|
|
||||||
|
(3)定量化提取。目视解译、自动分类等都属于定性信息提取,用来确定某个地物是什么、空间属于什么类型。相对于定性信息提取,定量遥感信息提取是从地物反射或发射的电磁辐射里,推演得到空间、地物某些特征定量化描述的方法。简单表达就是在遥感获取的各项电磁辐射信号的基础上,通过数学的或者物理的模型,将遥感信息与观测地表目标联系起来,定量地反演或推算目标的各种自然属性信息的方法,定量遥感是遥感技术的重要发展方向之一。
|
||||||
|
|
||||||
|
\section{国土空间规划决策方法}
|
||||||
|
\subsection{空间决策博弈方法}
|
||||||
|
地理设计(Geodesign,省级国土空间规划编制指南,附录A19)框架指出决策过程直接或间接地影响了国土空间的结构、布局与质量。从决策理论的来看,国土空间规划方案存在着“目标是否合理,方案是否可行,代价是否最小,副作用是否最小”的问题,因此,空间决策问题的研究对于自然资源优化配置,推动国土空间利用方式由粗放型向集约型转变,实现社会效益、经济效益和生态效益协调发展具有十分重要的意义。
|
||||||
|
|
||||||
|
国土空间是复杂空间系统,处理复杂系统的方法是将复杂空间划分为若干个子系统,并在完善子系统的基础上,对子系统之间的冲突进行博弈,形成最终的空间规划方案。国土空间规划涉及诸多的国土空间利用利益相关者,对空间系统的目标、实施策略都有着不同的需求和目的,所以地理设计框架提供的动态协同方法,非常适用于解决国土空间决策博弈问题:地理设计框架将国土空间利用的利益相关者分为政府、专家、居民、开发建设者,与规划人员、研究人员和信息技术支持人员等一起,将国土空间规划过程分为分析、设计、实施三个迭代过程与表达、过程、评估、改变、影响、决策六个过程,并在过程中对利益相关者等分别设定了角色和方法,决策过程中设定了在流程之间和在迭代过程之间的博弈方法,为高效地决策提供了框架和流程。
|
||||||
|
|
||||||
|
\subsection{空间决策支持系统}
|
||||||
|
Geodesign HUB国土空间规划设计涉及现状的、规划的、推演的海量空间数据,涉及大量的利益相关者,决策过程极其复杂,因此需要开发基于信息技术的空间决策支持系统。决策支持系统(Decision Support System,DSS)是在管理信息系统(Management Information System,MIS)基础上增加了模型库及管理系统,借助计算机技术,运用数学方法、信息技术和人工智能为管理者提供了分析问题、构建模型、模拟决策过程及评价最终效果的决策支持环境(闫鑫,2016),由人机界面、数据库及其管理系统、模型库及其管理系统三单元结构组成。
|
||||||
|
|
||||||
|
随着信息科学理论的不断发展与进步,决策支持系统集合了专家系统、遗传算法、神经网络等技术,将原有的三单元结构加以完善,逐渐添加方法库、知识库及其各自的管理系统,使决策支持更加智能。为增加DSS的空间数据巨册功能,可以将GIS 和 DSS 相结合,形成空间决策支持系统,为国土空间规划的一大技术保障。为在地理设计框架下,进行空间系统博弈,可以开发基于地理设计框架的空间决策系统为Geodesign HUB。
|
||||||
|
|
||||||
|
\subsubsection{1.国土空间规划决策支持系统实现方式}
|
||||||
|
国土空间规划的计算机辅助决策支持一般有三种方式(见表5.14)。
|
||||||
|
|
||||||
|
表5. 14计算机辅助决策支持方式
|
||||||
|
|
||||||
|
基于数据形式的决策支持是传统的辅助决策支持方式;基于模型和方法形式的规划设计决策支持是目前空间决策的常用手段。知识主导形式的决策支持是最为先进的辅助解决支持方式,由于引入了利益相关者参与决策,将大幅度地提高国土空间规划效率。
|
||||||
|
|
||||||
|
\subsubsection{2.国土规划空间决策的开发}
|
||||||
|
系统分析。系统分析需采用系统工程的方法,进行需求分析和可行性分析。结合空间决策过程的复杂性进行综合分析,制定可行方案,为系统设计提供依据。在需求分析中总的来说有三个问题(见表5.15)。
|
||||||
|
|
||||||
|
表5. 15空间决策开发系统分析问题
|
||||||
|
|
||||||
|
据此进行可行性分析并提出方案。空间决策支持系统要求将云技术,基于Web的GIS 与 DSS 相结合,根据功能、时间和经费等进行一体化、模块化设计开发,可通过 GIS 的组件结合 DSS 工具进行开发。
|
||||||
|
|
||||||
|
系统设计原则:
|
||||||
|
|
||||||
|
系统主要遵循科学性、实用性、规范性、可扩展性的设计原则(见表5.16)。
|
||||||
|
|
||||||
|
表5. 16空间决策开发系统设计原则
|
||||||
|
|
||||||
|
系统实现。决策系统旨在支持协作和博弈,以达成协同。应尽可能地简单:易于学习、设置、使用,特别是易于理解。决策系统的实现包括数据准备、各协作子系统的建立和系统的整体集成,系统应该是基于云的、免费和开放访问的、开放的平台,可以通过API与其他工具和模型进行链接。数据库管理系统现有的 主流GIS 数据库系统;模型库系统一般利用程序设计语言自行进行设计与开发;地理数据分析系统可利用 GIS 软件所提供的接口进行调用。人机交互系统要通过API实现与其他系统的调用接口,设计良好的界面,并嵌入集成式的 DSS 语言支持。实现的系统应经过系统测试与评价,形成最终可靠灵活的决策支持系统。
|
||||||
|
|
||||||
|
\subsection{空间模拟与仿真技术}
|
||||||
|
对国土空间规划方案进行决策时,需要先对其进行演进评估。由于方案的实施目标和策略不同,会对国土空间产生不同结果,特别是参与规划方案决策者的利益相关者的目标差异,会导致决策过程高度复杂,且实施结果的不确定性大大增加,降低决策的科学性和并且可实施性产生偏差。为解决此问题,需要通过空间的模拟与仿真对国土空间规划方案进行推演、模拟仿真,便于直观科学地决策。
|
||||||
|
|
||||||
|
当前主流的空间推演模型主要包含基于方程的模型(Equation-Based Model)、系统动力学模型(SD,System Dynamics Model)、元胞自动机模型(Cellular Automata Model)、基于主体模型(Agent-Based Model/ Multi-agent Model)及综合模型。从不同技术对国土空间变化预测的时空维度上,可以大致划分为:国土空间需求预测模型、国土空间预测模型和时空耦合的综合模型三类。
|
||||||
|
|
||||||
|
\subsubsection{1.系统动力学}
|
||||||
|
系统动力学(System Dynamics, SD)模型可以反映系统(或子系统)的信息、物质、能量的流动结构与反馈关系。SD模型始于20世纪50年代,由Forrester基于工业企业管理过程中关于系统库存控制、生产调节、劳动力雇佣等复杂关系提出。在构建流程上,SD模型需要将复杂系统的变量进行抽象和符号化,在分析变量之间的信息反馈关系的基础上搭建变量之间的因果关系方程,以此研究和分析复杂系统中要素的行为变化。相对于基于方程的统计模型,SD模型擅长处理具有长期性和周期性的问题,并且可以充分考虑变量之间可能存在的包括高阶、非线性和时变性等反馈机制。因此在分析社会经济变化对国土空间需求的影响以及政策情景预测方面具有一定的优势。目前SD模型已经广泛应用到国土空间规划的土地利用需求的预测中,分析过程主要由下述步骤组成。
|
||||||
|
|
||||||
|
定义系统。定义系统就是确定国土空间规划发展的目标和解决的问题。通过对规划方案的分析,预测规划的空概念方案可能出现的期望状态,然后分析规划的空间系统有关特征,最后确定空间方案的问题,并描述与问题有关的状态,估计问题产生的边界与范围、选择适当的变量等。
|
||||||
|
|
||||||
|
分析因果关系。对于国土空间规划的演进模拟,SD模型是把研究的对象作为空间系统来处理的,依据反馈动力学原理,将反馈环定义为空间系统的基本组件。多个反馈环的组合构成了空间复杂系统。空间系统在与外界的相互作用以及内部各要素之间的作用,使得空间系统总处于不断变化之中,SD通过因果关系图分析空间系统各子空间要素之间作用的因果关系。因果关系的分析将空间系统的要素通过箭头表示。然后根据空间系统边界各个子空间要素之间的因果关系形成反馈环。
|
||||||
|
|
||||||
|
建立空间系统流图。SD模型流图包括流位(空间系统内部的定量指标)、流率(描述系统实体在单位时间内的变化率)、流线(表示对系统的控制方式)和决策机构(由流位传来信息所确定的决策函数)等。
|
||||||
|
|
||||||
|
构造方程与运行模型。根据空间系统的对应关系构造符合时空变化的方程,然后利用计算机仿真语言,将SD模型转化成系统的仿真模型。
|
||||||
|
|
||||||
|
结果分析。借助计算机软件,输入对应参数以及相关控制语句运行建立的空间仿真模型,根据得的结果,再对结果进行分析迭代,若存在问题可对模型进行修正,一直到得到满意的空间推演结果。
|
||||||
|
|
||||||
|
\subsubsection{2.元胞自动机}
|
||||||
|
元胞自动机模型(Cellular Automata Model),简称CA模型,在国土空间模拟中的应用始于20世纪60-70年代,主要应用于城市空间扩散模型的研究。20世纪80-90年代以来,CA模型在土地利用/土地覆被变化(简称LUCC)模拟的理论框架基本形成,而自90年代以来,CA模型被广泛应用LUCC模拟,并取得丰硕的研究成果。CA模型框架相对简单和开放,加之具备强大的复杂计算能力,能够从微观尺度上构建决策规则并模拟空间复杂的时空动态变化过程。
|
||||||
|
|
||||||
|
CA模型由四个部分组成:元胞(Cells)、状态(States)、邻域(Neighbors)和规则(Rules)。CA的基本运行法则是:判断元胞自身与领域的状态,并依据所制定的局部规则确定下一时刻状态。CA模型中的转换规则是影响国土空间演进模拟精度的重要部分。目前,诸多学者采用不同的方法挖掘国土空间转换规则,主要包括:从驱动土地利用类型转移的影响因素出发,通过引入如人工神经网络、支持向量机和随机森林等算法提高规则制定的准确性;从土地利用变化类型及其空间关系出发,在挖掘地类之间相互作用关系的基础上,增加局部土地利用竞争关系,以提高模型预测的精度。
|
||||||
|
|
||||||
|
CA 模型在模拟土地利用变化时具有一定的优势:第一、作为一种空间动力学模型,基于“自下而上”的建模思路,能够从微观尺度上构建决策规则并模拟空间复杂的时空动态变化过程;第二、具备复杂且强大的计算能力,可以模拟国土空间变化中的复杂行为;第三、CA模型的离散性、同步性和局部性等特征,在处理大尺度的国土空间数据时具备并行计算的能力。所以,国土空间变化作为一种复杂的时空动态变化过程,CA模型与GIS技术的结合增强了模型对土地利用在空间上的模拟和分析能力。
|
||||||
|
|
||||||
|
\subsubsection{3.多智能体}
|
||||||
|
多智能体系统(Multi-Agent System, 简称MAS)理论和技术是在复杂适应系统理论及分布式人工智能(distributed artificial intelligence, DA1)技术的基础之上发展起来的,目前已经成 为一种进行复杂系统分析与模拟的思想方法与工具。
|
||||||
|
|
||||||
|
基于空间的多智能体一般都是借助于CA的思想,Agent分布在规则的二维网格上,二维网格相当于CA的元胞空间。利用Agent的局部连接规则、函数及局部细节模型,建立国土空间复杂系统的整体模型。空间Agent可以根据一定的移动规则在二维网格中自由移动。多个Agent可以占据同一个网格点,不同的网格点上可以拥有不同数量的Agent。空间Agent表现一定的智能性,具有空间决策能力和学习能力,能够对环境的变化做出适应性的反应,基于以上特点,MAS非常适合进行国土空间系统的模拟与仿真,进行演进分析。国土空间系统是一个典型的复杂系统,空间的动态发展是基于微观空间个体相互作用的结果。MAS的核心思想就是微观个体的相互作用能够产生宏观全局的格局。通过观察微观Agent与系统之间及Agent之间的交互作用,来研究系统层面整个区域的国土空间的演化过程,在国土空间规划的复杂模拟中,可以建立通过社会、经济、政策等因子反映不同类型智能体的决策偏好,为不同类型的智能体定义行为以实现智能体之间、智能体与环境之间的交互。
|
||||||
|
|
||||||
|
\section{国土空间规划“一张图”系统}
|
||||||
|
2019年7月18日,自然资源部办公厅为贯彻共中央 国务院《关于建立国土空间规划体系并监督实施的若干意见》精神,落实《自然资源部关于全面开展国土空间规划工作的通知》要求,依托国土空间基础信息平台,发布《关于开展国土空间规划“一张图”建设和现状评估工作的通知》自然资办发〔2019〕38 号。在通知中提出,要求依托国土空间规划基础信息平台,全面开展国土空间规划一张图建设和市县国土空间开发保护现状评估工作。
|
||||||
|
|
||||||
|
\subsection{国土空间规划“一张图”建设步骤}
|
||||||
|
国土空间规划“一张图”是指“一张现状底图+ 一张规划蓝图+一张管理用途”。一张现状底图是指以第三次全国国土调查成果(以下简称“三调”)为基础,融合规划编制所需的自然资源、社会经济、城乡建设、基础设施以及其他相关数据与信息, 形成以2000国家大地坐标系(CGCS2000),同时高程基准采用“1985国家高程基准”等坐标统一、边界一致的全国范围内的一张底图, 并进行一定时间间隔的时空数据更新。一张地图的建设能够有效支撑规划编制、 实施及监测等规划全周期。一张规划蓝图是指在一张底图的国土空间规划数据系统基础上,将规划成果通过标准转换、数据匹配、底图一致等工作,将规划成果向本级平台入库,通过数据库中多重叠加各类国土空间规划成果的图层数据,形成国土 空间规划的“一张图”,并定期通过更新规划修改、审批等信息,实现国土空间规划“一张图”的动态更新;一张管理用图是指以规划一张蓝图为指导,将各类国土空间开发与保护活动纳入一张管理业务图,管理好国土空间规划实施全部业务活动,实现国土空间治理的全面监管,促进国土空间规划的实时监测与动态管理。
|
||||||
|
|
||||||
|
\subsubsection{1.统一的底图}
|
||||||
|
以第三次全国国土调查成果为基础,整合规划编制所需的空间关联现状数据和信息,形成坐标一致、边界吻合、上下贯通的一张底图,用于支撑国土空间规划编制。以“三调”成果为基础,整合空间规划所需的 相关数据和信息,包括基础测绘、资源调查、资源感知、城乡建设、资源管理、社会数据、经济数据、人口活动、城乡建设等多元数据,通过数据融合、集成、汇总、叠加等过程形成坐标一致、边界统一的现状“一张图”底图。
|
||||||
|
|
||||||
|
“三调”成果数据中符合入库要求的直接入库,不能直接入库的各类数据需按照国土空间用途分类进行数据转换,空间关联数据需通过细化与补充调查后才能入库。其他入库数据主要分为四大类,现状类数据:土地利用现状、遥感影像、基础地理信息数据等;管控类数据:土地利用规划、功能区划、专项规划等;管理类数据:自然资源确权登记、生态修复、测绘管理等;社会经济类数据:社会、经济、人口、产业、行政机构等。现状数据通过图斑处理、图层构建、字段建立、属性赋值、冲突分析、图数核对、优化完善、整合成库等过程,构建统一数据格式、坐标标准、属性表达等的现状底图。
|
||||||
|
|
||||||
|
\subsubsection{2.国土空间规划“一张图”的构建}
|
||||||
|
各地自然资源主管部门在推进省级国土空间规划和市县国土空间总体规划编制中,将批准的规划成果向本级平台入库,作为详细规划和相关专项规划编制和审批的基础和依据。经核对和审批的详细规划和相关专项规划成果由自然资源主管部门整合叠加后,形成以一张地图为基础,可层层叠加打开的国土空间规划“一张图”,为统一国土空间用途管制、实施建设项目规划许可、强化规划实施监督提供支撑。
|
||||||
|
|
||||||
|
以现状底图为基础,进行“双评价”工作。通过评价区域资源环境承载能力,根据生态环境保护、农业生产、城镇建设发展的功能导向,划定区域资源环境承载能力等级,总结其环境优势与生态环境发展禀赋条件。通过评价区域国土空间开发适宜性,划定生态保护重要性分区以及农业生产和城镇建设适宜性分区,探测国土空间开发的风险区域,综合分析区域国土空间开发的潜力。“双评价”的成果主要用以指导“三区三线”的划定,即城镇空间、农业空间与生态空间;生态保护红线、永久基本农田保护红线和城镇开发边界。基于此,叠加国土空间规划的“五级三类”规划图层,总体规划层面包括国土空间格局、城乡统筹结构、重大基础设施廊道、城镇建设区、地下空间布局、产业发展分区、国土空间综合整治等; 详细规划层面包括用地布局、公共服务设施、道路交通规划等要素;专项规划包括资源利用类、 要素配置类、安全保护类、城市特色类等。叠合的要素通过数据集成、处理、融合、校正等步骤,形成统一用地分类标准、统一数据标准和统一事权的规划蓝图。
|
||||||
|
|
||||||
|
\subsubsection{3.建设国土空间信息平台}
|
||||||
|
自然资源部门建设国土空间基础信息平台,并与国家级平台对接,实现纵向联通,同时推进与其他相关部门信息平台的横向联通和数据共享。基于平台,建设从国家到市县级的国土空间规划“一张图”实施监督信息系统,开展国土空间规划动态监测评估预警。管理用图一般是基于国土空间基础信息平台建设的“一张图”规划实施监测系统,具有规划实施监测与评估的作用,并涵盖规划从编制、审批、 实施等全过程。它可有效应用于自然资源管理与开发、空间用途管制、生态修复、耕地保护、 确权登记、执法管理等方面,以期达到规划编制更智能、规划实施更精准、规划管控更科学的总体目标。
|
||||||
|
|
||||||
|
\subsection{国土空间规划“一张图”实施路径}
|
||||||
|
\subsubsection{1.支撑规划编制的底图}
|
||||||
|
2019年,自然资源部颁发的《关于开展国土空间规划“一张图”建设和现状评估工作的通知》中,要求全国国土空间规划的开展以第三次全国国土调查成果为基础,整合规划编制所需的空间关联现状数据和信息,形成坐标一致、边界吻合、上下贯通的一张底图,用于支撑国土空间规划编制。并强调,国土空间规划编制及其中三条控制线、自然保护地和历史文化保护范围的划定等内容必须与一张图底图相对应。为了保障数据的实效性,要求底图随年度土地变更调查、补充调研等工作及时更新。“一张底图”的形成是工作开展的第一步,需要根据国土空间规划编制和现状评估的具体要求,梳理并形成数据资源目录。
|
||||||
|
|
||||||
|
\subsubsection{2.开展评估“一张图”的流程}
|
||||||
|
以一张底图为基础,借助信息化手段,依据《国土空间开发保护现状评估技术指南》工作要求,建立国土空间开发保护现状评估指标体系和指标计算模型,开展国土空间开发保护现状评估,形成指标库和模型库,实现指标的计算与存储管理,辅助规划管理者就开发保护的底线管控、结构效率和生活品质等方面,摸清现状、找准问题、识别风险、研判趋势、提出对策,为科学编制国土空间规划提供决策依据(如图5.9所示)。
|
||||||
|
|
||||||
|
\subsubsection{3.支持下位规划的一张图}
|
||||||
|
将各级各类的规划编制成果汇集为规划“一张图”,需要通过三个阶段:预备工作阶段、质检审查阶段、入库汇交阶段。各级总体规划编制成果依此工作实施路径,及时在本级平台入库并向国家级平台汇交,作为详细规划和相关专项规划编制和审批的基础和依据;详细规划和专项规划成果在本级整合叠加后,逐级向国家级平台汇交,为空间用途管制和建设项目规划许可提供依据。
|
||||||
|
|
||||||
|
\subsubsection{4.一张图的重要责任}
|
||||||
|
为国土空间用途管制、实施建设项目规划许可等国土开发利用、耕地保护和生态修复等规划实施相关业务提供规划空间管控的工作支撑。根据国土空间规划空间控制体系确定的管控要点和管控规则,将生态保护红线、永久基本农田保护红线、城镇开发边界以及主导功能区、规划分区和用途分类等统一纳入空间管控体系中,对规划实施行为开展合规性审查,生成合规性审查“体检表”,保障管控底线不突破。
|
||||||
|
|
||||||
|
\subsubsection{5.一张图的风险识别}
|
||||||
|
明确规划实施监督的对象与目标,以指标体系为核心,梳理支撑落实监测评估预警的数据体系需求。基于平台与系统,采集与接入多源数据,通过构建实施监督指标库与模型库,实现指标的计算与存储管理,动态监测约束性指标、管控边界现状和各类国土空间开发保护行为,定期评估规划目标执行情况、国土空间开发保护现状及其结构、效率和宜居水平,及时预警规划实施过程和实施成效中出现的有底线突破风险、指标执行不力和发现疑似违法行为等情况,为领导干部绩效考核,实施相关用途管制政策,以及规划动态调整完善提供参考。
|
||||||
|
|
||||||
|
图 5.9国土空间规划一张图工作流程
|
||||||
|
|
||||||
|
\subsection{国土空间规划“一张图”实施意义}
|
||||||
|
建立坐标一致、边界吻合、上下贯通的国土空间规划“一张图”,是贯彻落实习近平总书记“统一底图、统一标准、统一规划、统一平台”重要指示和《中共中央 国务院关于建立国土空间规划体系并监督实施的若干意见》(以下简称《若干意见》)的基础性制度。为指导全国加快形成国土空间规划“一张图”,推进“智慧规划”建设,2018年10月,自然资源部会同有关方面,启动相关国家标准研制工作。2021年3月,经国家市场监督管理总局批准,《国土空间规划“一张图”实施监督信息系统技术规范》(GB/T 39972-2021)(以下简称《规范》)正式发布,成为“多规合一”改革后国土空间规划领域首个国家标准,明确依托全国统一的国土空间基础信息平台,以第三次全国国土调查数据形成底图,叠合各级各类国土空间规划,形成全国国土空间规划“一张图”,作为规划编制审批、实施监督全周期管理及专项规划“一张图”衔接核对的权威依据(见图19)。
|
||||||
|
|
||||||
|
\subsubsection{1.顶层设计支撑规划管理}
|
||||||
|
坚持国土空间的新发展理念,对照社会经济和人民群众高质量发展需求,深入贯彻落实数字中国的战略部署,始终把数字化智能化智慧化监管平台建设作为国土空间规划体系建设的重要内容,并摆在优先位置深入谋划。根据“科学、简明、可操作”的原则,以整合空间类规划、落实“多规合一”、形成“一本规划”“一本蓝图”为目标,着眼于强化规划全周期管理,推动解决过去各类规划内容重叠冲突、重规划编制轻规划实施、规划调整修改随意等突出问题,在总结提炼各地实践、广泛听取多领域专家意见基础上,高标准高质量地建设国土空间规划“一张图”系统。
|
||||||
|
|
||||||
|
以目标为导向,强化标准引领。国土空间规划“一张图”实施监督信息系统(以下简称规划“一张图”系统)是构建五级三类国土空间规划体系的统一数字化技术支撑。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》明确了“四个层次、两个体系”的总体框架,即设施层、数据层、支撑层、应用层和标准规范体系、安全运维体系;阐明了与其他业务系统的关系;明确了国、省、市、县一体建设,部门间共享协同的运转机制;规定了数据类型和要求以及主要功能应用等;确立了规划“一张图”系统建设的目标。
|
||||||
|
|
||||||
|
以问题为导向,提升规划全周期管理能力。规划“一张图”系统是实现国土空间规划全周期管理的有力手段。为防止出现违规编制、擅自调整、违规许可、未批先建、监管薄弱等乱象,《自然资源部办公厅关于加强国土空间规划监督管理的通知》(自然资办发〔2020〕27号)明确提出“实行规划全周期管理”,要求将国土空间规划“一张图”作为统一国土空间用途管制、实施建设项目规划许可、强化规划实施监督的依据和支撑。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》紧紧围绕规划编制、审批、修改和实施监督全流程,明确了国土空间规划的“一张图应用”“国土空间分析评价”“成果审查与管理”“实施监督”“指标模型管理”和“社会公众服务”等6大类23项功能。其中,重点强化了规划的智能化编制、智能化审查、智能化监管功能;并保持分析评价指标模型、算法的开放性,为适应不断发展的国土空间规划编制管理实践预留了接口。
|
||||||
|
|
||||||
|
以服务为导向,注重共建共享共治。规划“一张图”系统是保障各级政府、各相关部门单位、社会公众共同参与国土空间治理的数字化基础设施。国土空间数据是数字空间的基础数据底盘。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》要求规划“一张图”系统以数据为“细胞”,加强多源数据归集,赋能国土空间智慧治理;以共建共享为原则,分类有序推进上下级、部门间数据共享,鼓励跨部门跨层级多场景应用开发;设置社会公众服务模块,在满足公开公示、意见征询、公众监督等基本功能基础上,着力对接民众急需的高频应用场景,切实加强规划的公众参与,提高人民群众的获得感、幸福感和安全感;旨在依托规划“一张图”系统推动国土空间治理机制流程的重塑、治理方式效能的提升,把数据优势切实转化为治理优势,促进国土空间治理体系和治理能力的质量变革、效率变革、动力变革。
|
||||||
|
|
||||||
|
\subsubsection{2.系统功能的完善}
|
||||||
|
《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》要求,各地推进规划“一张图”系统建设应本着节约和从实际需求出发的原则,统筹协调相关已建成的国土空间、土地调查、环境保护、农田、林业、水利、草原、水系等方面的基础地理信息化工作,整合既有地理信息要素资源和数据资源。
|
||||||
|
|
||||||
|
加强相关国土空间规划数据的标准规范、数据统一。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》要求基于国土空间基础信息平台要强化标准的规范性建设和数据格式与基础信息的统一平台建设。依据统一的测绘基准以及统一的用地用海分类标准,集成基础地理信息和自然资源调查监测成果数据,形成覆盖全域、三维立体、权威统一的国土空间数字化“底板”。在此基础上,逐级汇交纳入国土空间总体规划、详细规划成果数据,叠加相关专项规划成果数据,形成国土空间规划“一张图”。同时,着力营造安全、开放、充满活力的数字化治理生态,充分运用物联网、5G、大数据、云计算、区块链、人工智能等先进技术,促进国土空间数据与经济社会综合数据的融合应用,推动建设全要素、多类型、全覆盖、实时更新的权威国土空间数据库,将海量数据转化为“数据红利”,夯实“可感知、能学习、善治理、自适应”的智慧规划建设基础。
|
||||||
|
|
||||||
|
推动多元化国土空间规划“一张图”数据的共建共享。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》要求开发专项规划与“一张图”的核对衔接功能,对专项规划开展符合性审查,并将经衔接一致的专项规划成果纳入规划“一张图”系统,统一各类开发保护建设活动的国土空间用途管制依据。同时,强调要为其他部门提供规划成果数据共享。“共建”与“共享”是一个硬币的两面,相辅相成、互为促进,共建是为了更高水准的共享,共享有助于推动更广范围的共建。《若干意见》提出,“整合各类空间关联数据,建立全国统一的国土空间基础信息平台。”国土空间基础信息平台积累了原国土资源部时期以来丰富的调查监测、地理国情、遥感影像等数据,是支撑包括规划“一张图”系统在内的各部门涉空间治理信息系统和应用的统一数据基底。为不折不扣落实党中央“多规合一”改革的重大决策部署,各地在推进有关信息系统和应用建设过程中,应严格按照中央文件要求,将各类空间关联数据统一整合到国土空间基础信息平台上来,为智慧城市、城市信息模型等各类应用提供统一的空间数据服务支撑,坚决杜绝各自为政、另搞一套,影响统一国土空间用途管制制度落地;最大程度避免财政重复投资,发挥出行政资源的集成综合效应。
|
||||||
|
|
||||||
|
强化国土空间规划“一张图”的数据贯通传导。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》以因地制宜、统分结合为原则,要求国、省、市、县分级完成规划“一张图”系统建设,实现上下贯通,确保规划目标要求逐级传导落地。同时,鼓励各地扩展开发符合当地需要、更加精细化的应用场景和功能模块,增强动态感知城乡发展态势、智能分析城乡空间治理问题短板的能力,促进政府决策科学化、社会治理精细化、公共服务高效化。
|
||||||
|
|
||||||
|
增进国土空间规划“一张图”的开放共享。《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》鼓励以方便企业和群众为出发点和落脚点,在应用层开发完善服务社会公众、企事业单位、科研院所的相关功能模块,鼓励相关机构与个人接入“一张图”数据基础平台,构建国土空间规划“一张图”领域的大众创新、万众创业。增进“一张图”基础新平台与外部数据接口的畅通沟通渠道,支持科学研究、创新创业、便民服务以及公众展示等各类型数据平台开发服务,打造基于国土空间规划“一张图”的宜居、宜业、宜游的数字开放城市。
|
||||||
|
|
||||||
|
\subsubsection{3.系统协同以构建“一张图”系统}
|
||||||
|
各级自然资源主管部门要进一步深入贯彻落实党中央国务院的“多规合一”改革的重大决策部署,深刻认识规划“一张图”系统在推进国土空间治理体系和治理能力现代化、提升城乡治理水平、服务民生改善等方面的重要作用和重大意义,按照各级国土空间规划的统一工作部署和《国土空间规划“一张图”实施监督信息系统技术规范(GB/T 39972-2021)》要求,充分发挥主观能动性和创造性,加快推进系统建设,完善配套制度规则,拓展丰富应用服务。
|
||||||
|
|
||||||
|
积极推动国土空间规划“一张图”系统构建工作。《自然资源部办公厅关于开展国土空间规划“一张图”建设和现状评估工作的通知》(自然资办发〔2019〕38号)明确要求,未完成系统建设的市县不得先行报批国土空间总体规划。各地应对照《规范》,抓紧完成规划“一张图”系统建设,确保不影响各级规划报批,为国土空间规划实施管理提供及时的技术支撑(如图5.10所示)。
|
||||||
|
|
||||||
|
构建国土空间规划“一张图”的意义在于解决规划的矛盾与冲突,实现主体功能区战略格局精准落地、优化国土空间格局等﹒具体体现如目标对接、技术标准对接、用地分类对接等多方面的矛盾,在统一的基础上,完成数字工作底图的绘制,为区域空间布局及成果编制奠定基础;开展“双评价”工作(资源环境承载能力评价和国土开发适宜性评价),科学划定“三区三线”,统筹全域空间,确保主体功能区的精准落地;“一张图”是统领区域各规划的总图,基于区域的本底条件与基础,构建科学、合理的城乡用地、农业发展和生态安全格局,引导国土空间格局良性发展,从而有效提升管控效率。
|
||||||
|
|
||||||
|
国土空间规划“一张图”的构建,能够打通数据采集、融合、共享和应用全链条,让数据“跑”起来。数据的生命在于应用。要进一步强化主动服务意识,以用促建,在数据汇集、系统集成、上下联动、有序共享上下更大功夫,不断提高数据汇集的能力、效率和质量,持续提升数据分析应用的水平。
|
||||||
|
|
||||||
|
加大探索创新力度,延伸拓展国土空间规划“一张图”系统的多场景应用。结合国家创新创业的发展战略机遇和科研机构、小微企业、便民服务等多类型用户需求视角,积极延伸互联网+国土空间规划“一张图”的应用场景,会同有关部门,在城乡治理、民生需求的重点领域,聚焦社会经济发展重大需求和国计民生的重点问题,针对性地开发应用场景,助力智慧城市与数字乡村建设,努力提供更多普惠便捷、优质高效的数字服务,让人们更好共享信息化发展成果。
|
||||||
|
|
||||||
|
加强完善配套体系标准,建立健全国土空间规划“一张图”制度建设。加快制定公开公正的国土空间规划“一张图”制度建设,增强规则层面的“一张图”建设内容,完善制度建设“一张图”与技术层面的“一张图”的统筹协调。健全完善有利于“多规合一”、有利于共建共享的规划“一张图”系统建设应用规则标准,推进业务标准化、工作流程化、管理规范化。同时,统筹发展与安全,严格落实安全发展的要求,建立健全管理制度,保证数据安全。
|
||||||
|
|
||||||
|
图 5.10国土空间规划“一张图”应用与指标模型管理功能
|
||||||
|
|
||||||
|
\section{国土空间规划管理信息平台}
|
||||||
|
《自然资源部办公厅关于开展国土空间规划“一张图”建设和现状评估工作的通知》(自然资办发〔2019〕38号)明确指出,国土空间基础信息平台(以下简称“平台”)是形成国土空间规划“一张图”的基础载体,省、市、县各级所建平台既要能够与国家级平台对接,全面实现纵向联通,亦要能够与其他相关部门信息平台横向联通、数据共享(如图5.11所示)。
|
||||||
|
|
||||||
|
平台建设首先需要完成数据资源体系建设,建立资源中心,保证各类数据能够“进得来”、“管得住”、“出得去”。其次,平台需要提供服务的统一管理,建立服务中心,促进政府部门的现有数字基础设施的建设成果向资产化、服务化方向发展,提升政务服务水平。
|
||||||
|
|
||||||
|
基于国土空间基础信息平台,构建国土空间规划“一张图”实施监督信息系统,有效支撑国土空间规划分析评价和现状评估,识别风险,发现问题;支撑国土空间规划成果审查,落实规划逐级传导与管控;有效支撑用途管制,保障空间管控要求落地;有效支撑监测评估预警,对约束性指标和管控边界实现动态监测,对目标执行情况、开发保护现状等方面开展评估,对底线突破有风险、目标执行不力以及疑似违法等行为进行及时预警。从而实现规划、实施、监测、评估和预警全过程的信息化支撑,全面提升空间治理体系和治理能力现代化水平。
|
||||||
|
|
||||||
|
图 5.11国土空间基础信息平台
|
||||||
|
|
||||||
|
\subsection{满足国土空间规划需求}
|
||||||
|
国土空间规划“一张图”管理信息平台要满足全流程、多层级的国土空间规划建设和管理的业务需求。相关国土空间规划的管理业务应对的是各级国土空间规划主管部门的国土空间规划管理事权,支撑分级分类型、全域全要素和全程全方位的国土空间规划体系的建立并监督实施。整个业务过程和相关工作需求主要包括各级各类国土空间规划的编制(修编)、各级各类国土空间规划的规划审批、规划实施、规划监督和规划评估,要将业务需求形成闭环管理模式。系统应穿透国土空间规划的全覆盖、全流程、全生命周期的管理系统。
|
||||||
|
|
||||||
|
\subsection{构建标准的数据体系}
|
||||||
|
国土空间规划“一张图”管理信息平台系统要服务规划全业务过程闭环,实际上就是要能够形成多套图,包括服务规划编制的“一张图”,规划实施的“一张图”、规划监督的“一张图”和监测预警评估的“一张图”,并依工作要求对应层层打开或关闭。因此,上述要求绝不是机械的、简单地完成数据的罗列堆砌和叠加展示所能解决的,而必须要构建一套基于业务需求、管理机制、应用要求的数据资源体系。在“一张图”系统构建初期就要从数据工程的角度,构建规范化、标准化的数据管理规范,解决整个系统的数据构成、数据关系、数据标准及质量管控、动态更新的问题。
|
||||||
|
|
||||||
|
按照这个要求,“一张图”的具体建设就需要考虑以下几个重点:
|
||||||
|
|
||||||
|
(1)梳理出“一张图”数据资源目录体系。理清每一项数据的业务来源、生产和管理单位、业务使用场景。
|
||||||
|
|
||||||
|
(2)建立统一、可以执行的数据标准规范体系。包括数据生产和采集规范、存储和管理规范、应用和共享规范等。
|
||||||
|
|
||||||
|
(3)建立可执行的数据动态更新机制。“一张图”的建设,不是一次性工程,而是需要让数据动起来,用起来,能够为将来国土空间规划的编制、管理、实施、监督提供决策依据,使管控要素精确落地。所以,明确主体责任、设立专门的保障部门,将数据的动态更新机制落到可执行层面非常关键。
|
||||||
|
|
||||||
|
(4)提供标准化的配套工具保障。从数据资源可用性角度出发,必须建立完善的数据管理制度,但制度的最终落地执行,则需要依赖一套工具支撑。
|
||||||
|
|
||||||
|
\subsection{国土空间规划信息平台的建设}
|
||||||
|
为了响应国土空间规划的治理目标,支持国土空间规划全环节业务管理,系统在应用层面,需要形成一套覆盖国土空间规划编制、审批、实施、监测、评估、预警全过程的功能体系。通过开放共享以及多类型多权限管理等方式,增进国土空间规划“一张图”管理信息平台的应用场景建设和应用服务领域建设。系统应用体系的搭建,服务于国土空间规划的改革、服务于规划管理。所以,在搭建应用体系的时候,既要考虑功能背后的管理诉求,也要考虑功能之间的横向联动,纵向对接,还要考虑功能的随需扩展。
|
||||||
|
|
||||||
|
\subsection{构建稳健中台保障体系}
|
||||||
|
国土空间规划“一张图”管理信息平台系统建设,跟过去“规划一张图”、“国土一张图”、“多规合一一张图”最大的不一样,就是业务上的贯穿,不是为了解决单一业务问题,而是注重在纵向的垂直管理、横向的空间治理、环向业务过程不同维度的系统性支撑。随着整个国土空间规划工作的逐渐递进和深化,未来还涉及更多的功能拓展和延伸。
|
||||||
|
|
||||||
|
如果在建设之时仅仅局限或关注解决某个点状问,建立一个或几个业务应用,那么显然是会造成业务和信息孤岛,不可持续。为此,跟国土空间规划要“四梁八柱”的框架支持一样,建设系统,也应当从整体性、系统性、协同性、可扩展性等技术层面考虑进行技术体系的搭建,从技术层面解决信息孤岛和无法支撑全业务环节的数据流动问题,解决全过程数字化决策在数据库管理、指标管理、模型管理的衔接问题,解决业务随需应变、持续深化的拓展问题,保障上层应用系统不至于成为“一次性工程”。
|
||||||
|
|
||||||
|
要坚持稳健、完善的中台系统建设,丰富标准化的中台架构及配套研发运营体系的深入理解和落地,要通过将技术和业务能力进行沉淀,为“前台”业务变化及创新的快速响应提供专业、高价值、低成本、可复用的专业能力。按照中台架构进行能力的解耦合增强,形成数据中台、技术中台、业务中台三大中台,为国土空间规划提供有一定领域和行业深度的产品和能力,有效支撑系统的建设实施。
|
||||||
|
|
||||||
|
\subsection{建设“一张图”平台服务体系}
|
||||||
|
新时期的国土空间规划“一张图”管理信息平台是针对国土空间全域全要素的规划,涉及多领域、多学科,要落实建立国土空间规划体系并监督实施的目标,就需要按照软件工程化到角度,面向用户提供全环节的服务支持。要将国土空间规划“一张图”管理信息平台作为我国社会经济高质量发展和创新驱动国土空间高质量发展的重要保障设施,加强国土空间规划“一张图”管理信息平台对接社会经济发展的重大需求,满足科研机构、企事业单位、人民群众等多元化的地理信息数据服务需求。基于标准、流程和经验的融合,精细把控业务、标准、数据、应用、技术等各环节,为建设能用、管用、实用的系统提供坚实的服务支撑(如图5.12所示)。
|
||||||
|
|
||||||
|
(1) “一张图”的系统支撑能力的增强
|
||||||
|
|
||||||
|
国土空间规划“一张图”管理信息平台系统建设涉及标准、数据、指标模型、功能、运维一系列的工程建设;横向需要与政务服务系统、多规合一系统、国土空间基础信息平台对接;纵向需要与不同层级的系统对接。通过数据标准模板、数据资源规划与数据工程纲要、指标模型基础算法、成熟产品体系、接口规范实践对每一项工作进行有力的工程支撑。
|
||||||
|
|
||||||
|
(2)“一张图”的建设内容的完善
|
||||||
|
|
||||||
|
国土空间规划“一张图”管理信息平台建设由于其业务的复杂性、综合性,具有干系人复杂、涉及多部门、多团队的特点。为了使工作有序开展、按时完成,需要科学运用项目管理的相关理论知识和方法,各业务管理部门、信息中心、规划编制单位、软件开发单位、数据处理单位等应明确分工与时序。在承建单位内部,要形成合理的项目组织结构,划分标准规范、数据资源、指标模型、应用系统、硬件保障等不同的职能小组,明确各小组的职责,指定小组成员和负责人。最终,各部门、多团队衔接协同,才能保障国土空间规划“一张图”建设的各项工作能够有序落实。
|
||||||
|
|
||||||
|
(3)“一张图”的技术体系的创新
|
||||||
|
|
||||||
|
国土空间规划“一张图”管理信息平台建设要增强创新技术体系的建设。打造建设能用、管用、实用的系统,在有效的团队协作之上,还要建立有序的工程方法,不能仅仅关注于系统功能的开发实现,而是需要实现从需求调研、业务分析、系统设计、编码实现、集成部署、系统测试、试用反馈到上线运行、项目交付的全过程贯穿。针对系统建设的业务需求具有不确定性和多样性的特点,鼓励“一张图”管理信息平台建设实施过程中各类创新技术、创新管理以及创新模式等方面对系统建设涉及的业务逻辑和系统逻辑,创新驱动国土空间规划编制、审批(查)、实施、监督各环节的业务流程和管理规范,在保证用户需求被准确传递的同时,让业务应用系统开发人员能够实行高效、准确的沟通交流,使架构师、业务分析员和项目经理等人员能够有效介入项目进度和质量的全程管控。
|
||||||
|
|
||||||
|
(4)稳定的“一张图”运维体系的打造
|
||||||
|
|
||||||
|
国土空间规划“一张图”管理信息平台在系统上线运行之后,仍然需要与业务部门的日常工作进行持续进行数据更新与运维系统的不断升级。系统在使用过程中因为各种各样的问题,同时,在“全网通办”、“数字政务服务”等营商环境完善战略的要求下,全国不少省、市都明确了业务系统向“互联网+政务服务”的转变过程,强调了便捷性与营商环境改善的重要性,对“一张图”管理信息平台的运维工作提出了新的挑战。这些问题都需要通过日常专业的运维服务来解决。打造持续稳定的“一张图”更新与运维服务体系,通过建立包括自动化监控、系统安全加固、政务云迁移、容灾建设等内容的运维服务体系,保障系统的稳定运行、数据的持续更新、功能的升级完善,降低运维管理压力和成本。
|
||||||
|
|
||||||
|
国土空间规划“一张图”实施监督信息系统集中承载了新时期国土空间规划的治理逻辑,要让系统在各级自然资源部门中发挥实际作用,就必须摆脱“建系统”和拼功能的思维,转向系统性、整体性、协同性思维,在理解新时代国土空间规划的治理逻辑、用户业务特性和现实需求的前提下,按照系统性、工程化、持续化的角度,综合考虑业务、数据、应用和技术平台等内容,给用户提供一个实用、好用、管用的系统。
|
||||||
|
|
||||||
|
图5.12国土空间一张图系统
|
||||||
|
|
||||||
|
第5章国土空间规划的技术工具与方法 148
|
||||||
|
|
||||||
|
5.1国土空间规划基础方法 148
|
||||||
|
|
||||||
|
5.1.1调查勘测方法 148
|
||||||
|
|
||||||
|
5.1.2系统评价方法 149
|
||||||
|
|
||||||
|
5.1.3预测分析方法 160
|
||||||
|
|
||||||
|
5.1.4系统制图方法 164
|
||||||
|
|
||||||
|
5.2国土空间规划空间分析技术 165
|
||||||
|
|
||||||
|
5.2.1地理信息系统(GIS)空间分析 165
|
||||||
|
|
||||||
|
5.2.2遥感(RS)技术 167
|
||||||
|
|
||||||
|
5.3国土空间规划决策方法 169
|
||||||
|
|
||||||
|
5.3.1空间决策博弈方法 169
|
||||||
|
|
||||||
|
5.3.2空间决策支持系统 169
|
||||||
|
|
||||||
|
5.3.3空间模拟与仿真技术 171
|
||||||
|
|
||||||
|
5.4国土空间规划“一张图”系统 173
|
||||||
|
|
||||||
|
5.4.1国土空间规划“一张图”建设步骤 173
|
||||||
|
|
||||||
|
5.4.2国土空间规划“一张图”实施路径 175
|
||||||
|
|
||||||
|
5.4.3国土空间规划“一张图”实施意义 176
|
||||||
|
|
||||||
|
5.5国土空间规划管理信息平台 180
|
||||||
|
|
||||||
|
5.5.1满足国土空间规划需求 181
|
||||||
|
|
||||||
|
5.5.2构建标准的数据体系 181
|
||||||
|
|
||||||
|
5.5.3国土空间规划信息平台的建设 182
|
||||||
|
|
||||||
|
5.5.4构建稳健中台保障体系 182
|
||||||
|
|
||||||
|
5.5.5建设“一张图”平台服务体系 183
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 类型 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 工程勘测 & 航片判读调绘、遥感图像识别、工程测绘 \\
|
||||||
|
2 & 国土调查 & 分布数量、权属、利用结构、现状问题调查 \\
|
||||||
|
3 & 资源调查 & 山水林田湖草沙等自然资源的调查 \\
|
||||||
|
4 & 在野调查 & 生态环境、农业环境、水体现状等野外踏查与评测 \\
|
||||||
|
5 & 人居环境调查 & 城、镇、乡村各级各类人居空间人口、交通网络、生产力布局与经济环境等的调查 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 分类 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 国土文字信息 & ①调查与收集规划区内国土资源的种类、数量、质量、规模、分布、组合、结构数据与信息。②收集规划区周边地区及评价区域的上一级行政区域的国土资源特征及开发利用保护现状数据和信息。③收集与调查规划区国土资源开发利用保护历史与现状资料。④收集规划区的经济社会发展状况资料。⑤收集规划区国土资源及其开发利用保护的科学研究成果档案资料。⑥收集国内外国土资源开发利用比较成功的国家和地区国土资源开发利用研究成果及经验数据资料。⑦收集与调查各类国土资源的开发利用保护规划及实施效果、各种国土资源调查评价报告等 \\
|
||||||
|
2 & 国土图像信息 & ①图像信息包括遥感图像,如航空相片、卫星图片以及各种地图。彩色航空相片,可用于国家和区域制定综合的国土资源开发利用保护规划,还可以用于各种专项的国土空间整治规划,同时还可以被利用于探测地壳变动,判断森林、水体等各种资源的数量消长,发现淹没迹地等,或是地形、地质、土壤、土地利用及其他有关国土空间基础调查研究的重要资料。②地图可划分为综合地图和专题地图。专题地图是指对特定主题有突出表现的地图,主要以自然条件为对象的,如水文地质、地形分类、植被、湖沼图等;有主要以人文条件为对象的,如地籍图、行政区划图、道路图、工业分布图等;③自然与经济社会相关的图,如土地分级图、土地利用图等 \\
|
||||||
|
3 & 国土数值信息 & 国土数值信息有表示位置的坐标资料与特定网格内属性的网格资料。它是将地形、地质、土壤、高程、土地利用现状、流域、铁路与道路、湖泊与海岸线,行政界线及重大工程、公共设施等有关信息,通过网格或坐标的形式,存储于纸、光盘、磁盘等相关媒介中形成的。网格信息作为表示位置数值化的方法,原则上是采用经纬线将地区分为网格状的“标准区域网格”。标准网格,是按大体等形、等积进行划分的。网格的大小,是由区域的特点、研究工作的深度及精度所决定的。坐标信息包括重大工程、公共设施等点的信息,以及海岸线、行政界线、河川、道路等线状信息 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 来源 & 类型 \\
|
||||||
|
\hline
|
||||||
|
1 & 政府部门的数据档案 & 统计年鉴、经济发展年鉴、环境统计年鉴、城市统计年鉴、国土资源开发利用保护及经济社会发展计划、工作总结、研究报告等 \\
|
||||||
|
2 & 各部门各行业规划资料 & 土地利用规划、矿产开发利用规划、农业发展规划、农业区划、林业发展规划、水资源开发利用规划、旅游发展规划、环境整治规划等 \\
|
||||||
|
3 & 典型调查勘测资料 & 城乡居民收入调查、人口普查、国土资源大调查、耕地普查等,也可以是各行业和科研部门、企事业单位组织的调查勘测资料 \\
|
||||||
|
4 & 地图和遥感资料 & 地质图、地形图、矿产资源图、土壤图、水资源分布图、土地利用现状图、航空相片、卫星图像等 \\
|
||||||
|
5 & 各类政策法规资料 & 各级权力机构的法令、法规、政策、政府工作报告等 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 重要意义 & 具体内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 优化国土空间开发格局和协调“三生空间”的现实需要 & 控制区域国土开发强度,调整空间功能布局结构,促进生产空间集约高效、生活空间宜居适度、生态空间山清水秀,亟须以资源环境承载力评价为依据,对国土空间进行分区分类管理,并严格加强用途管制。 \\
|
||||||
|
2 & 主体功能区规划编制和空间用途管制的必要前提 & 主体功能区划明确将国土空间划分为不同的分区,不同主体功能区资源环境承载力的差异,使得其国土空间用途、开发利用与保护方式各异;同时,划定生产、生活、生态空间开发管制界限,落实用途管制,明确资源环境承载力与国土空间开发保护的关系,也要以承载力相应指标的监测作为国土空间用途管制的基础与依据。 \\
|
||||||
|
3 & 建立国土空间规划体系的重要基础 & 国土空间规划体系是全域全要素的综合性空间规划,需要从空间层面上对地区产业、人口等要素的集聚特征,以及资源环境要素的整合效应进行综合把控,也必须将资源环境承载力评价作为规划编制的重要基础。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 方法 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 多要素叠置分析法 & 基于一系列评价指标,通过数学模型处理和指标加权,展开叠置分析,最终得出一个表征资源环境承载水平的综合指数 \\
|
||||||
|
2 & 比较法 & 首先选定一个资源环境承载状态合理的区域,然后将研究区域的各项指标与其对比,从而评价研究区的承载情况 \\
|
||||||
|
3 & 短板原理法 & 依据短板效应,认为区域综合承载力水平最终取决于对经济社会发展具有“瓶颈”作用的制约因素,在计算出各个单因素承载力后,取各单因子最小值作为最终的综合承载水平 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 方法 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 资源环境要素单项评价 & 按照评价对象和尺度差异遴选评价指标,对土地资源、水资源、环境、生态和灾害五类自然要素进行单项评价,并针对不同功能指向与评价层级分别构建相应的评价指标与方法。具体而言,土地资源评价涵盖坡度、高程、土壤质地等指标,以农业生产和城镇建设为功能指向;水资源评价涵盖降水量、水资源可利用量等水资源丰度指标,以农业生产和城镇建设为功能指向;环境评价涵盖土壤、大气和水环境容量等指标,以农业生产和城镇建设为功能指向;生态评价涵盖生态系统服务功能重要性、生态敏感性和盐渍化敏感性等指标,以生态保护和农业生产为功能指向;灾害评价涵盖气象灾害、地质灾害和风暴潮灾害危险性等指标,以农业生产和城镇建设为功能指向。 \\
|
||||||
|
2 & 资源环境承载力集成评价 & 基于资源环境要素单项评价的分级结果,根据生态保护、农业生产、城镇建设三方面的差异化要求,综合划分生态指向的生态保护等级以及农业、城镇指向的承载力等级,表征国土空间的自然本底条件对人类生活生产活动综合支撑能力;承载力等级按取值由低到高划分为一级、二级、三级、四级、五级等若干不同的等级。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 内涵 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 人类发展 & 即城镇建设空间的选址应在自然条件良好、避开灾害风险的稳定区域,以保障人类安全与发展需要; \\
|
||||||
|
2 & 粮食保障与经济效率 & 即在保障粮食供应等人类生活基本需要的同时,形成高效有序的经济空间组织形态; \\
|
||||||
|
3 & 生态安全 & 即要求各类开发建设活动需与自然生态、资源环境本底条件相协调,维护生态系统服务功能并实现可持续发展。以国土空间开发适宜性评价为基础,实施国土空间用途管制并进行“三区三线”等区界的划定,这是国土空间规划编制的核心内容之一。国土空间开发适宜性评价是优化国土空间开发格局,合理布局农业、生态与建设空间的基础和依据。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 评价方面 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 生态保护重要性评价 & 开展生态系统服务功能重要性评价、生态感性评价,集成得到生态保护重要性,划分为极重要、重要、一般等不同等级。生物多样性维护、水源涵养、水土保持、防风固沙、海岸防护等生态系统服务功能越重要,或水土流失、石漠化、土地沙化、海岸侵蚀等生态敏感性越高,且生态系统完整性越好、生态廊道的连通性越好的区域,其生态保护的重要性等级越高。 \\
|
||||||
|
2 & 农业生产适宜性评价 & 开展农业生产的土地资源、水资源、气候、环境、生态、灾害等单项评价,集成得到农业生产适宜性,则分为适宜、一般适宜、不适宜等不同等级。地势越平坦,水资源丰度越高,光热越充足,土壤环境容量越高,气象灾害风险越低,且地块规模和连片程度越高的区域,其农业生产适宜性的等级越高 \\
|
||||||
|
3 & 城镇建设适宜性评价 & 开展城镇建设功能指向的土地资源、水资源、气候、环境、灾害、区位等单项评价,集成得到城镇建设适宜性,划分为适宜、一般适宜、不适宜等不同等级。地势越平缓,水资源越丰富,水气环境容量越高,人居环境条件越好,自然灾害风险越低,且地块规模和集中程度越高,地理及交通区位条件越好的区域,其城镇建设适宜性的等级越高。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|c|c|}
|
||||||
|
\hline
|
||||||
|
评价类型 & 战略定位 & 管理导向 & 空间治理 & 出口指向 \\
|
||||||
|
\hline
|
||||||
|
资源环境承载力评价 & 约束性 & 严格调控 & 空间保护 & 数量规模 \\
|
||||||
|
资源环境承载力评价 & 优先关注的是“承载力的超载”“超限”“高压力”“红色预警”等。 & 承载力状态好坏是判断管理政策是否需要干预的晴雨表,评价结果侧重为“政策调控”做依据。 & 宏观上倒逼国土空间规划对部分“高承载”区域进行空间保护;微观上为管理部门提供线索,调整治理时序。 & 以数量规模为评价出口。通过分析资源环境各因子的量化数据,筛查出短板要素,对国土空间开发利用格局形成规模约束。 \\
|
||||||
|
国土空间开发适宜性评价 & 发展性 & 积极引导 & 空间开发 & 空间布局 \\
|
||||||
|
国土空间开发适宜性评价 & 优先关注的适宜区,重在辨识区域某项开发活动最具潜力的区域。 & 适宜性大小是判断空间综合功能效用是否可以发挥到最大的标准,评价结果可引导国土空间规划的合理布局。 & 宏观上探索高效有序的经济空间组织形态和空间结构;微观上考虑地区发展模式与管理成本收益,深入认知区域开发的综合效益。 & 以空间布局为评价出口。通过应用评价结果,划定三区三线,对国土空间开发利用格局开形成空间约束。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
国空规划的决策作用 & 国空规划的实践内容 & 国空规划的编制过程 \\
|
||||||
|
\hline
|
||||||
|
先后关系 & 并列关系 & 辩证统一关系 \\
|
||||||
|
资源环境承载力评价为国土空间规划实施提供基础判断,可以说是国空规划开展的“决策基础”;
|
||||||
|
国土空间开发适宜性评价为国土空间规划实施奠定深层的理解与引导,可以说是国空规划进行的“决策引领”。 & 在《中共中央国务院关于加快推进生态文明建设的意见》提出了“树立底线思维、设定并严守资源消耗上限、环境质量底线、生态保护红线,将各类开发活动限制在资源环境承载能力之内”,其中三大任务需要双评价分别实现,因此二者之间存在并列关系 & 矛盾关系表现在现状与理想潜能本身就存在矛盾关系:承载力评价侧重阐述开发的现存状态、适宜性评价侧重阐述开发的理想潜能。统一关系表现在两者缺一不可:国空规划本质就在于区域空间要素的重构,离不开对现状的参考,也离不开对理想潜能的预测。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 原理 & 概要 \\
|
||||||
|
\hline
|
||||||
|
1 & 惯性原理 & 事物的发展和系统的运行在没有受到外力强烈干扰的情景下,通常都会有一定的惯性,即过去和现在的情景将会持续到未来。 \\
|
||||||
|
2 & 类比原理 & 它是根据两个具有相同或相似特征的事物间的对比,从某一事物的某些已知特征去推测另一事物的相应特征,从而对预测对象的未来作出判断的预测方法。类比方法是在两个特殊事物之间进行分析比较,它不需要建立在对大量特殊事物分析研究并发现它们的一般规律的基础上。因此,它可以在归纳与演绎无能为力的一些领域中发挥独特的作用,尤其是在那些被研究的事物个案太少或缺乏足够的研究、科学资料的积累水平较低、不具备归纳和演绎条件的领域 \\
|
||||||
|
3 & 关联原理 & 在社会经济系统中,许多社会经济变量之间常存在着关联关系或相关关系,如正相关、负相关等。通过这种关联关系分析,就可以对预测事物的未来变化进行判断。多元回归分析的预测技术,就是根据这种关联原理,从样本对整体进行估计、验证和模拟,对事物的未来进行预测 \\
|
||||||
|
4 & 概率原理 & 它是指任何事物的发展都有一定的必然性和偶然性,社会经济发展过程也不例外。通过对事物发展偶然性的分析,找出其发展规律,从而进行预测。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 方法 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 聚类分析法 & 利用统计手段进行聚类分析,可以从影响自然、经济和社会等指标对区域内各地区进行分析,找出地区间的差异和地区经济发展特征,这是国土空间规划分区的重要参考基础 \\
|
||||||
|
2 & 空间叠置法 & 适用于规划图和区划图齐全的情况下,将有关图件上规划界线重叠在一起,以确定共同的区界。对于不重叠的地方要具体分析其将来主导的国土空间用途并据以取舍 \\
|
||||||
|
3 & 综合分析法 & 一种带有定性分析的分区方法,主要适用于区域差异显著、分区明显易定的情况,要求操作人员非常熟悉当地的实际情况,一般为专家个人或集体 \\
|
||||||
|
4 & 主导因素法 & 它是在微观的规划单元划分基础上,适当地加以归并,逐步扩大国土空间利用区,再将地域相连的类型区合并成为区域,以主导的国土空间用途作为国土空间区域名称 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 分区 & 具体内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 自然分区 & 它是根据国土空间地质、地貌、气候、水文、土壤和生物等因素及其发生、发展和演替方向的相对一致性所划分的自然地理综合体。 \\
|
||||||
|
2 & 经济分区 & 它是根据经济发展的资源条件、经济发展水平、经济发展内在联系、经济发展目标和方向的相对一致性所划分的地域生产综合体 \\
|
||||||
|
3 & 行政分区 & 它是按照地方政权存在的区域和管辖范围所划分的行政管理地域单元。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 决策支持方式 & 具体内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 基于空间数据形式的决策支持 & 数据是事物的特征和状态的数量化表现。在国土空间规划中涉及大量的空间数据、属性数据和统计数据,如何利用这些数据快速、高效提取规划信息,这是智能决策支持,开放的GIS空间数据库为数据支持提供了数据平台。 \\
|
||||||
|
2 & 基于模型和方法形式的决策支持 & 研究人员为了描述国土空间的变化规律建立了大量的模型和方法,均可在GIS平台上实现,特别是利用GIS平台的空间分析功能等,为辅助决策提供支持。 \\
|
||||||
|
3 & 基于知识主导形式的决策支持 & 在建立模型库和方法的基础上,建立知识库推理机制,结合模拟仿真技术、演进技术,人机交互系统,基于Web技术的异构终端,使利益相关者高效地参与决策过程,最终形成决策方案。 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 问题 \\
|
||||||
|
\hline
|
||||||
|
1 & 要明确国土空间规划的三个迭代及六个过程模型 \\
|
||||||
|
2 & 要明确系统开发时间的要求 \\
|
||||||
|
3 & 对系统开发费用的要求 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{table}[H]
|
||||||
|
\centering
|
||||||
|
\begin{tabular}{|c|c|c|}
|
||||||
|
\hline
|
||||||
|
序号 & 特性 & 内容 \\
|
||||||
|
\hline
|
||||||
|
1 & 科学性 & 系统需采用新思想、新技术。在空间数据库设计、系统功能设计方面应具有科学、清晰的结构与组织,为满足决策分析的需求,确保系统运行的稳定 \\
|
||||||
|
2 & 实用性 & 系统应结构简洁,操作方便,界面友好 \\
|
||||||
|
3 & 规范性 & 系统与国内主流空间数据库能很好地接轨,应遵循统一、规范的信息编码和坐标系统、规范的数据精度和符号系统 \\
|
||||||
|
4 & 可扩充性 & 系统应具有一定兼容性,方便系统将来的升级与移植 \\
|
||||||
|
\hline
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
|||||||
|
\documentclass[12pt,a4paper]{book}
|
||||||
|
\usepackage[UTF8]{ctex}
|
||||||
|
\usepackage{geometry}
|
||||||
|
\geometry{left=2.5cm,right=2.5cm,top=3cm,bottom=3cm}
|
||||||
|
\usepackage{graphicx}
|
||||||
|
\usepackage{amsmath}
|
||||||
|
\usepackage{amsfonts}
|
||||||
|
\usepackage{amssymb}
|
||||||
|
\usepackage{hyperref}
|
||||||
|
\usepackage{booktabs}
|
||||||
|
\usepackage{longtable}
|
||||||
|
\usepackage{multirow}
|
||||||
|
\usepackage{array}
|
||||||
|
\usepackage{float}
|
||||||
|
\usepackage{caption}
|
||||||
|
|
||||||
|
\title{国土空间规划:概念、原理、方法}
|
||||||
|
\author{}
|
||||||
|
\date{}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
|
||||||
|
\frontmatter
|
||||||
|
|
||||||
|
\maketitle
|
||||||
|
|
||||||
|
\input{abstract}
|
||||||
|
|
||||||
|
\tableofcontents
|
||||||
|
|
||||||
|
\mainmatter
|
||||||
|
|
||||||
|
\input{preface}
|
||||||
|
\input{chapter01}
|
||||||
|
\input{chapter02}
|
||||||
|
\input{chapter03}
|
||||||
|
\input{chapter04}
|
||||||
|
\input{chapter05}
|
||||||
|
|
||||||
|
\backmatter
|
||||||
|
|
||||||
|
% 如果需要添加参考文献,请取消以下注释并创建references.bib文件
|
||||||
|
% \bibliographystyle{unsrt}
|
||||||
|
% \bibliography{references}
|
||||||
|
|
||||||
|
\end{document}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
\section{前 言}
|
||||||
|
我国作为历史悠久的文明古国,从聚落现象开始到封建社会的发展,从近代开端初始到中西文化交流,空间规划经历了长期的实践探索,为我们积累了丰富的文化遗产和营造理念。建国以后,特别是改革开放以后,随着国家发展环境以及行政体制改革的变化,我国的空间规划逐渐趋于多元,除传统的城乡规划外,逐步出现了土地利用规划、主体功能区划、生态环境保护规划等法定或“主流类型”的空间规划,这些规划的发展为社会主义建设起到了积极的作用和良好的效果,并且基本形成了三个共识:一是各种类型、各个系列的空间规划是国家发展的重要公共政策之一,也是服务于国家经济社会发展的重要的技术性工具之一,更是国家治理体系重要组成部分之一。正基于此,各项规划之间由于各种原因,形成了一定的相互掣肘的问题,迫切需要以“多规合一”的思想,统一到生态文明视角下的国土空间资源体系一盘棋的主要方向上来。
|
||||||
|
|
||||||
|
2019年5月中共中央、国务院印发《关于建立国土空间规划体系并监督实施的若干意见》,提出以习近平新时代中国特色社会主义思想为指导,全面贯彻党的十八大、十九大和历届全会精神,紧紧围绕统筹推进“五位一体”总体布局和协调推进“四个全面”战略布局,坚持新发展理念,坚持以人民为中心,坚持一切从实际出发,按照高质量发展要求,做好国土空间规划顶层设计,发挥国土空间规划在国家规划体系中的基础性作用,为国家发展战略落地实施提供空间保障。新组建的国土资源部将我国国土空间规划体系概括为“五级三类四体系”,标志着我国国土空间规划体系顶层设计“四梁八柱”的基本形成。
|
||||||
|
|
||||||
|
国土空间规划是落实治国理政,推进生态文明建设,提升国土空间治理效能,服务人居环境发展的重要政策工具。国土空间规划能够解决以往规划体系繁杂、各部门规划事权交叉重叠、审批流程复杂等系列问题,是政府调控和引导空间资源配置的基础,编制国土空间规划是生态文明建设、自然资源保护的历史必然选择,是国家国土空间治理体系和治理能力现代化的重要表现。
|
||||||
|
|
||||||
|
按国土空间发展规划,原计划到2020年,基本建立国土空间规划体系,逐步建立“多规合一”的规划编制审批体系、实施监督体系、法规政策体系和技术标准体系;基本完成市县以上各级国土空间总体规划编制,初步形成全国国土空间开发保护“一张图”。目前此项工作还在不断完善之中。2025年,健全国土空间规划法规政策和技术标准体系;全面实施国土空间监测预警和绩效考核机制;形成以国土空间规划为基础,以统一用途管制为手段的国土空间开发保护制度。2035年,全面提升国土空间治理体系和治理能力现代化水平,基本形成生产空间集约高效、生活空间宜居适度、生态空间山清水秀,安全和谐、富有竞争力和可持续发展的国土空间格局。
|
||||||
|
|
||||||
|
国土空间规划全面拉开帷幕,其指导思想、技术标准、法规体系成为学科教学的基本依据;其规划体系、评价方法、用地分类、基本编制原则与内容等构成了国土空间规划基本原理与方法;未来的国土空间规划仍然需要多个学科共同支撑,城乡规划、国土资源管理、生态环境,地理等学科,必将通过深刻的整合和体系的重构,实现国土空间规划学科与人居环境学科的相互支撑、相互融合发展。在以上背景下,本书参考了已陆续出版的各类指导资料,结合国土空间的规划领域人才培养需求,构建了从生态文明到人居环境认知入手的内容体系,以期为人居环境科学向国土空间规划学科转型提供基本教学参考。
|
||||||
|
|
||||||
|
基于此,我们编著了这部国土空间规划原理,本书由哈尔滨工业大学建筑学院吴松涛教授拟定大纲并负责统稿,周小新、苏万庆协助组织全书,全书包括5章,其中第一章由董珂、周小新编写;第二章由吴松涛、王永德编写;第三章由苏万庆、吴冰编写,第四章由吴松涛、周小新、杜立柱编写;第五章由吴远翔、吴冰、许大明编写。
|
||||||
|
|
||||||
|
国土空间是我国面向新时代需求而架构的全新的空间规划体系,其内容的成熟稳定必须要经过长久的探索和积累才能完成,由于编者能力、水平时间有限,书中存在着不当甚至错误之处,甚至可能会出现引用专家观点标注不全面等问题,请各位专家读者不吝指正,我们将在未来的教学和研究中不断的校正、丰富和完善。
|
||||||
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
第1章生态文明下的人居环境和国土空间 1
|
||||||
|
|
||||||
|
1.1我国人居环境发展历程 1
|
||||||
|
|
||||||
|
1.1.1原始时期 1
|
||||||
|
|
||||||
|
1.1.2古代时期 1
|
||||||
|
|
||||||
|
1.1.3近代时期 2
|
||||||
|
|
||||||
|
1.1.4现代时期 3
|
||||||
|
|
||||||
|
1.2我国传统人居环境的理念与人居环境学科发展 9
|
||||||
|
|
||||||
|
1.2.1人居环境的核心理念 9
|
||||||
|
|
||||||
|
1.2.2人居环境的科学发展 11
|
||||||
|
|
||||||
|
1.3生态文明演进与我国国土空间的生态安全问题 12
|
||||||
|
|
||||||
|
1.3.1生态文明的演进 12
|
||||||
|
|
||||||
|
1.3.2我国生态资源的基本情况 13
|
||||||
|
|
||||||
|
1.3.3我国生态资源环境挑战 13
|
||||||
|
|
||||||
|
1.4生态文明建设的基本观念 15
|
||||||
|
|
||||||
|
1.4.1历史思维:“生态兴则文明兴,生态衰则文明衰” 16
|
||||||
|
|
||||||
|
1.4.2全球思维:“共谋全球生态文明建设” 16
|
||||||
|
|
||||||
|
1.4.3底线思维:“坚守资源上限、环境底线、生态红线” 17
|
||||||
|
|
||||||
|
1.4.4民本思维:“良好生态环境是最普惠的民生福祉” 17
|
||||||
|
|
||||||
|
1.4.5整体思维:“坚持人与自然和谐共生” 17
|
||||||
|
|
||||||
|
1.4.6系统思维:“山水林田湖草是生命共同体” 18
|
||||||
|
|
||||||
|
1.4.7辩证思维:“绿水青山就是金山银山” 18
|
||||||
|
|
||||||
|
1.4.8法制思维:“用最严格制度最严密法治保护生态环境” 19
|
||||||
|
|
||||||
|
1.5生态文明建设的总体路径 19
|
||||||
|
|
||||||
|
1.5.1高水平保护——框定生态资源环境安全底线 20
|
||||||
|
|
||||||
|
1.5.2高质量发展——促进要素向优势地区集聚 20
|
||||||
|
|
||||||
|
1.5.3高品质生活——满足人民美好生活需要 21
|
||||||
|
|
||||||
|
1.5.4高效能治理——推进空间治理体系和治理能力现代化 21
|
||||||
|
|
||||||
|
1.6生态文明下的人居环境与国土空间和谐共存 21
|
||||||
|
|
||||||
|
1.6.1探索以生态优先、人居环境绿色发展为导向的新路子 22
|
||||||
|
|
||||||
|
1.6.2生态文明下的国土空间导向 22
|
||||||
|
|
||||||
|
第2章自然资源与土地利用 24
|
||||||
|
|
||||||
|
2.1自然资源的概念、构成与属性 24
|
||||||
|
|
||||||
|
2.1.1自然资源的概念 24
|
||||||
|
|
||||||
|
2.1.2自然资源的类型 24
|
||||||
|
|
||||||
|
2.2土地定义与土地管理制度 26
|
||||||
|
|
||||||
|
2.2.1土地的定义 27
|
||||||
|
|
||||||
|
2.2.2土地制度 28
|
||||||
|
|
||||||
|
2.2.3土地利用规划与管理 31
|
||||||
|
|
||||||
|
2.3国土空间土地用途管制 32
|
||||||
|
|
||||||
|
2.3.1空间用途管制的源起与发展 32
|
||||||
|
|
||||||
|
2.3.2国土空间用途管制的方法 33
|
||||||
|
|
||||||
|
2.3.3国土空间用途管制的内容 34
|
||||||
|
|
||||||
|
2.3.4城乡建设用地的统筹管控 36
|
||||||
|
|
||||||
|
2.3.5土地用途分区 37
|
||||||
|
|
||||||
|
第3章 城乡空间规划思想与理论发展 39
|
||||||
|
|
||||||
|
3.1我国城乡规划思想与理论发展 39
|
||||||
|
|
||||||
|
3.1.1中国古代城市规划思想与理论 39
|
||||||
|
|
||||||
|
3.1.2中国近代城市规划思想与理论 41
|
||||||
|
|
||||||
|
3.1.3新中国成立后城市规划思想与理论 42
|
||||||
|
|
||||||
|
3.2西方城市规划思想与理论发展 46
|
||||||
|
|
||||||
|
3.2.1西方古代城市规划思想与理论 46
|
||||||
|
|
||||||
|
3.2.2西方近代城市规划思想与理论 49
|
||||||
|
|
||||||
|
3.2.3 20世纪以来西方现代城市规划思想与理论 51
|
||||||
|
|
||||||
|
3.2.4 21世纪的城市规划思想与理论变革 57
|
||||||
|
|
||||||
|
3.3我国土地利用规划理论与实践 58
|
||||||
|
|
||||||
|
3.3.1第一轮土地利用总体规划(1986-1996年) 59
|
||||||
|
|
||||||
|
3.3.2第二轮土地利用总体规划(1996-2006年) 59
|
||||||
|
|
||||||
|
3.3.3第三轮土地利用总体规划(2006一2020年) 59
|
||||||
|
|
||||||
|
3.4我国主体功能区规划理论与实践 60
|
||||||
|
|
||||||
|
3.4.1主体功能区的发展历程 60
|
||||||
|
|
||||||
|
3.4.2主体功能区的核心思想 61
|
||||||
|
|
||||||
|
3.4.4主体功能区的理论与技术方法 61
|
||||||
|
|
||||||
|
3.4.5主体功能区的相关实践 62
|
||||||
|
|
||||||
|
3.5我国生态规划理论与实践 63
|
||||||
|
|
||||||
|
3.5.1国内外城市生态理论研究与实践新动向 63
|
||||||
|
|
||||||
|
3.5.2中国生态城市的理论与实践 64
|
||||||
|
|
||||||
|
3.6多规合一与国土空间规划 66
|
||||||
|
|
||||||
|
3.6.1“多规合一”的历程 67
|
||||||
|
|
||||||
|
3.6.2“多规合一”的目标 67
|
||||||
|
|
||||||
|
3.6.3多规合一的政策演进 68
|
||||||
|
|
||||||
|
3.6.4“多规合一”与国土空间规划 69
|
||||||
|
|
||||||
|
第4章 国土空间规划基本原理与体系构成 71
|
||||||
|
|
||||||
|
4.1国土空间规划编制总则与基本术语 71
|
||||||
|
|
||||||
|
4.1.1国土空间规划编制总则 71
|
||||||
|
|
||||||
|
4.1.2国土空间规划基本术语 72
|
||||||
|
|
||||||
|
4.1.3国土空间用地分类 74
|
||||||
|
|
||||||
|
4.2国土空间规划体系 76
|
||||||
|
|
||||||
|
4.2.1国土空间规划体系的“五级” 77
|
||||||
|
|
||||||
|
4.2.2国土空间规划体系的“三类” 79
|
||||||
|
|
||||||
|
4.2.3国土空间规划体系的“四体系” 81
|
||||||
|
|
||||||
|
4.3国土空间规划编制基本方法 82
|
||||||
|
|
||||||
|
4.3.1国土空间规划基本原理 82
|
||||||
|
|
||||||
|
4.3.2国土空间规划管控方法 84
|
||||||
|
|
||||||
|
4.4国土空间规划编制基本内容 86
|
||||||
|
|
||||||
|
4.4.1省级国土空间规划编制基本内容 86
|
||||||
|
|
||||||
|
4.4.2市级国土空间规划编制基本内容 98
|
||||||
|
|
||||||
|
4.4.3县级国土空间规划编制基本内容 114
|
||||||
|
|
||||||
|
4.4.4乡镇级国土空间规划编制基本内容 132
|
||||||
|
|
||||||
|
第5章国土空间规划的技术工具与方法 148
|
||||||
|
|
||||||
|
5.1国土空间规划基础方法 148
|
||||||
|
|
||||||
|
5.1.1调查勘测方法 148
|
||||||
|
|
||||||
|
5.1.2系统评价方法 149
|
||||||
|
|
||||||
|
5.1.3预测分析方法 160
|
||||||
|
|
||||||
|
5.1.4系统制图方法 164
|
||||||
|
|
||||||
|
5.2国土空间规划空间分析技术 165
|
||||||
|
|
||||||
|
5.2.1地理信息系统(GIS)空间分析 165
|
||||||
|
|
||||||
|
5.2.2遥感(RS)技术 167
|
||||||
|
|
||||||
|
5.3国土空间规划决策方法 169
|
||||||
|
|
||||||
|
5.3.1空间决策博弈方法 169
|
||||||
|
|
||||||
|
5.3.2空间决策支持系统 169
|
||||||
|
|
||||||
|
5.3.3空间模拟与仿真技术 171
|
||||||
|
|
||||||
|
5.4国土空间规划“一张图”系统 173
|
||||||
|
|
||||||
|
5.4.1国土空间规划“一张图”建设步骤 173
|
||||||
|
|
||||||
|
5.4.2国土空间规划“一张图”实施路径 175
|
||||||
|
|
||||||
|
5.4.3国土空间规划“一张图”实施意义 176
|
||||||
|
|
||||||
|
5.5国土空间规划管理信息平台 180
|
||||||
|
|
||||||
|
5.5.1满足国土空间规划需求 181
|
||||||
|
|
||||||
|
5.5.2构建标准的数据体系 181
|
||||||
|
|
||||||
|
5.5.3国土空间规划信息平台的建设 182
|
||||||
|
|
||||||
|
5.5.4构建稳健中台保障体系 182
|
||||||
|
|
||||||
|
5.5.5建设“一张图”平台服务体系 183
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
services:
|
||||||
|
# PostgreSQL数据库
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: course_agent_db
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
ports:
|
||||||
|
- "5433:5432" # 外部端口改为5433,避免与本地PostgreSQL冲突
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U user -d course_agent_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
# 整合的前后端服务(单容器)
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # 后端API
|
||||||
|
- "8001:8001" # 前端应用
|
||||||
|
environment:
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_URL: postgresql+psycopg://user:password@db:5432/course_agent_db
|
||||||
|
|
||||||
|
# JWT配置
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
|
||||||
|
ALGORITHM: HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||||
|
|
||||||
|
# 硅基流动API配置
|
||||||
|
SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY:-sk-pvvtosiglncktlucwarxilvsypqcttqizgpcfdvodgcuaezn}
|
||||||
|
SILICONFLOW_BASE_URL: ${SILICONFLOW_BASE_URL:-https://api.siliconflow.cn/v1}
|
||||||
|
SILICONFLOW_MODEL: ${SILICONFLOW_MODEL:-Qwen/Qwen3-30B-A3B-Thinking-2507}
|
||||||
|
|
||||||
|
# 向量数据库配置
|
||||||
|
VECTOR_STORE_PATH: /app/vector_store
|
||||||
|
EMBEDDING_MODEL: shibing624/text2vec-base-chinese
|
||||||
|
HF_ENDPOINT: https://hf-mirror.com
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
APP_NAME: 国土空间规划课程智能体
|
||||||
|
APP_VERSION: 0.1.0
|
||||||
|
DEBUG: ${DEBUG:-False}
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
# 后端端口在uvicorn命令中指定,不需要PORT环境变量
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8001,http://127.0.0.1:8001}
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
MAX_FILE_SIZE: 10485760
|
||||||
|
ALLOWED_EXTENSIONS: ".pdf,.docx,.txt,.md"
|
||||||
|
|
||||||
|
# 知识库配置
|
||||||
|
KNOWLEDGE_BASE_DIR: /app/data/knowledge_base
|
||||||
|
ENABLE_FILE_WATCHER: True
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
LOG_FILE: /app/logs/app.log
|
||||||
|
|
||||||
|
# 时区配置
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
|
# 前端配置
|
||||||
|
NEXT_PUBLIC_BACKEND_URL: http://localhost:8000
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-your-nextauth-secret-change-in-production}
|
||||||
|
NEXTAUTH_URL: http://localhost:8001
|
||||||
|
PORT: 8001 # Next.js前端端口
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./runtime:/app/runtime
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "curl -f http://localhost:8000/health && curl -f http://localhost:8001"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
cpus: '2.0'
|
||||||
|
reservations:
|
||||||
|
memory: 2G
|
||||||
|
cpus: '1.0'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@echo off
|
||||||
|
echo ====================================
|
||||||
|
echo 启动国土空间规划课程智能体
|
||||||
|
echo ====================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 检查Docker是否运行
|
||||||
|
docker info >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] Docker未运行,请先启动Docker Desktop
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 检查.env文件
|
||||||
|
if not exist .env (
|
||||||
|
echo [提示] .env文件不存在,正在从env.example创建...
|
||||||
|
copy env.example .env
|
||||||
|
echo [提示] 请编辑.env文件配置必要的参数(API密钥等)
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 启动服务
|
||||||
|
echo [信息] 正在启动服务...
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [信息] 服务启动完成!
|
||||||
|
echo.
|
||||||
|
echo 访问地址:
|
||||||
|
echo 前端应用: http://localhost:8001
|
||||||
|
echo 后端API: http://localhost:8000
|
||||||
|
echo API文档: http://localhost:8000/docs
|
||||||
|
echo.
|
||||||
|
echo 查看日志: docker-compose logs -f
|
||||||
|
echo 停止服务: docker-compose down
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "===================================="
|
||||||
|
echo "启动国土空间规划课程智能体"
|
||||||
|
echo "===================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 检查Docker是否运行
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo "[错误] Docker未运行,请先启动Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查.env文件
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "[提示] .env文件不存在,正在从env.example创建..."
|
||||||
|
cp env.example .env
|
||||||
|
echo "[提示] 请编辑.env文件配置必要的参数(API密钥等)"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
echo "[信息] 正在启动服务..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[信息] 服务启动完成!"
|
||||||
|
echo
|
||||||
|
echo "访问地址:"
|
||||||
|
echo " 前端应用: http://localhost:8001"
|
||||||
|
echo " 后端API: http://localhost:8000"
|
||||||
|
echo " API文档: http://localhost:8000/docs"
|
||||||
|
echo
|
||||||
|
echo "查看日志: docker-compose logs -f"
|
||||||
|
echo "停止服务: docker-compose down"
|
||||||
|
echo
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 等待数据库就绪(如果使用PostgreSQL)
|
||||||
|
if [ -n "$DATABASE_URL" ] && [[ "$DATABASE_URL" == postgresql* ]]; then
|
||||||
|
echo "Waiting for database to be ready..."
|
||||||
|
until pg_isready -h db -p 5432 -U user; do
|
||||||
|
echo "Database is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Database is ready!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动supervisor(管理后端和前端进程)
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/dev/stdout
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
|
[program:backend]
|
||||||
|
command=/app/.venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
environment=PYTHONPATH="/app",PYTHONUNBUFFERED="1"
|
||||||
|
|
||||||
|
[program:frontend]
|
||||||
|
command=node server.js
|
||||||
|
directory=/app/web
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
environment=PORT="8001",HOSTNAME="0.0.0.0",NODE_ENV="production"
|
||||||
|
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# 硅基流动API配置
|
||||||
|
SILICONFLOW_API_KEY=your-api-key-here
|
||||||
|
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
|
SILICONFLOW_MODEL=Qwen/Qwen3-30B-A3B-Thinking-2507
|
||||||
|
|
||||||
|
# 数据库配置(本地开发默认使用SQLite)
|
||||||
|
DATABASE_URL=sqlite:///../data/database/course_agent.db
|
||||||
|
# Docker部署使用PostgreSQL
|
||||||
|
# DATABASE_URL=postgresql+psycopg://user:password@db:5432/course_agent_db
|
||||||
|
|
||||||
|
# JWT配置
|
||||||
|
SECRET_KEY=your-super-secret-key-change-in-production
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
|
||||||
|
# 向量数据库配置
|
||||||
|
VECTOR_STORE_PATH=../runtime/vector_store
|
||||||
|
EMBEDDING_MODEL=shibing624/text2vec-base-chinese
|
||||||
|
HF_ENDPOINT=https://hf-mirror.com
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
APP_NAME=国土空间规划课程智能体
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
DEBUG=True
|
||||||
|
HOST=127.0.0.1
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
ALLOWED_ORIGINS=["http://localhost:8001","http://127.0.0.1:8001"]
|
||||||
|
|
||||||
|
# 文件上传配置
|
||||||
|
UPLOAD_DIR=../data/uploads
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
ALLOWED_EXTENSIONS=[".pdf",".docx",".txt",".md"]
|
||||||
|
|
||||||
|
# 知识库配置
|
||||||
|
KNOWLEDGE_BASE_DIR=../data/knowledge_base
|
||||||
|
ENABLE_FILE_WATCHER=True
|
||||||
|
|
||||||
|
# 书籍配置
|
||||||
|
BOOK_DIR=../data/book
|
||||||
|
|
||||||
|
# 图像生成配置
|
||||||
|
GENERATED_IMAGES_DIR=../runtime/generated_images
|
||||||
|
IMAGE_GENERATION_TIMEOUT=120
|
||||||
|
MAX_IMAGE_SIZE=10485760
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=../runtime/logs/app.log
|
||||||
|
|
||||||
|
# 前端配置
|
||||||
|
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
||||||
|
NEXTAUTH_SECRET=your-nextauth-secret-change-in-production
|
||||||
|
NEXTAUTH_URL=http://localhost:8001
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// 启用 standalone 输出模式(用于 Docker)
|
||||||
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
turbo: {
|
||||||
|
rules: {
|
||||||
|
'*.svg': {
|
||||||
|
loaders: ['@svgr/webpack'],
|
||||||
|
as: '*.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
// 在容器环境中,前端和后端在同一容器内,可以直接访问
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000';
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${backendUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/generated_images/:path*',
|
||||||
|
destination: `${backendUrl}/generated_images/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
|
||||||
|
generateBuildId: async () => {
|
||||||
|
return 'build-' + Date.now();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "course-agent-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"check": "next lint && tsc --noEmit",
|
||||||
|
"dev": "next dev --turbo --port 8001",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
|
"preview": "next build && next start --port 8001",
|
||||||
|
"start": "next start --port 8001",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.2.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.8",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.11",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
|
"@t3-oss/env-nextjs": "^0.11.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"katex": "^0.16.21",
|
||||||
|
"lucide-react": "^0.487.0",
|
||||||
|
"next": "^15.4.7",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-force-graph-2d": "^1.29.0",
|
||||||
|
"react-hook-form": "^7.56.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"sonner": "^2.0.3",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"zod": "^3.24.3",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@types/hast": "^3.0.4",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"eslint-config-next": "^15.2.3",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^4.0.15",
|
||||||
|
"typescript-eslint": "^8.27.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.6.5"
|
||||||
|
}
|
||||||
Generated
+7110
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 MiB |
@@ -0,0 +1,394 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>p5.js 城市数据可视化</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; overflow: hidden; background: #0a0a1a; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#info {
|
||||||
|
position: fixed; bottom: 20px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="info">移动鼠标查看区域数据 | 点击切换数据维度 | 按空格切换视图</div>
|
||||||
|
<script>
|
||||||
|
let buildings = [];
|
||||||
|
let roads = [];
|
||||||
|
let districts = [];
|
||||||
|
let currentMetric = 0;
|
||||||
|
let metricNames = ['人口密度', '绿化率', '容积率', '交通便利度'];
|
||||||
|
let metricColors = [
|
||||||
|
[255, 100, 100],
|
||||||
|
[100, 220, 100],
|
||||||
|
[100, 150, 255],
|
||||||
|
[255, 220, 80]
|
||||||
|
];
|
||||||
|
let camX = 0, camY = 0;
|
||||||
|
let targetCamX = 0, targetCamY = 0;
|
||||||
|
let hoveredBuilding = null;
|
||||||
|
let viewMode = 0;
|
||||||
|
let time = 0;
|
||||||
|
let particles = [];
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
createCanvas(windowWidth, windowHeight);
|
||||||
|
generateCity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCity() {
|
||||||
|
buildings = [];
|
||||||
|
roads = [];
|
||||||
|
districts = [];
|
||||||
|
particles = [];
|
||||||
|
|
||||||
|
// 生成道路网格
|
||||||
|
let gridSize = 100;
|
||||||
|
for (let x = -800; x <= 800; x += gridSize) {
|
||||||
|
roads.push({ x1: x, y1: -800, x2: x, y2: 800 });
|
||||||
|
}
|
||||||
|
for (let y = -800; y <= 800; y += gridSize) {
|
||||||
|
roads.push({ x1: -800, y1: y, x2: 800, y2: y });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成主路(更宽)
|
||||||
|
for (let x = -800; x <= 800; x += gridSize * 3) {
|
||||||
|
roads.push({ x1: x, y1: -800, x2: x, y2: 800, main: true });
|
||||||
|
}
|
||||||
|
for (let y = -800; y <= 800; y += gridSize * 3) {
|
||||||
|
roads.push({ x1: -800, y1: y, x2: 800, y2: y, main: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成建筑
|
||||||
|
let types = ['residential', 'commercial', 'industrial', 'park'];
|
||||||
|
let typeLabels = { residential: '住宅区', commercial: '商业区', industrial: '工业区', park: '公园绿地' };
|
||||||
|
|
||||||
|
for (let gx = -750; gx < 750; gx += gridSize) {
|
||||||
|
for (let gy = -750; gy < 750; gy += gridSize) {
|
||||||
|
let cx = gx + gridSize / 2;
|
||||||
|
let cy = gy + gridSize / 2;
|
||||||
|
let dist = sqrt(cx * cx + cy * cy);
|
||||||
|
let type;
|
||||||
|
|
||||||
|
if (dist < 200) type = 'commercial';
|
||||||
|
else if (dist < 500) type = random() > 0.2 ? 'residential' : 'park';
|
||||||
|
else type = random() > 0.3 ? 'residential' : 'industrial';
|
||||||
|
|
||||||
|
if (type === 'park') {
|
||||||
|
buildings.push({
|
||||||
|
x: cx, y: cy,
|
||||||
|
w: gridSize * 0.8, h: gridSize * 0.8,
|
||||||
|
height: 0, type: type, label: typeLabels[type],
|
||||||
|
metrics: {
|
||||||
|
pop: random(5, 20),
|
||||||
|
green: random(70, 95),
|
||||||
|
far: random(0.1, 0.3),
|
||||||
|
traffic: random(20, 40)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = floor(random(1, 5));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let bw = random(15, gridSize * 0.4);
|
||||||
|
let bh = random(15, gridSize * 0.4);
|
||||||
|
let bx = cx + random(-gridSize * 0.3, gridSize * 0.3);
|
||||||
|
let by = cy + random(-gridSize * 0.3, gridSize * 0.3);
|
||||||
|
let bHeight;
|
||||||
|
|
||||||
|
if (type === 'commercial') bHeight = random(60, 180);
|
||||||
|
else if (type === 'residential') bHeight = random(20, 80);
|
||||||
|
else bHeight = random(15, 40);
|
||||||
|
|
||||||
|
buildings.push({
|
||||||
|
x: bx, y: by,
|
||||||
|
w: bw, h: bh,
|
||||||
|
height: bHeight,
|
||||||
|
type: type,
|
||||||
|
label: typeLabels[type],
|
||||||
|
metrics: {
|
||||||
|
pop: type === 'commercial' ? random(200, 800) : random(50, 300),
|
||||||
|
green: random(10, 40),
|
||||||
|
far: bHeight / 50,
|
||||||
|
traffic: random(30, 90)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流动粒子(代表交通)
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
particles.push({
|
||||||
|
roadIdx: floor(random(roads.length)),
|
||||||
|
t: random(1),
|
||||||
|
speed: random(0.002, 0.008),
|
||||||
|
color: [255, 220, 80, random(100, 200)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
background(10, 10, 26);
|
||||||
|
time += 0.01;
|
||||||
|
|
||||||
|
// 平滑相机
|
||||||
|
camX = lerp(camX, targetCamX, 0.05);
|
||||||
|
camY = lerp(camY, targetCamY, 0.05);
|
||||||
|
|
||||||
|
push();
|
||||||
|
translate(width / 2 + camX, height / 2 + camY);
|
||||||
|
|
||||||
|
// 绘制地面
|
||||||
|
fill(15, 15, 35);
|
||||||
|
noStroke();
|
||||||
|
rect(-800, -800, 1600, 1600);
|
||||||
|
|
||||||
|
// 绘制道路
|
||||||
|
for (let r of roads) {
|
||||||
|
if (r.main) {
|
||||||
|
stroke(40, 40, 70);
|
||||||
|
strokeWeight(4);
|
||||||
|
} else {
|
||||||
|
stroke(30, 30, 55);
|
||||||
|
strokeWeight(1);
|
||||||
|
}
|
||||||
|
line(r.x1, r.y1, r.x2, r.y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新和绘制流动粒子
|
||||||
|
noStroke();
|
||||||
|
for (let p of particles) {
|
||||||
|
p.t += p.speed;
|
||||||
|
if (p.t > 1) p.t -= 1;
|
||||||
|
|
||||||
|
let r = roads[p.roadIdx];
|
||||||
|
let px = lerp(r.x1, r.x2, p.t);
|
||||||
|
let py = lerp(r.y1, r.y2, p.t);
|
||||||
|
fill(p.color);
|
||||||
|
ellipse(px, py, 3, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
hoveredBuilding = null;
|
||||||
|
|
||||||
|
// 绘制建筑
|
||||||
|
for (let b of buildings) {
|
||||||
|
let mc = metricColors[currentMetric];
|
||||||
|
let metricVal;
|
||||||
|
if (currentMetric === 0) metricVal = b.metrics.pop / 800;
|
||||||
|
else if (currentMetric === 1) metricVal = b.metrics.green / 95;
|
||||||
|
else if (currentMetric === 2) metricVal = b.metrics.far / 3.6;
|
||||||
|
else metricVal = b.metrics.traffic / 90;
|
||||||
|
metricVal = constrain(metricVal, 0, 1);
|
||||||
|
|
||||||
|
if (viewMode === 0) {
|
||||||
|
drawBuildingTop(b, mc, metricVal);
|
||||||
|
} else {
|
||||||
|
drawBuildingIso(b, mc, metricVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测鼠标悬停
|
||||||
|
let mx = mouseX - width / 2 - camX;
|
||||||
|
let my = mouseY - height / 2 - camY;
|
||||||
|
if (abs(mx - b.x) < b.w / 2 + 5 && abs(my - b.y) < b.h / 2 + 5) {
|
||||||
|
hoveredBuilding = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pop();
|
||||||
|
|
||||||
|
// 绘制UI
|
||||||
|
drawUI();
|
||||||
|
|
||||||
|
// 绘制悬停信息
|
||||||
|
if (hoveredBuilding) {
|
||||||
|
drawTooltip(hoveredBuilding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBuildingTop(b, mc, val) {
|
||||||
|
if (b.type === 'park') {
|
||||||
|
fill(30, 100 + val * 80, 30, 150);
|
||||||
|
noStroke();
|
||||||
|
rect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, 4);
|
||||||
|
// 树
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
let tx = b.x + random(-b.w * 0.3, b.w * 0.3);
|
||||||
|
let ty = b.y + random(-b.h * 0.3, b.h * 0.3);
|
||||||
|
fill(40, 140, 40, 180);
|
||||||
|
ellipse(tx, ty, 8, 8);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let pulse = sin(time * 2 + b.x * 0.01) * 0.1 + 0.9;
|
||||||
|
let alpha = 80 + val * 170;
|
||||||
|
fill(mc[0], mc[1], mc[2], alpha * pulse);
|
||||||
|
noStroke();
|
||||||
|
rect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, 2);
|
||||||
|
|
||||||
|
// 高度阴影
|
||||||
|
fill(0, 0, 0, b.height * 0.3);
|
||||||
|
let offset = b.height * 0.1;
|
||||||
|
rect(b.x - b.w / 2 + offset, b.y - b.h / 2 + offset, b.w, b.h, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBuildingIso(b, mc, val) {
|
||||||
|
let h = b.height * 0.3;
|
||||||
|
if (b.type === 'park') {
|
||||||
|
fill(30, 120, 30, 150);
|
||||||
|
noStroke();
|
||||||
|
rect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, 4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpha = 100 + val * 155;
|
||||||
|
|
||||||
|
// 右侧面
|
||||||
|
fill(mc[0] * 0.6, mc[1] * 0.6, mc[2] * 0.6, alpha);
|
||||||
|
noStroke();
|
||||||
|
beginShape();
|
||||||
|
vertex(b.x + b.w / 2, b.y - b.h / 2);
|
||||||
|
vertex(b.x + b.w / 2, b.y - b.h / 2 - h);
|
||||||
|
vertex(b.x + b.w / 2 + h * 0.4, b.y - b.h / 2 - h - h * 0.4);
|
||||||
|
vertex(b.x + b.w / 2 + h * 0.4, b.y - b.h / 2 + h * 0.4);
|
||||||
|
endShape(CLOSE);
|
||||||
|
|
||||||
|
// 正面
|
||||||
|
fill(mc[0] * 0.8, mc[1] * 0.8, mc[2] * 0.8, alpha);
|
||||||
|
beginShape();
|
||||||
|
vertex(b.x - b.w / 2, b.y - b.h / 2);
|
||||||
|
vertex(b.x - b.w / 2, b.y - b.h / 2 - h);
|
||||||
|
vertex(b.x + b.w / 2, b.y - b.h / 2 - h);
|
||||||
|
vertex(b.x + b.w / 2, b.y - b.h / 2);
|
||||||
|
endShape(CLOSE);
|
||||||
|
|
||||||
|
// 顶面
|
||||||
|
fill(mc[0], mc[1], mc[2], alpha);
|
||||||
|
beginShape();
|
||||||
|
vertex(b.x - b.w / 2, b.y - b.h / 2 - h);
|
||||||
|
vertex(b.x - b.w / 2 + h * 0.4, b.y - b.h / 2 - h - h * 0.4);
|
||||||
|
vertex(b.x + b.w / 2 + h * 0.4, b.y - b.h / 2 - h - h * 0.4);
|
||||||
|
vertex(b.x + b.w / 2, b.y - b.h / 2 - h);
|
||||||
|
endShape(CLOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawUI() {
|
||||||
|
// 左上角标题
|
||||||
|
fill(255, 255, 255, 200);
|
||||||
|
noStroke();
|
||||||
|
textSize(18);
|
||||||
|
textAlign(LEFT, TOP);
|
||||||
|
textFont('system-ui');
|
||||||
|
text('城市数据可视化', 20, 20);
|
||||||
|
|
||||||
|
textSize(12);
|
||||||
|
fill(255, 255, 255, 120);
|
||||||
|
text('当前指标: ' + metricNames[currentMetric], 20, 48);
|
||||||
|
|
||||||
|
// 图例
|
||||||
|
let legendY = 80;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let mc = metricColors[i];
|
||||||
|
if (i === currentMetric) {
|
||||||
|
fill(mc[0], mc[1], mc[2], 255);
|
||||||
|
rect(20, legendY, 12, 12, 2);
|
||||||
|
fill(255);
|
||||||
|
textSize(13);
|
||||||
|
text(metricNames[i], 38, legendY);
|
||||||
|
} else {
|
||||||
|
fill(mc[0], mc[1], mc[2], 80);
|
||||||
|
rect(20, legendY, 12, 12, 2);
|
||||||
|
fill(150);
|
||||||
|
textSize(13);
|
||||||
|
text(metricNames[i], 38, legendY);
|
||||||
|
}
|
||||||
|
legendY += 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 色阶条
|
||||||
|
let barX = 20, barY = height - 50, barW = 200, barH = 12;
|
||||||
|
let mc = metricColors[currentMetric];
|
||||||
|
for (let i = 0; i < barW; i++) {
|
||||||
|
let t = i / barW;
|
||||||
|
fill(mc[0] * t, mc[1] * t, mc[2] * t);
|
||||||
|
noStroke();
|
||||||
|
rect(barX + i, barY, 1, barH);
|
||||||
|
}
|
||||||
|
fill(255, 255, 255, 120);
|
||||||
|
textSize(11);
|
||||||
|
text('低', barX, barY + barH + 14);
|
||||||
|
textAlign(RIGHT, TOP);
|
||||||
|
text('高', barX + barW, barY + barH + 14);
|
||||||
|
|
||||||
|
// 建筑数量
|
||||||
|
textAlign(RIGHT, TOP);
|
||||||
|
fill(255, 255, 255, 100);
|
||||||
|
textSize(12);
|
||||||
|
text('建筑: ' + buildings.length + ' | 粒子: ' + particles.length, width - 20, 20);
|
||||||
|
text('视图: ' + (viewMode === 0 ? '俯视图' : '等轴测'), width - 20, 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTooltip(b) {
|
||||||
|
let tx = mouseX + 15;
|
||||||
|
let ty = mouseY - 10;
|
||||||
|
|
||||||
|
let tw = 160;
|
||||||
|
let th = 100;
|
||||||
|
|
||||||
|
if (tx + tw > width) tx = mouseX - tw - 15;
|
||||||
|
if (ty + th > height) ty = mouseY - th - 10;
|
||||||
|
|
||||||
|
fill(20, 20, 40, 220);
|
||||||
|
stroke(80, 80, 120);
|
||||||
|
strokeWeight(1);
|
||||||
|
rect(tx, ty, tw, th, 6);
|
||||||
|
|
||||||
|
noStroke();
|
||||||
|
fill(255);
|
||||||
|
textSize(13);
|
||||||
|
textAlign(LEFT, TOP);
|
||||||
|
text(b.label, tx + 10, ty + 8);
|
||||||
|
|
||||||
|
fill(180);
|
||||||
|
textSize(11);
|
||||||
|
let keys = ['人口密度', '绿化率', '容积率', '交通便利'];
|
||||||
|
let vals = [b.metrics.pop, b.metrics.green, b.metrics.far, b.metrics.traffic];
|
||||||
|
let units = ['人/hm²', '%', '', '分'];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let mc = metricColors[i];
|
||||||
|
fill(mc[0], mc[1], mc[2], i === currentMetric ? 255 : 120);
|
||||||
|
text(keys[i] + ': ' + (i === 1 ? vals[i].toFixed(0) : vals[i].toFixed(1)) + units[i], tx + 10, ty + 30 + i * 17);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mousePressed() {
|
||||||
|
currentMetric = (currentMetric + 1) % 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyPressed() {
|
||||||
|
if (key === ' ') {
|
||||||
|
viewMode = (viewMode + 1) % 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseDragged() {
|
||||||
|
targetCamX += mouseX - pmouseX;
|
||||||
|
targetCamY += mouseY - pmouseY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowResized() {
|
||||||
|
resizeCanvas(windowWidth, windowHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>国土空间规划课程智能体</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { overflow: hidden; background: #0a0a1a; font-family: 'Noto Sans SC', system-ui, sans-serif; }
|
||||||
|
canvas { display: block; position: fixed; top: 0; left: 0; z-index: 0; }
|
||||||
|
#overlay {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
z-index: 1; pointer-events: none;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
}
|
||||||
|
#title {
|
||||||
|
font-size: 48px; font-weight: 700; color: white;
|
||||||
|
text-shadow: 0 0 40px rgba(100, 140, 255, 0.4);
|
||||||
|
margin-bottom: 12px; letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
#subtitle {
|
||||||
|
font-size: 18px; font-weight: 300; color: rgba(180, 200, 255, 0.7);
|
||||||
|
margin-bottom: 40px; letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
#tagline {
|
||||||
|
font-size: 14px; color: rgba(140, 160, 220, 0.5);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
#info {
|
||||||
|
position: fixed; bottom: 20px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
font-size: 12px; z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="overlay">
|
||||||
|
<div id="title">国土空间规划课程智能体</div>
|
||||||
|
<div id="subtitle">基于大模型的智能问答系统</div>
|
||||||
|
<div id="tagline">哈尔滨工业大学建筑与设计学院</div>
|
||||||
|
</div>
|
||||||
|
<div id="info">移动鼠标与知识网络互动</div>
|
||||||
|
<script>
|
||||||
|
const keywords = [
|
||||||
|
'容积率', '建筑密度', '绿地率', '控规', '总规', '详规',
|
||||||
|
'用地性质', '红线', '蓝线', '绿线', '紫线', '黄线',
|
||||||
|
'城市更新', '国土空间', '双评价', '三区三线',
|
||||||
|
'生态保护', '耕地保护', '城镇开发', '用途管制',
|
||||||
|
'空间结构', '交通规划', '基础设施', '公共服务',
|
||||||
|
'人口预测', '用地布局', '天际线', '风环境',
|
||||||
|
'海绵城市', '韧性城市', '智慧城市', '碳中和'
|
||||||
|
];
|
||||||
|
|
||||||
|
let nodes = [];
|
||||||
|
let nodeCount;
|
||||||
|
let connectDist = 150;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
createCanvas(windowWidth, windowHeight);
|
||||||
|
nodeCount = min(floor(width * height / 12000), 80);
|
||||||
|
|
||||||
|
for (let i = 0; i < nodeCount; i++) {
|
||||||
|
nodes.push(createNode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode() {
|
||||||
|
let kw = random(keywords);
|
||||||
|
return {
|
||||||
|
x: random(width),
|
||||||
|
y: random(height),
|
||||||
|
vx: random(-0.3, 0.3),
|
||||||
|
vy: random(-0.3, 0.3),
|
||||||
|
size: textWidth(kw) || kw.length * 14,
|
||||||
|
baseSize: random(3, 6),
|
||||||
|
keyword: kw,
|
||||||
|
showText: random() > 0.5,
|
||||||
|
hue: random(200, 280),
|
||||||
|
phase: random(TWO_PI),
|
||||||
|
textAlpha: random() > 0.6 ? random(60, 150) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
background(10, 10, 26, 30);
|
||||||
|
|
||||||
|
// 渐变背景(每帧微量覆盖)
|
||||||
|
noStroke();
|
||||||
|
fill(10, 10, 26, 25);
|
||||||
|
rect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 绘制连线
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
let d = dist(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
||||||
|
if (d < connectDist) {
|
||||||
|
let alpha = map(d, 0, connectDist, 60, 0);
|
||||||
|
let h = (nodes[i].hue + nodes[j].hue) / 2;
|
||||||
|
stroke(h, 60, 200, alpha);
|
||||||
|
strokeWeight(0.8);
|
||||||
|
line(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标吸引线
|
||||||
|
for (let n of nodes) {
|
||||||
|
let d = dist(mouseX, mouseY, n.x, n.y);
|
||||||
|
if (d < 200) {
|
||||||
|
let alpha = map(d, 0, 200, 80, 0);
|
||||||
|
stroke(220, 180, 255, alpha);
|
||||||
|
strokeWeight(1);
|
||||||
|
line(mouseX, mouseY, n.x, n.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新和绘制节点
|
||||||
|
for (let n of nodes) {
|
||||||
|
// 鼠标交互
|
||||||
|
let dm = dist(mouseX, mouseY, n.x, n.y);
|
||||||
|
if (dm < 180) {
|
||||||
|
let force = map(dm, 0, 180, 0.5, 0);
|
||||||
|
let angle = atan2(n.y - mouseY, n.x - mouseX);
|
||||||
|
n.vx += cos(angle) * force * 0.05;
|
||||||
|
n.vy += sin(angle) * force * 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运动
|
||||||
|
n.x += n.vx;
|
||||||
|
n.y += n.vy;
|
||||||
|
|
||||||
|
// 摩擦力
|
||||||
|
n.vx *= 0.99;
|
||||||
|
n.vy *= 0.99;
|
||||||
|
|
||||||
|
// 添加微小随机运动
|
||||||
|
n.vx += random(-0.02, 0.02);
|
||||||
|
n.vy += random(-0.02, 0.02);
|
||||||
|
|
||||||
|
// 边界
|
||||||
|
if (n.x < 0) { n.x = 0; n.vx *= -1; }
|
||||||
|
if (n.x > width) { n.x = width; n.vx *= -1; }
|
||||||
|
if (n.y < 0) { n.y = 0; n.vy *= -1; }
|
||||||
|
if (n.y > height) { n.y = height; n.vy *= -1; }
|
||||||
|
|
||||||
|
// 脉动效果
|
||||||
|
let pulse = sin(frameCount * 0.02 + n.phase) * 0.3 + 0.7;
|
||||||
|
|
||||||
|
// 绘制光晕
|
||||||
|
noStroke();
|
||||||
|
let glowAlpha = 20 * pulse;
|
||||||
|
fill(n.hue, 50, 200, glowAlpha);
|
||||||
|
ellipse(n.x, n.y, n.baseSize * 8, n.baseSize * 8);
|
||||||
|
|
||||||
|
fill(n.hue, 50, 200, glowAlpha * 1.5);
|
||||||
|
ellipse(n.x, n.y, n.baseSize * 4, n.baseSize * 4);
|
||||||
|
|
||||||
|
// 绘制节点
|
||||||
|
let nodeAlpha = (dm < 180) ? map(dm, 0, 180, 255, 120) : 120 * pulse;
|
||||||
|
fill(n.hue, 60, 240, nodeAlpha);
|
||||||
|
ellipse(n.x, n.y, n.baseSize * 2, n.baseSize * 2);
|
||||||
|
|
||||||
|
// 绘制关键词文字
|
||||||
|
if (n.textAlpha > 0) {
|
||||||
|
let ta = n.textAlpha * pulse;
|
||||||
|
if (dm < 180) ta = min(ta + 80, 220);
|
||||||
|
fill(n.hue, 30, 230, ta);
|
||||||
|
noStroke();
|
||||||
|
textAlign(CENTER, CENTER);
|
||||||
|
textSize(12);
|
||||||
|
textFont('Noto Sans SC');
|
||||||
|
text(n.keyword, n.x, n.y - n.baseSize * 2 - 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标光标光晕
|
||||||
|
noStroke();
|
||||||
|
fill(230, 200, 255, 8);
|
||||||
|
ellipse(mouseX, mouseY, 200, 200);
|
||||||
|
fill(230, 200, 255, 15);
|
||||||
|
ellipse(mouseX, mouseY, 80, 80);
|
||||||
|
fill(230, 200, 255, 30);
|
||||||
|
ellipse(mouseX, mouseY, 20, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowResized() {
|
||||||
|
resizeCanvas(windowWidth, windowHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>p5.js 互动图形 Demo</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; overflow: hidden; background: #000; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#info {
|
||||||
|
position: fixed; bottom: 20px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="info">移动鼠标生成图形 | 点击产生爆炸 | 按空格清空画布</div>
|
||||||
|
<script>
|
||||||
|
let shapes = [];
|
||||||
|
let hueOffset = 0;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
createCanvas(windowWidth, windowHeight);
|
||||||
|
colorMode(HSB, 360, 100, 100, 100);
|
||||||
|
rectMode(CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
background(0, 0, 0, 5);
|
||||||
|
|
||||||
|
for (let i = shapes.length - 1; i >= 0; i--) {
|
||||||
|
let s = shapes[i];
|
||||||
|
s.angle += s.rotSpeed;
|
||||||
|
s.size += s.growSpeed;
|
||||||
|
s.alpha -= s.fadeSpeed;
|
||||||
|
|
||||||
|
if (s.alpha <= 0) {
|
||||||
|
shapes.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
push();
|
||||||
|
translate(s.x, s.y);
|
||||||
|
rotate(s.angle);
|
||||||
|
noStroke();
|
||||||
|
fill(s.hue, s.sat, s.bright, s.alpha);
|
||||||
|
|
||||||
|
if (s.type === 0) {
|
||||||
|
ellipse(0, 0, s.size, s.size);
|
||||||
|
} else if (s.type === 1) {
|
||||||
|
triangle(
|
||||||
|
0, -s.size / 2,
|
||||||
|
-s.size / 2, s.size / 2,
|
||||||
|
s.size / 2, s.size / 2
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rect(0, 0, s.size, s.size);
|
||||||
|
}
|
||||||
|
pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
hueOffset = (hueOffset + 0.5) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseMoved() {
|
||||||
|
let hue = (hueOffset + random(-20, 20) + 360) % 360;
|
||||||
|
shapes.push({
|
||||||
|
x: mouseX + random(-15, 15),
|
||||||
|
y: mouseY + random(-15, 15),
|
||||||
|
size: random(10, 30),
|
||||||
|
angle: random(TWO_PI),
|
||||||
|
rotSpeed: random(-0.05, 0.05),
|
||||||
|
growSpeed: random(0.3, 1.2),
|
||||||
|
fadeSpeed: random(0.8, 2.0),
|
||||||
|
alpha: 80,
|
||||||
|
hue: hue,
|
||||||
|
sat: random(60, 90),
|
||||||
|
bright: random(70, 100),
|
||||||
|
type: floor(random(3))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shapes.length > 500) {
|
||||||
|
shapes.splice(0, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mousePressed() {
|
||||||
|
let count = floor(random(15, 25));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let angle = random(TWO_PI);
|
||||||
|
let dist = random(20, 120);
|
||||||
|
let hue = (hueOffset + random(-40, 40) + 360) % 360;
|
||||||
|
shapes.push({
|
||||||
|
x: mouseX + cos(angle) * dist,
|
||||||
|
y: mouseY + sin(angle) * dist,
|
||||||
|
size: random(8, 25),
|
||||||
|
angle: angle,
|
||||||
|
rotSpeed: random(-0.08, 0.08),
|
||||||
|
growSpeed: random(0.5, 2.0),
|
||||||
|
fadeSpeed: random(1.0, 2.5),
|
||||||
|
alpha: 90,
|
||||||
|
hue: hue,
|
||||||
|
sat: random(70, 100),
|
||||||
|
bright: random(80, 100),
|
||||||
|
type: floor(random(3))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyPressed() {
|
||||||
|
if (key === ' ') {
|
||||||
|
shapes = [];
|
||||||
|
background(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowResized() {
|
||||||
|
resizeCanvas(windowWidth, windowHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 517 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 517 KiB |
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { BookOpen, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
|
import { FlickeringGrid } from "@/components/magicui/flickering-grid";
|
||||||
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().min(1, "用户名不能为空"),
|
||||||
|
password: z.string().min(1, "密码不能为空"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const success = await login(data);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 登录成功后回到主页,让用户看到无缝过渡效果
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app relative overflow-hidden">
|
||||||
|
{/* 背景动画 */}
|
||||||
|
<FlickeringGrid
|
||||||
|
className="absolute inset-0 z-0 [mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
|
||||||
|
squareSize={4}
|
||||||
|
gridGap={4}
|
||||||
|
color="#60A5FA"
|
||||||
|
maxOpacity={0.1}
|
||||||
|
flickerChance={0.05}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主题切换按钮 */}
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo和标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
||||||
|
<BookOpen className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
国土空间规划课程智能体
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-muted-foreground text-lg">登录您的账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录表单 */}
|
||||||
|
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">登录</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
输入您的用户名和密码以访问系统
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户名输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">用户名</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
{...register("username")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-sm text-red-600">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
{...register("password")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"登录"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 注册链接 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
还没有账户?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 快速登录提示 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
首次使用?请先注册账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { BookOpen, Eye, EyeOff, Loader2, CheckCircle } from "lucide-react";
|
||||||
|
import { FlickeringGrid } from "@/components/magicui/flickering-grid";
|
||||||
|
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(3, "用户名至少3个字符").max(20, "用户名最多20个字符"),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().min(6, "密码至少6个字符"),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
full_name: z.string().optional(),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "密码不匹配",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { register: registerUser, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const success = await registerUser({
|
||||||
|
username: data.username,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
full_name: data.full_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
router.push("/chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app relative overflow-hidden">
|
||||||
|
{/* 背景动画 */}
|
||||||
|
<FlickeringGrid
|
||||||
|
className="absolute inset-0 z-0 [mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
|
||||||
|
squareSize={4}
|
||||||
|
gridGap={4}
|
||||||
|
color="#60A5FA"
|
||||||
|
maxOpacity={0.1}
|
||||||
|
flickerChance={0.05}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主题切换按钮 */}
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo和标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center space-x-3 mb-6 group cursor-pointer transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-shadow">
|
||||||
|
<BookOpen className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
国土空间规划课程智能体
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-muted-foreground text-lg">创建您的账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 注册表单 */}
|
||||||
|
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">注册</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
填写以下信息创建您的账户
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户名输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">用户名 *</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
{...register("username")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-sm text-red-600">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">邮箱 *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
{...register("email")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-600">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 姓名输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="full_name">姓名(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="full_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入您的姓名"
|
||||||
|
{...register("full_name")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.full_name && (
|
||||||
|
<p className="text-sm text-red-600">{errors.full_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码 *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
{...register("password")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认密码输入 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">确认密码 *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 注册按钮 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
注册中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
注册
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 登录链接 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
已有账户?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
立即登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 注册提示 */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
注册即表示您同意我们的服务条款和隐私政策
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
BookOpen,
|
||||||
|
MessageSquare,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
Target,
|
||||||
|
Lightbulb,
|
||||||
|
Download,
|
||||||
|
Share2
|
||||||
|
} from "lucide-react";
|
||||||
|
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||||
|
import { analyticsAPI } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Statistics {
|
||||||
|
total_sessions: number;
|
||||||
|
total_messages: number;
|
||||||
|
total_documents: number;
|
||||||
|
active_days: number;
|
||||||
|
popular_questions: Array<{
|
||||||
|
question: string;
|
||||||
|
count: number;
|
||||||
|
category: string;
|
||||||
|
}>;
|
||||||
|
knowledge_coverage: Array<{
|
||||||
|
topic: string;
|
||||||
|
coverage: number;
|
||||||
|
questions: number;
|
||||||
|
}>;
|
||||||
|
learning_trends: Array<{
|
||||||
|
date: string;
|
||||||
|
messages: number;
|
||||||
|
sessions: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LearningReport {
|
||||||
|
user_id: number;
|
||||||
|
total_questions: number;
|
||||||
|
topics_covered: string[];
|
||||||
|
learning_progress: number;
|
||||||
|
recommendations: string[];
|
||||||
|
study_time: number;
|
||||||
|
knowledge_gaps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||||
|
|
||||||
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||||
|
const [learningReport, setLearningReport] = useState<LearningReport | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loadAnalyticsData();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, router]);
|
||||||
|
|
||||||
|
const loadAnalyticsData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 调用真实API获取完整分析数据
|
||||||
|
const response = await analyticsAPI.getFullAnalytics();
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const { statistics, learning_trends, popular_questions, knowledge_coverage, learning_report } = response.data;
|
||||||
|
|
||||||
|
// 构建Statistics对象
|
||||||
|
const statisticsData: Statistics = {
|
||||||
|
total_sessions: statistics.total_sessions,
|
||||||
|
total_messages: statistics.total_messages,
|
||||||
|
total_documents: statistics.total_documents,
|
||||||
|
active_days: statistics.active_days,
|
||||||
|
popular_questions: popular_questions,
|
||||||
|
knowledge_coverage: knowledge_coverage,
|
||||||
|
learning_trends: learning_trends
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建LearningReport对象
|
||||||
|
const learningReportData: LearningReport = {
|
||||||
|
user_id: learning_report.user_id,
|
||||||
|
total_questions: learning_report.total_questions,
|
||||||
|
topics_covered: learning_report.topics_covered,
|
||||||
|
learning_progress: learning_report.learning_progress,
|
||||||
|
recommendations: learning_report.recommendations,
|
||||||
|
study_time: learning_report.study_time,
|
||||||
|
knowledge_gaps: learning_report.knowledge_gaps
|
||||||
|
};
|
||||||
|
|
||||||
|
setStatistics(statisticsData);
|
||||||
|
setLearningReport(learningReportData);
|
||||||
|
} else {
|
||||||
|
throw new Error("API返回失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("加载分析数据失败:", err);
|
||||||
|
// 如果API调用失败,使用模拟数据作为后备
|
||||||
|
const mockStatistics: Statistics = {
|
||||||
|
total_sessions: 0,
|
||||||
|
total_messages: 0,
|
||||||
|
total_documents: 0,
|
||||||
|
active_days: 0,
|
||||||
|
popular_questions: [],
|
||||||
|
knowledge_coverage: [],
|
||||||
|
learning_trends: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLearningReport: LearningReport = {
|
||||||
|
user_id: 0,
|
||||||
|
total_questions: 0,
|
||||||
|
topics_covered: [],
|
||||||
|
learning_progress: 0,
|
||||||
|
recommendations: ["请先开始学习以获取分析数据"],
|
||||||
|
study_time: 0,
|
||||||
|
knowledge_gaps: []
|
||||||
|
};
|
||||||
|
|
||||||
|
setStatistics(mockStatistics);
|
||||||
|
setLearningReport(mockLearningReport);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return `${hours}小时${mins}分钟`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* 页面头部 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground mb-2">学习分析</h1>
|
||||||
|
<p className="text-muted-foreground">了解您的学习进度和知识掌握情况</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签页 */}
|
||||||
|
<div className="flex space-x-1 mb-8">
|
||||||
|
{[
|
||||||
|
{ id: "overview", name: "总览", icon: BarChart3 },
|
||||||
|
{ id: "progress", name: "学习进度", icon: TrendingUp },
|
||||||
|
{ id: "knowledge", name: "知识图谱", icon: BookOpen }
|
||||||
|
].map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
variant={activeTab === tab.id ? "default" : "outline"}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<tab.icon className="w-4 h-4 mr-2" />
|
||||||
|
{tab.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总览标签页 */}
|
||||||
|
{activeTab === "overview" && statistics && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 关键指标 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MessageSquare className="w-8 h-8 text-blue-600" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">对话会话</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{statistics.total_sessions}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<BookOpen className="w-8 h-8 text-green-600" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">消息总数</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{statistics.total_messages}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText className="w-8 h-8 text-purple-600" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">文档数量</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{statistics.total_documents}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Calendar className="w-8 h-8 text-orange-600" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">活跃天数</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{statistics.active_days}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 知识覆盖度 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>知识覆盖度</CardTitle>
|
||||||
|
<CardDescription>各主题的学习覆盖情况</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{statistics.knowledge_coverage.map((item, index) => (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium text-foreground">{item.topic}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{item.coverage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${item.coverage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{item.questions} 个问题</span>
|
||||||
|
<span>{item.coverage}% 掌握度</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 热门问题 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>热门问题</CardTitle>
|
||||||
|
<CardDescription>您最常问的问题</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{statistics.popular_questions.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{item.question}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.category}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-blue-600">{item.count} 次</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 学习进度标签页 */}
|
||||||
|
{activeTab === "progress" && learningReport && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 学习进度 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>学习进度</CardTitle>
|
||||||
|
<CardDescription>您的整体学习进度</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-lg font-medium text-foreground">总体进度</span>
|
||||||
|
<span className="text-2xl font-bold text-blue-600">{learningReport.learning_progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-4">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-blue-500 to-purple-600 h-4 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${learningReport.learning_progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">学习时间:</span>
|
||||||
|
<span className="font-medium text-foreground">{formatTime(learningReport.study_time)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">问题总数:</span>
|
||||||
|
<span className="font-medium text-foreground">{learningReport.total_questions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 学习建议 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center">
|
||||||
|
<Lightbulb className="w-5 h-5 mr-2" />
|
||||||
|
学习建议
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>基于您的学习情况,我们为您提供以下建议</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{learningReport.recommendations.map((recommendation, index) => (
|
||||||
|
<div key={index} className="flex items-start space-x-3 p-3 bg-blue-500/10 dark:bg-blue-500/20 rounded-lg">
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-foreground">{recommendation}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 知识缺口 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center">
|
||||||
|
<Target className="w-5 h-5 mr-2" />
|
||||||
|
知识缺口
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>需要重点关注的学习领域</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{learningReport.knowledge_gaps.map((gap, index) => (
|
||||||
|
<div key={index} className="flex items-start space-x-3 p-3 bg-orange-500/10 dark:bg-orange-500/20 rounded-lg">
|
||||||
|
<div className="w-2 h-2 bg-orange-600 rounded-full mt-2 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-foreground">{gap}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 知识图谱标签页 */}
|
||||||
|
{activeTab === "knowledge" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>知识图谱</CardTitle>
|
||||||
|
<CardDescription>您的知识结构可视化</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<BookOpen className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">知识图谱</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
知识图谱功能正在开发中,将展示您的知识结构和学习路径
|
||||||
|
</p>
|
||||||
|
<Button variant="outline">
|
||||||
|
即将推出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex justify-end space-x-4 mt-8">
|
||||||
|
<Button variant="outline" className="flex items-center">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出报告
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="flex items-center">
|
||||||
|
<Share2 className="w-4 h-4 mr-2" />
|
||||||
|
分享
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { useChatStore } from "@/store/chat";
|
||||||
|
import ChatInterface from "@/components/chat/chat-interface";
|
||||||
|
import Sidebar from "@/components/chat/sidebar";
|
||||||
|
import MobileNav from "@/components/layout/mobile-nav";
|
||||||
|
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, user, isLoading: authLoading } = useAuthStore();
|
||||||
|
const { loadSessions, isLoading: chatLoading } = useChatStore();
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && !isInitialized) {
|
||||||
|
// 加载聊天会话
|
||||||
|
loadSessions();
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, isInitialized, router, loadSessions]);
|
||||||
|
|
||||||
|
if (authLoading || chatLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-4rem)] bg-background flex">
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<div className="w-80 border-r bg-card/50 flex-shrink-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主聊天区域 */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<ChatInterface />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端导航 */}
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// 禁用静态生成
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import MobileNav from "@/components/layout/mobile-nav";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
BookOpen,
|
||||||
|
GraduationCap,
|
||||||
|
Lightbulb,
|
||||||
|
Target,
|
||||||
|
Network,
|
||||||
|
List
|
||||||
|
} from "lucide-react";
|
||||||
|
import { courseContentAPI } from "@/lib/api";
|
||||||
|
import { BookStructure } from "@/types";
|
||||||
|
import dynamicImport from "next/dynamic";
|
||||||
|
import NodeDetailDialog from "@/components/course-content/node-detail-dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileText, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
// 动态导入 KnowledgeGraph 组件,禁用 SSR
|
||||||
|
const KnowledgeGraph = dynamicImport(
|
||||||
|
() => import("@/components/course-content/knowledge-graph"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function CourseContentPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||||
|
const [bookStructure, setBookStructure] = useState<BookStructure | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'graph' | 'list'>('graph');
|
||||||
|
const [selectedNode, setSelectedNode] = useState<{
|
||||||
|
type: 'chapter' | 'section' | 'subsection';
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subsections?: Array<{ id: number; title: string }>;
|
||||||
|
} | null>(null);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
loadCourseContent();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, router]);
|
||||||
|
|
||||||
|
const loadCourseContent = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const structure = await courseContentAPI.getCourseContent();
|
||||||
|
console.log("加载的书籍结构:", structure);
|
||||||
|
console.log("章节数量:", structure?.chapters?.length || 0);
|
||||||
|
if (structure?.chapters) {
|
||||||
|
structure.chapters.forEach((chapter, index) => {
|
||||||
|
console.log(`章节 ${index + 1}:`, chapter.title, "节数:", chapter.sections?.length || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBookStructure(structure);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("加载课程内容失败:", err);
|
||||||
|
setError("加载课程内容失败: " + (err instanceof Error ? err.message : String(err)));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authLoading || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-app pb-16 lg:pb-0">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* 页面头部 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<GraduationCap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">课程内容</h1>
|
||||||
|
<p className="text-muted-foreground">探索国土空间规划课程的核心知识点</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 视图切换 */}
|
||||||
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'graph' | 'list')}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="graph" className="flex items-center space-x-2">
|
||||||
|
<Network className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">知识图谱</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="list" className="flex items-center space-x-2">
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">列表视图</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-6">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 课程内容展示 */}
|
||||||
|
{!bookStructure ? (
|
||||||
|
<Card className="backdrop-blur-sm bg-card/80 border-border/50 shadow-xl">
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<BookOpen className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">暂无课程内容</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
课程内容正在准备中,请稍后再来查看
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 知识图谱视图 */}
|
||||||
|
{viewMode === 'graph' && (
|
||||||
|
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border bg-background overflow-hidden">
|
||||||
|
<KnowledgeGraph bookStructure={bookStructure} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 列表视图 */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{bookStructure.chapters.map((chapter) => (
|
||||||
|
<Card
|
||||||
|
key={chapter.id}
|
||||||
|
className="hover:shadow-lg transition-shadow backdrop-blur-sm bg-card/80 border-border/50"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl mb-3 flex items-center space-x-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-blue-600" />
|
||||||
|
<span>第{chapter.chapter_number}章 {chapter.title}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedNode({
|
||||||
|
type: 'chapter',
|
||||||
|
id: chapter.id,
|
||||||
|
title: chapter.title,
|
||||||
|
});
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>查看内容</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{chapter.sections.map((section) => (
|
||||||
|
<AccordionItem key={section.id} value={`section-${section.id}`}>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<AccordionTrigger className="text-base font-medium flex-1">
|
||||||
|
<span>{section.title}</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedNode({
|
||||||
|
type: 'section',
|
||||||
|
id: section.id,
|
||||||
|
title: section.title,
|
||||||
|
subsections: section.subsections,
|
||||||
|
});
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="mr-2 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span className="text-xs">查看</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="pt-2 pl-4 space-y-2">
|
||||||
|
{section.subsections.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无小节(知识点)</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-2">
|
||||||
|
小节(知识点) ({section.subsections.length} 个)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{section.subsections.map((subsection) => (
|
||||||
|
<li
|
||||||
|
key={subsection.id}
|
||||||
|
className="flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-2 flex-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-600 mt-2 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-sm text-muted-foreground">{subsection.title}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedNode({
|
||||||
|
type: 'subsection',
|
||||||
|
id: subsection.id,
|
||||||
|
title: subsection.title,
|
||||||
|
});
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity ml-2 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
<span className="text-xs">查看</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 节点详情对话框 */}
|
||||||
|
{selectedNode && (
|
||||||
|
<NodeDetailDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
nodeType={selectedNode.type === 'subsection' ? 'section' : selectedNode.type}
|
||||||
|
nodeId={selectedNode.type === 'subsection' ? 0 : selectedNode.id} // nodeId is not used for direct subsection display
|
||||||
|
nodeTitle={selectedNode.title}
|
||||||
|
subsections={selectedNode.type === 'section' ? selectedNode.subsections : undefined}
|
||||||
|
subsectionId={selectedNode.type === 'subsection' ? selectedNode.id : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 移动端导航 */}
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user