第一步
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# ============================================================
|
||||
# AI Knowledge Link — 环境配置模板
|
||||
# 复制为 .env 后修改;.env 已被 .gitignore 排除,绝不提交。
|
||||
# 生成密钥: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# ============================================================
|
||||
|
||||
# --- 基础 ---
|
||||
# local / production:production 下 Cookie 强制 Secure
|
||||
ENVIRONMENT=local
|
||||
# 用于 Session 签名、Fernet 加密 Secret Token 等全部密码学用途
|
||||
SECRET_KEY=change-me-run-python-c-import-secrets-token_urlsafe-48
|
||||
|
||||
# --- 数据库 ---
|
||||
# SQLite(开发/生产 MVP 均可):
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
# 未来 PostgreSQL:
|
||||
# DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/amb_rag
|
||||
|
||||
# --- 文件存储 ---
|
||||
# 本地文件系统根目录(相对于 backend/ 工作目录)
|
||||
STORAGE_ROOT=./data
|
||||
# 未来 MinIO:
|
||||
# MINIO_ENDPOINT=localhost:9000
|
||||
# MINIO_ACCESS_KEY=change-me
|
||||
# MINIO_SECRET_KEY=change-me
|
||||
|
||||
# --- 套餐 ---
|
||||
# 免费用户存储配额(字节)
|
||||
DEFAULT_STORAGE_QUOTA=104857600
|
||||
# 单文件最大(字节)
|
||||
DEFAULT_MAX_FILE_SIZE=20971520
|
||||
|
||||
# --- 公共 AI 页面 ---
|
||||
# 限流:每 token / 每 IP 每分钟
|
||||
RATE_LIMIT_PER_TOKEN_PER_MIN=60
|
||||
RATE_LIMIT_PER_IP_PER_MIN=30
|
||||
|
||||
# --- 前端 ---
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
VITE_API_BASE_URL=/api
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
*.local
|
||||
|
||||
# Env & secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Data — SQLite + uploaded files + markdown
|
||||
data/
|
||||
!data/.gitkeep
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Backup
|
||||
backup/
|
||||
@@ -0,0 +1,47 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
**AI Knowledge Link(AI知识链接)** — MVP SaaS:用户上传 Word/PDF 文档 → 自动解析为 Markdown → 建立知识库 → 生成高熵 Secret URL(`/k/{token}`)→ 用户把链接发给第三方 AI → AI 通过 SSR 纯 HTML/Markdown 页面按描述自主选择并读取文档。
|
||||
|
||||
**核心设计约束:公共 AI 页面(`/k/**`)必须零 JS、零 Cookie、零登录、SSR 输出、标准 HTML**;Secret URL 即访问凭证(128bit 熵,DB 存 SHA-256 hash,统一 404 防存在性探测)。
|
||||
|
||||
**完整需求清单(含逐节完成状态)见 `docs/requirements.md`;完整技术审查与架构决策见 `docs/technical-review.md`(最高优先级参考)**。
|
||||
|
||||
## Tech Stack (MVP v1)
|
||||
|
||||
- **Backend**: Python 3.12+(本机 venv 3.13)、FastAPI、SQLAlchemy 2.x(Mapped 风格, 同步引擎 + SQLite)、Pydantic v2、Alembic、Jinja2(公共页 SSR)
|
||||
- **Frontend**: Vue 3 + TypeScript + Vite + Element Plus + Pinia + Vue Router + Axios
|
||||
- **Storage**: SQLite(元数据)+ 服务器本地文件系统(原始文件 + Markdown)
|
||||
- **Parsing**: MarkItDown 优先, PyMuPDF(PDF fallback, AGPL—商品化前需替换为 pypdfium2), python-docx(DOCX fallback)
|
||||
- **Search**: SQLite FTS5 + KeywordRetriever 抽象
|
||||
- **Auth**: Argon2id 密码哈希 + 服务端 Session(内存/SQLite)
|
||||
- **Rate Limit**: 内存 TokenBucket(单进程)
|
||||
|
||||
**v1 明确不依赖**: PostgreSQL, Redis, Celery, MinIO, Qdrant, Elasticsearch, Kafka
|
||||
|
||||
## Environment
|
||||
|
||||
- **Windows 开发**(无 Docker):Shell 为 Git Bash(POSIX 语法)
|
||||
- Python venv: `.venv/`(激活:`source .venv/Scripts/activate`,或直接 `.venv/Scripts/python.exe`)
|
||||
- Node 24 / npm 12 已装
|
||||
- IDE 为 PyCharm,`.venv` 已被排除在模块外
|
||||
- **Docker**: 仅用于 Ubuntu 生产部署;本地开发直接跑 uvicorn + npm run dev
|
||||
|
||||
## Development Phases(需求 §56,每阶段完成必须验证后再进入下一阶段)
|
||||
|
||||
Phase 1 项目初始化 → 2 数据库+Alembic → 3 用户注册登录 → 4 知识库CRUD → 5 本地StorageService → 6 文档上传 → 7 文档解析+Markdown → 8 文档管理 → 9 Secret URL → 10 AI公共页面 → 11 多格式输出 → 12 搜索 → 13 安全 → 14 前端完善 → 15 测试 → 16 Docker → 17 Ubuntu部署文档
|
||||
|
||||
## Conventions
|
||||
|
||||
- 分层:api → services → repositories → models;公共 AI 页面独立在 `app/public/`,模板在 `app/templates/`
|
||||
- 统一错误体:`{"code": "...", "message": "中文文案", "detail": null}`;用户侧永不暴露堆栈/内部路径/storage_path/内部 UUID
|
||||
- 所有用户数据查询必须带 user_id 隔离校验(防 IDOR)
|
||||
- 文件操作全部经过 StorageService 抽象,禁止业务代码直接 `open()` / `Path()`
|
||||
- 上传:流式 SHA256、扩展名+MIME 双校验、随机存储对象名、原始文件名只做展示
|
||||
- Token: secrets.token_urlsafe(16), DB 存 SHA-256 hash + Fernet 加密原文, 统一 404
|
||||
- 测试:pytest(`backend/tests/`)
|
||||
- 代码质量:ruff + mypy(backend)、ESLint + Prettier(frontend)
|
||||
- v1 明确不做:支付、OCR、团队协作、Qdrant、自建聊天机器人、绑定特定 AI、强制 AI 端登录、Redis、Celery、MinIO、PostgreSQL
|
||||
@@ -0,0 +1,86 @@
|
||||
# AI Knowledge Link(AI知识链接)
|
||||
|
||||
把用户的 Word / PDF 文档自动解析为 Markdown、组织成知识库,并为每个知识库生成一个**高熵 Secret URL**。用户把链接发给任意支持网页访问的 AI,AI 即可按用户描述自主选择并读取文档。
|
||||
|
||||
```
|
||||
上传文档 → 自动解析 → 转换 Markdown → 建立知识库 → 生成 AI 专属链接
|
||||
→ 把链接发给任意 AI → AI 读取目录并按描述访问具体文档
|
||||
```
|
||||
|
||||
> 状态:**开发中(Phase 1 — 项目初始化)**。完整架构与技术决策见 [docs/technical-review.md](docs/technical-review.md)。
|
||||
|
||||
## 技术栈(MVP v1)
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Python 3.12+ · FastAPI · SQLAlchemy 2 · Pydantic v2 · Alembic · Jinja2 |
|
||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · Pinia |
|
||||
| 存储 | SQLite(元数据)+ 本地文件系统(原始文件 + Markdown) |
|
||||
| 解析 | MarkItDown(优先)· PyMuPDF(PDF fallback)· python-docx(DOCX fallback) |
|
||||
| 搜索 | SQLite FTS5 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/ FastAPI 应用(api 管理端 / public AI公共端 / processors 解析 / retrieval 检索 / storage 存储)
|
||||
frontend/ Vue 3 管理后台
|
||||
nginx/ 反向代理配置(生产)
|
||||
data/ SQLite + 用户文件(.gitignore 排除,仅保留 .gitkeep)
|
||||
docs/ 架构与技术文档
|
||||
```
|
||||
|
||||
## 快速开始(Windows 开发)
|
||||
|
||||
> 无需 Docker,直接运行。
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
# Git Bash:
|
||||
source .venv/Scripts/activate
|
||||
# 或 CMD:
|
||||
.venv\Scripts\activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
- API 文档:http://localhost:8000/api/docs
|
||||
- 健康检查:http://localhost:8000/api/health
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 访问:http://localhost:5173
|
||||
- Vite 代理:`/api` 和 `/k` → `localhost:8000`
|
||||
|
||||
## 生产部署(Ubuntu + Docker)
|
||||
|
||||
```bash
|
||||
git clone <repo> /opt/ai-knowledge-link
|
||||
cd /opt/ai-knowledge-link
|
||||
cp .env.example .env # 修改全部密钥
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- 数据持久化:`./data/` 宿主机 volume,删除容器不丢失
|
||||
- 备份:SQLite `.backup` + tar 用户文件目录
|
||||
|
||||
## 安全须知
|
||||
|
||||
- `.env` 携带全部密钥,**绝不提交 Git**
|
||||
- 知识库 Secret URL 即访问凭证(链接即钥匙),请勿公开传播
|
||||
- 公共 AI 页面不依赖 JS/Cookie/登录;含 `noindex` meta + robots.txt 屏蔽
|
||||
|
||||
## 文档
|
||||
|
||||
- [技术审查报告](docs/technical-review.md) — 架构、ER、API、安全模型、风险清单、6 个扩展接口
|
||||
@@ -0,0 +1,22 @@
|
||||
# AI Knowledge Link — Backend Dockerfile
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖(PyMuPDF 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# data/ 通过 volume 挂载,不进镜像
|
||||
VOLUME /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动:先跑迁移,再起服务
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""健康检查。
|
||||
|
||||
/healthz → 存活探针(liveness):不触碰依赖,快速返回。
|
||||
/api/health → 就绪探针(readiness):探测 SQLite + 文件存储。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HealthComponent(BaseModel):
|
||||
status: str
|
||||
latency_ms: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class HealthReport(BaseModel):
|
||||
status: str
|
||||
environment: str
|
||||
components: dict[str, HealthComponent]
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def liveness() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthReport)
|
||||
async def readiness(response: Response) -> HealthReport:
|
||||
report = HealthReport(
|
||||
status="ok",
|
||||
environment=get_settings().environment,
|
||||
components={
|
||||
"database": _check_db(),
|
||||
"storage": _check_storage(),
|
||||
},
|
||||
)
|
||||
if any(c.status != "ok" for c in report.components.values()):
|
||||
report.status = "degraded"
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return report
|
||||
|
||||
|
||||
def _check_db() -> HealthComponent:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(text("SELECT 1"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return HealthComponent(status="down", error=_brief(exc))
|
||||
return HealthComponent(status="ok", latency_ms=_ms(start))
|
||||
|
||||
|
||||
def _check_storage() -> HealthComponent:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
from app.storage.local_storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
root = storage._root
|
||||
if not root.exists():
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return HealthComponent(status="down", error=_brief(exc))
|
||||
return HealthComponent(status="ok", latency_ms=_ms(start))
|
||||
|
||||
|
||||
def _ms(start: float) -> int:
|
||||
return int((time.perf_counter() - start) * 1000)
|
||||
|
||||
|
||||
def _brief(exc: Exception) -> str:
|
||||
text = f"{type(exc).__name__}: {exc}"
|
||||
return text.split("\n")[0][:200]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""全局配置:pydantic-settings,全部来自环境变量 / .env。
|
||||
|
||||
规则(docs/technical-review.md §1.3):
|
||||
- 密钥不得硬编码;SECRET_KEY 缺失或仍为模板值时,生产环境拒绝启动。
|
||||
- DATABASE_URL 可切换 PostgreSQL(扩展接口 1)。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
_TEMPLATE_MARKERS = ("change-me",)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# --- 基础 ---
|
||||
environment: str = "local" # local / production
|
||||
secret_key: str = Field(min_length=16)
|
||||
|
||||
# --- 数据库 ---
|
||||
database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
# --- 文件存储 ---
|
||||
storage_root: str = "./data"
|
||||
|
||||
# --- 套餐 ---
|
||||
default_storage_quota: int = 104_857_600 # 100 MB
|
||||
default_max_file_size: int = 20_971_520 # 20 MB
|
||||
|
||||
# --- 限流 ---
|
||||
rate_limit_per_token_per_min: int = 60
|
||||
rate_limit_per_ip_per_min: int = 30
|
||||
|
||||
# --- CORS ---
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.environment == "production"
|
||||
|
||||
@property
|
||||
def storage_root_path(self) -> Path:
|
||||
return Path(self.storage_root).resolve()
|
||||
|
||||
def validate_secrets(self) -> None:
|
||||
"""生产环境禁止携带模板密钥启动。"""
|
||||
if not self.is_production:
|
||||
return
|
||||
for field_name in ("secret_key",):
|
||||
value = getattr(self, field_name).lower()
|
||||
if any(marker in value for marker in _TEMPLATE_MARKERS):
|
||||
raise RuntimeError(
|
||||
f"配置错误:{field_name} 仍为模板值,生产环境禁止启动。"
|
||||
"请运行: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
s = Settings() # type: ignore[call-arg]
|
||||
s.validate_secrets()
|
||||
return s
|
||||
@@ -0,0 +1,76 @@
|
||||
"""SQLAlchemy 2.x 同步引擎 + 会话管理(MVP: SQLite)。
|
||||
|
||||
切换 PostgreSQL 时(扩展接口 1):
|
||||
1. DATABASE_URL 改为 postgresql+asyncpg://...
|
||||
2. 引擎换 create_async_engine + async_sessionmaker
|
||||
3. get_session 改 async generator + yield
|
||||
4. 业务层 Repository 调用加 await
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
# SQLite 专属:启用 WAL 模式(并发读 + 写串行化)
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
|
||||
)
|
||||
# SQLite: 启用 WAL 模式与外键约束
|
||||
if "sqlite" in settings.database_url:
|
||||
from sqlalchemy import event, text
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_conn, _): # type: ignore[no-untyped-def]
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = sessionmaker(
|
||||
get_engine(),
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""FastAPI 依赖:请求级会话。"""
|
||||
factory = get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def dispose_engine() -> None:
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""统一业务异常与错误响应体(需求 §37 → 统一 code+message+detail)。
|
||||
|
||||
对外格式:{"code": "...", "message": "中文文案", "detail": null}
|
||||
未捕获异常兜底为 500 INTERNAL_ERROR,原始异常只进日志。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""业务异常基类。"""
|
||||
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
code: str = "INTERNAL_ERROR"
|
||||
message: str = "服务内部错误,请稍后重试。"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
detail: Any = None,
|
||||
code: str | None = None,
|
||||
) -> None:
|
||||
self.detail = detail
|
||||
if message is not None:
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_response(self) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=self.status_code,
|
||||
content={"code": self.code, "message": self.message, "detail": self.detail},
|
||||
)
|
||||
|
||||
|
||||
# --- 通用 ---
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
code = "NOT_FOUND"
|
||||
message = "资源不存在。"
|
||||
|
||||
|
||||
class PermissionDeniedError(AppError):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
code = "PERMISSION_DENIED"
|
||||
message = "没有权限访问该资源。"
|
||||
|
||||
|
||||
class AuthRequiredError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_REQUIRED"
|
||||
message = "请先登录。"
|
||||
|
||||
|
||||
class InvalidCredentialsError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_INVALID_CREDENTIALS"
|
||||
message = "用户名或密码错误。"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
code = "CONFLICT"
|
||||
message = "资源冲突。"
|
||||
|
||||
|
||||
class RateLimitedError(AppError):
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
code = "RATE_LIMITED"
|
||||
message = "请求过于频繁,请稍后再试。"
|
||||
|
||||
|
||||
# --- 上传与配额 ---
|
||||
|
||||
|
||||
class StorageQuotaExceededError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "STORAGE_QUOTA_EXCEEDED"
|
||||
message = "存储空间不足。"
|
||||
|
||||
|
||||
class FileTooLargeError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "FILE_TOO_LARGE"
|
||||
message = "单个文件大小超出限制。"
|
||||
|
||||
|
||||
class FileTypeUnsupportedError(AppError):
|
||||
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
code = "FILE_TYPE_UNSUPPORTED"
|
||||
message = "不支持的文件类型。"
|
||||
|
||||
|
||||
# --- 异常处理器 ---
|
||||
|
||||
|
||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||
if exc.status_code >= 500:
|
||||
logger.error("AppError %s: %s", exc.code, exc.message, exc_info=exc)
|
||||
return exc.to_response()
|
||||
|
||||
|
||||
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "请求参数不正确。",
|
||||
"detail": exc.errors(include_url=False, include_input=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_STATUS_CODE_MAP = {
|
||||
status.HTTP_404_NOT_FOUND: ("NOT_FOUND", "资源不存在。"),
|
||||
status.HTTP_405_METHOD_NOT_ALLOWED: ("METHOD_NOT_ALLOWED", "请求方法不被允许。"),
|
||||
status.HTTP_401_UNAUTHORIZED: ("AUTH_REQUIRED", "请先登录。"),
|
||||
}
|
||||
|
||||
|
||||
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""把框架层 HTTPException(如未匹配路由的 404)转成统一错误体。"""
|
||||
code, message = _STATUS_CODE_MAP.get(exc.status_code, ("HTTP_ERROR", str(exc.detail)))
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": code, "message": message, "detail": None},
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("Unhandled: %s %s", request.method, request.url.path, exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"code": "INTERNAL_ERROR", "message": "服务内部错误,请稍后重试。", "detail": None},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: Any) -> None:
|
||||
app.add_exception_handler(AppError, app_error_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_error_handler)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""结构化日志配置。"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
global _CONFIGURED
|
||||
if _CONFIGURED:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers = [handler]
|
||||
|
||||
for noisy in ("uvicorn.access", "asyncio"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""内存限流(MVP: 单进程 TokenBucket)。
|
||||
|
||||
未来切换 Redis: 实现 RedisRateLimiter,接口不变。
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class _Bucket:
|
||||
__slots__ = ("tokens", "max_tokens", "refill_rate", "last_refill")
|
||||
|
||||
def __init__(self, max_tokens: int, per_seconds: int) -> None:
|
||||
self.max_tokens = max_tokens
|
||||
self.refill_rate = max_tokens / per_seconds
|
||||
self.tokens = float(max_tokens)
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def allow(self) -> bool:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
self.tokens = min(self.max_tokens, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
if self.tokens >= 1.0:
|
||||
self.tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# key → Bucket
|
||||
_token_buckets: dict[str, _Bucket] = defaultdict(lambda: _Bucket(_token_limit, 60))
|
||||
_ip_buckets: dict[str, _Bucket] = defaultdict(lambda: _Bucket(_ip_limit, 60))
|
||||
|
||||
# 延迟初始化
|
||||
_token_limit = 60
|
||||
_ip_limit = 30
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_init() -> None:
|
||||
global _token_limit, _ip_limit, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
settings = get_settings()
|
||||
_token_limit = settings.rate_limit_per_token_per_min
|
||||
_ip_limit = settings.rate_limit_per_ip_per_min
|
||||
# 重建 default dict factories
|
||||
_token_buckets.default_factory = lambda: _Bucket(_token_limit, 60) # type: ignore[assignment]
|
||||
_ip_buckets.default_factory = lambda: _Bucket(_ip_limit, 60) # type: ignore[assignment]
|
||||
_initialized = True
|
||||
|
||||
|
||||
def check_rate_limit(token_key: str | None = None, ip_key: str | None = None) -> bool:
|
||||
"""检查是否允许请求。返回 True 表示允许。"""
|
||||
_ensure_init()
|
||||
if token_key and not _token_buckets[token_key].allow():
|
||||
return False
|
||||
if ip_key and not _ip_buckets[ip_key].allow():
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,74 @@
|
||||
"""服务端 Session 管理(MVP: 内存 dict + TTL)。
|
||||
|
||||
- Session token: secrets.token_urlsafe(32)
|
||||
- Cookie: HttpOnly, SameSite=Lax, 生产环境 Secure
|
||||
- 默认过期: 7 天
|
||||
|
||||
未来切换 Redis: 改 _store 为 Redis 客户端,接口不变。
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
# 内存存储:{token: SessionEntry}
|
||||
_store: dict[str, "SessionEntry"] = {}
|
||||
|
||||
SESSION_COOKIE_NAME = "session_id"
|
||||
SESSION_TTL_SECONDS = 7 * 24 * 3600 # 7 天
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionEntry:
|
||||
user_id: str
|
||||
created_at: float
|
||||
last_accessed: float
|
||||
|
||||
|
||||
def create_session(user_id: str) -> str:
|
||||
"""创建 session,返回 token(写入 Cookie)。"""
|
||||
_cleanup_expired()
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
_store[token] = SessionEntry(user_id=user_id, created_at=now, last_accessed=now)
|
||||
return token
|
||||
|
||||
|
||||
def get_session_user(token: str) -> str | None:
|
||||
"""根据 token 获取 user_id;不存在或过期返回 None。"""
|
||||
entry = _store.get(token)
|
||||
if entry is None:
|
||||
return None
|
||||
now = time.time()
|
||||
if now - entry.last_accessed > SESSION_TTL_SECONDS:
|
||||
_store.pop(token, None)
|
||||
return None
|
||||
entry.last_accessed = now
|
||||
return entry.user_id
|
||||
|
||||
|
||||
def delete_session(token: str) -> None:
|
||||
_store.pop(token, None)
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""惰性清理过期 session。"""
|
||||
now = time.time()
|
||||
expired = [t for t, e in _store.items() if now - e.last_accessed > SESSION_TTL_SECONDS]
|
||||
for t in expired:
|
||||
_store.pop(t, None)
|
||||
|
||||
|
||||
def get_cookie_params() -> dict:
|
||||
"""构建 Set-Cookie 参数。"""
|
||||
settings = get_settings()
|
||||
return {
|
||||
"key": SESSION_COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"secure": settings.is_production,
|
||||
"max_age": SESSION_TTL_SECONDS,
|
||||
"path": "/",
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""AI Knowledge Link — FastAPI 应用工厂。
|
||||
|
||||
Phase 1 只包含:配置校验、日志、异常处理器、健康检查、lifespan 资源管理。
|
||||
后续 Phase 逐步挂载:auth、knowledge-bases、documents、public /k/ 路由。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
setup_logging()
|
||||
|
||||
# 确保 data 目录存在
|
||||
data_dir = settings.storage_root_path
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Starting backend (env=%s, data=%s)", settings.environment, data_dir)
|
||||
|
||||
yield
|
||||
|
||||
dispose_engine()
|
||||
logger.info("Backend shutdown complete")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="AI Knowledge Link",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs" if not settings.is_production else None,
|
||||
openapi_url="/api/openapi.json" if not settings.is_production else None,
|
||||
)
|
||||
|
||||
# CORS(开发期允许 Vite dev server)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-Requested-With"],
|
||||
)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
# 路由挂载
|
||||
app.include_router(health_router, prefix="/api", tags=["health"])
|
||||
# Phase 3+: app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
# Phase 4+: app.include_router(kb_router, prefix="/api/knowledge-bases", tags=["knowledge-bases"])
|
||||
# Phase 10+: app.include_router(public_router, tags=["public"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""存储服务抽象(扩展接口 2:本地文件 → MinIO/S3)。"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StorageService(Protocol):
|
||||
"""文件存储统一接口。所有业务代码通过此接口读写文件,禁止直接 open()/Path()。"""
|
||||
|
||||
def save(self, key: str, data: bytes) -> str:
|
||||
"""保存数据,返回实际存储路径。"""
|
||||
...
|
||||
|
||||
def read(self, key: str) -> bytes:
|
||||
"""读取数据。"""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""删除文件。"""
|
||||
...
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""文件是否存在。"""
|
||||
...
|
||||
|
||||
def get_size(self, key: str) -> int:
|
||||
"""获取文件大小(字节)。"""
|
||||
...
|
||||
@@ -0,0 +1,67 @@
|
||||
"""本地文件系统存储实现(MVP)。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LocalStorageService:
|
||||
"""文件存储到服务器本地 data/ 目录。
|
||||
|
||||
key 格式示例:
|
||||
users/{uid}/kbs/{kb_id}/original/{rand}_{safe_name}
|
||||
users/{uid}/kbs/{kb_id}/markdown/{rand}.md
|
||||
|
||||
实际路径 = storage_root / key
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._root = settings.storage_root_path
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _resolve(self, key: str) -> Path:
|
||||
"""解析 key 为绝对路径,同时防路径穿越。"""
|
||||
path = (self._root / key).resolve()
|
||||
# 安全:确保解析后路径仍在 root 内
|
||||
if not str(path).startswith(str(self._root)):
|
||||
raise ValueError(f"路径穿越检测:key={key}")
|
||||
return path
|
||||
|
||||
def save(self, key: str, data: bytes) -> str:
|
||||
path = self._resolve(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
logger.debug("Saved file: %s (%d bytes)", key, len(data))
|
||||
return key
|
||||
|
||||
def read(self, key: str) -> bytes:
|
||||
path = self._resolve(key)
|
||||
return path.read_bytes()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = self._resolve(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
logger.debug("Deleted file: %s", key)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
return self._resolve(key).exists()
|
||||
|
||||
def get_size(self, key: str) -> int:
|
||||
path = self._resolve(key)
|
||||
return path.stat().st_size
|
||||
|
||||
|
||||
# 单例
|
||||
_instance: LocalStorageService | None = None
|
||||
|
||||
|
||||
def get_storage() -> LocalStorageService:
|
||||
global _instance
|
||||
if _instance is None:
|
||||
_instance = LocalStorageService()
|
||||
return _instance
|
||||
@@ -0,0 +1,50 @@
|
||||
"""对象存储 key 生成。
|
||||
|
||||
布局:users/{user_id}/kbs/{kb_id}/original/{rand8}_{safe_filename}
|
||||
users/{user_id}/kbs/{kb_id}/markdown/{rand8}.md
|
||||
|
||||
规则(需求 §27 / §23):
|
||||
- 不信任用户文件名:取 basename,剔除路径穿越与控制字符,截断长度。
|
||||
- 主体是内部 UUID,公共 URL 使用 token,绝不回显此 key。
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]+")
|
||||
_MAX_FILENAME_LEN = 120
|
||||
|
||||
|
||||
def sanitize_filename(original: str) -> str:
|
||||
name = original.replace("\\", "/").split("/")[-1]
|
||||
name = _SAFE_NAME_RE.sub("_", name).strip("._")
|
||||
if not name:
|
||||
name = "file"
|
||||
return name[:_MAX_FILENAME_LEN]
|
||||
|
||||
|
||||
def original_object_key(
|
||||
*,
|
||||
user_id: str,
|
||||
knowledge_base_id: str,
|
||||
document_id: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
rand = secrets.token_urlsafe(6) # ~8 字符
|
||||
safe = sanitize_filename(original_filename)
|
||||
return (
|
||||
f"users/{user_id}/kbs/{knowledge_base_id}/docs/{document_id}/original/{rand}_{safe}"
|
||||
)
|
||||
|
||||
|
||||
def markdown_object_key(
|
||||
*,
|
||||
user_id: str,
|
||||
knowledge_base_id: str,
|
||||
document_id: str,
|
||||
) -> str:
|
||||
rand = secrets.token_urlsafe(6)
|
||||
return (
|
||||
f"users/{user_id}/kbs/{knowledge_base_id}/docs/{document_id}/markdown/{rand}.md"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "S", "C4", "ASYNC", "RUF"]
|
||||
ignore = ["S101"] # S101: tests 里允许 assert
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S105", "S106"]
|
||||
"alembic/versions/**" = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
disallow_untyped_defs = true
|
||||
check_untyped_defs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
@@ -0,0 +1,39 @@
|
||||
# AI Knowledge Link — backend dependencies (MVP v1)
|
||||
# Python 3.12+(开发机为 3.13)
|
||||
|
||||
# --- Web 框架 ---
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.3
|
||||
jinja2>=3.1
|
||||
|
||||
# --- 数据库 ---
|
||||
sqlalchemy>=2.0.30
|
||||
alembic>=1.13
|
||||
|
||||
# --- 安全 ---
|
||||
argon2-cffi>=23.1
|
||||
cryptography>=42.0
|
||||
|
||||
# --- 文档解析 ---
|
||||
# MIME 嗅探:纯 Python,Windows 无需 libmagic DLL
|
||||
filetype>=1.2
|
||||
# MarkItDown(MIT)
|
||||
markitdown>=0.1.0
|
||||
# PDF fallback: PyMuPDF(AGPL-3.0)
|
||||
# TODO: 商品化前替换为 pypdfium2(Apache-2.0/BSD),见 docs/technical-review.md R3
|
||||
PyMuPDF>=1.24.0
|
||||
# DOCX fallback
|
||||
python-docx>=1.1
|
||||
# 中文分词(关键词提取)
|
||||
jieba>=0.42
|
||||
|
||||
# --- Markdown 安全渲染 ---
|
||||
bleach>=6.1
|
||||
|
||||
# --- 开发 / 测试 ---
|
||||
pytest>=8.2
|
||||
httpx>=0.27
|
||||
ruff>=0.5
|
||||
mypy>=1.10
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Phase 1 冒烟测试:应用可创建、健康检查可用、错误体格式正确。"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
# with 语句确保 lifespan(配置校验、data 目录创建)真实执行
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_liveness() -> None:
|
||||
with _client() as client:
|
||||
resp = client.get("/api/healthz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_readiness_all_components() -> None:
|
||||
with _client() as client:
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert set(body["components"]) == {"database", "storage"}
|
||||
assert all(c["status"] == "ok" for c in body["components"].values())
|
||||
|
||||
|
||||
def test_404_uses_unified_error_envelope() -> None:
|
||||
with _client() as client:
|
||||
resp = client.get("/api/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["code"] == "NOT_FOUND"
|
||||
assert isinstance(body["message"], str)
|
||||
@@ -0,0 +1,45 @@
|
||||
# AI Knowledge Link — Docker Compose (生产)
|
||||
# Ubuntu 服务器部署用。Windows 开发不需要 Docker。
|
||||
#
|
||||
# 数据持久化:./data 挂载到 backend,SQLite + 用户文件均在其中。
|
||||
# 删除容器后数据不丢失。
|
||||
#
|
||||
# 用法:
|
||||
# cp .env.example .env # 修改密钥
|
||||
# mkdir -p data
|
||||
# docker compose up -d --build
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
expose:
|
||||
- "8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
- frontend
|
||||
@@ -0,0 +1,342 @@
|
||||
# AI Knowledge Link — 产品需求文档(MVP v1)
|
||||
|
||||
> 状态标注说明:✅ 已完成 | 🔄 部分完成 | ⬜ 未开始(对应 Phase N) | 📌 持续约束(贯穿全程)
|
||||
>
|
||||
> **版本说明**:本文档为当前有效需求(MVP 版)。早前一版需求(PostgreSQL + Redis + Celery + MinIO 全功能栈)已被本 MVP 版**取代**,核心差异:SQLite 替代 PostgreSQL、本地文件系统替代 MinIO、同步解析替代 Celery、内存限流替代 Redis、FTS5 替代 jieba+tsvector。原版中的商业许可审查结论仍有效(PDF 解析商品化前需将 PyMuPDF 替换为 pypdfium2,见 `technical-review.md` R3)。
|
||||
>
|
||||
> 关联文档:[技术审查报告](technical-review.md) | [CLAUDE.md](../CLAUDE.md)
|
||||
|
||||
## Phase 进度总览(对应 §56 开发顺序)
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | 项目初始化 | ✅ 完成 |
|
||||
| 2 | 数据库和 Alembic | ⬜ |
|
||||
| 3 | 用户注册登录 | ⬜ |
|
||||
| 4 | 知识库 CRUD | ⬜ |
|
||||
| 5 | 本地 StorageService | ✅ 完成(提前实现于 Phase 1) |
|
||||
| 6 | 文档上传 | ⬜ |
|
||||
| 7 | 文档解析和 Markdown | ⬜ |
|
||||
| 8 | 文档管理 | ⬜ |
|
||||
| 9 | Secret URL | ⬜ |
|
||||
| 10 | AI 公共页面 | ⬜ |
|
||||
| 11 | Markdown/TXT/JSON 输出 | ⬜ |
|
||||
| 12 | 搜索 | ⬜ |
|
||||
| 13 | 安全 | ⬜ |
|
||||
| 14 | 前端完善 | ⬜ |
|
||||
| 15 | 测试 | ⬜ |
|
||||
| 16 | Docker | 🔄 compose/Dockerfile 已写(Phase 1),待 Docker 环境验证 |
|
||||
| 17 | Ubuntu 部署文档 | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 一、产品核心目标 ⬜(产品定位,随各 Phase 逐步实现)
|
||||
|
||||
这个产品不是普通网盘。
|
||||
|
||||
核心功能是:
|
||||
|
||||
用户上传自己的 Word、PDF 等文档 → 系统自动解析文档 → 转换成 Markdown → 建立个人知识库 → 系统生成一个 AI 专属访问链接 → 用户复制这个链接 → 发送给第三方 AI → AI 访问知识库 → AI 查看知识库中的文档列表 → 根据用户的描述选择一个或多个相关文档 → 访问具体文档内容 → 利用这些资料回答用户问题
|
||||
|
||||
例如:用户上传 公司介绍.docx、产品说明.pdf、技术架构.pdf、项目需求.docx,系统生成 `https://example.com/k/xxxxxxxx`。用户把这个链接发送给 AI:"请访问这个知识库,阅读产品A相关的技术架构和产品说明,然后告诉我产品A有哪些技术风险。" AI 访问 /k/xxxxxxxx,看到文档列表,然后根据用户要求访问相关文档。
|
||||
|
||||
## 二、第一阶段必须保持简单 📌 持续约束(Phase 1 已遵循)
|
||||
|
||||
这是一个 MVP。第一版禁止过度工程化。
|
||||
|
||||
第一版只使用:
|
||||
|
||||
- 前端:Vue 3、TypeScript、Vite、Element Plus、Pinia、Axios
|
||||
- 后端:Python 3.12+、FastAPI、SQLAlchemy、Pydantic、Alembic
|
||||
- 数据库:SQLite
|
||||
- 文件存储:服务器本地文件系统
|
||||
- 文档解析:MarkItDown、PyMuPDF、python-docx
|
||||
- 部署:Docker Compose,可以使用 Nginx
|
||||
|
||||
第一版禁止强制依赖:PostgreSQL、Redis、Celery、MinIO、Qdrant、Elasticsearch、Kafka。不要因为未来可能商品化而提前加入这些组件。
|
||||
|
||||
## 三、开发环境 ✅ 完成(Phase 1 验证通过)
|
||||
|
||||
本地开发环境:Windows。本地没有 Docker。因此 Windows 开发必须可以直接运行。
|
||||
|
||||
- 前端:`npm install` → `npm run dev` ✅
|
||||
- 后端:Python venv → `pip install` → `uvicorn` ✅
|
||||
- 本地开发不要求 Docker ✅
|
||||
- Docker 主要用于 Ubuntu 云服务器
|
||||
|
||||
生产环境:Ubuntu、Docker、Docker Compose。
|
||||
|
||||
## 四、第一版系统架构 ⬜(架构已定,实现随 Phase 推进)
|
||||
|
||||
采用:
|
||||
|
||||
- Windows 开发:Vue 3 + FastAPI + SQLite + 本地文件
|
||||
- 生产:Ubuntu + Docker(frontend / backend / nginx)
|
||||
|
||||
数据目录:
|
||||
|
||||
```
|
||||
/data
|
||||
├── app.db
|
||||
└── users/{user_id}/knowledge_bases/{knowledge_base_id}/
|
||||
├── original/
|
||||
└── markdown/
|
||||
```
|
||||
|
||||
SQLite 保存元数据。文件系统保存:Word、PDF、Markdown。
|
||||
|
||||
## 五、不要把文件塞进 SQLite 📌 持续约束(存储层设计已按此实现)
|
||||
|
||||
禁止把原始 PDF、Word 二进制内容存进 SQLite。SQLite 只保存:用户信息、知识库信息、文档元数据、文件路径、Markdown 路径、文档状态、Token hash、时间、分类、描述、关键词、搜索相关数据。原始文件与 Markdown 均存服务器文件系统。
|
||||
|
||||
## 六、数据库访问必须抽象 ⬜ Phase 2(Phase 1 已建 db.py 引擎层)
|
||||
|
||||
不能在业务代码中到处直接调用 sqlite3。必须使用 SQLAlchemy,并通过 Repository / Service 分层(UserRepository、KnowledgeBaseRepository、DocumentRepository)。业务逻辑不能依赖 SQLite 具体实现。数据库配置通过 `DATABASE_URL`(如 `sqlite:///./data/app.db`),未来可以切换 PostgreSQL 而尽量不修改业务层。
|
||||
|
||||
## 七、未来扩展接口 1:SQLite → PostgreSQL 📌 持续约束(设计规则已在 db.py 落地)
|
||||
|
||||
第一版使用 SQLite,但设计 DatabaseRepository、SQLAlchemy models、Alembic migrations。不要使用 SQLite 特有 SQL 作为核心业务逻辑;避免 PRAGMA(WAL 设置除外)、SQLite 专属函数、SQLite 专属数据类型。业务代码尽可能使用标准 SQLAlchemy。未来切换只需更换连接配置并处理迁移。
|
||||
|
||||
## 八、未来扩展接口 2:本地文件 → MinIO/S3 ✅ 完成(Phase 1 提前实现)
|
||||
|
||||
已实现 `StorageService` 协议(`storage/base.py`:save/read/delete/exists/get_size)与 `LocalStorageService`(`storage/local_storage.py`)。业务代码不能直接 `open("/data/xxx")`,所有文件读写必须经过 StorageService。未来可增加 MinioStorageService / S3StorageService。
|
||||
|
||||
## 九、未来扩展接口 3:同步解析 → 异步任务 ⬜ Phase 7(DocumentProcessor 协议)
|
||||
|
||||
第一版:上传文件 → FastAPI → 同步解析 → 保存 Markdown → 返回结果。必须抽象 `DocumentProcessor`(如 `process_document()`):第一版 `LocalDocumentProcessor`,未来可增加 `CeleryDocumentProcessor`。业务代码不要把文档解析逻辑写进 API endpoint。
|
||||
|
||||
## 十、未来扩展接口 4:关键词搜索 → RAG ⬜ Phase 12(Retriever 协议)
|
||||
|
||||
第一版不使用向量数据库。实现 `Retriever` 统一接口(`search(query, knowledge_base_id)`),第一版 `KeywordRetriever`(SQLite FTS 或简单关键词搜索)。未来可加 VectorRetriever / HybridRetriever / QdrantRetriever、Embedding、Reranker。第一版不安装这些依赖。
|
||||
|
||||
## 十一、未来扩展接口 5:个人用户 → 团队/企业 📌 持续约束(数据库设计时遵守)
|
||||
|
||||
第一版:一个用户拥有多个知识库(User → KnowledgeBase → Documents)。但数据库设计必须避免以后无法扩展。未来:Organization → Members → KnowledgeBases → Documents。第一版不实现团队,但不要把 user_id 硬编码成未来无法扩展的权限结构。
|
||||
|
||||
## 十二、未来扩展接口 6:AI 网页访问 → API/MCP 📌 持续约束(Phase 10 落地)
|
||||
|
||||
第一版:AI 通过 Secret URL 访问(GET /k/{token}),这是产品核心功能。未来可增加 API Key、REST API、MCP Server、OpenAPI、AI Agent 接口。第一版不实现 MCP,但公共知识库服务层必须独立:HTML、Markdown、TXT、JSON 都调用同一个 `KnowledgeBasePublicService`(Controller → Service → Repository → Database),不要每种格式各写一套查询逻辑。
|
||||
|
||||
## 十三、用户系统 ⬜ Phase 3
|
||||
|
||||
实现注册、登录、退出登录。
|
||||
|
||||
User:id、username、email、password_hash、status、storage_quota、storage_used、created_at、updated_at。密码必须安全哈希,优先 Argon2id;禁止明文密码、MD5、SHA1。
|
||||
|
||||
## 十四、存储限制 🔄 部分完成(config.py 已有配额配置;Plan 模型 Phase 2 落地)
|
||||
|
||||
默认免费用户 100MB,单文件 20MB。不要硬编码,放到配置或 Plan 模型。第一版 Free Plan(100MB / 20MB-per-file),未来 Basic 1GB、Pro 5GB、Enterprise 自定义。第一版不实现支付。
|
||||
|
||||
## 十五、知识库 ⬜ Phase 4
|
||||
|
||||
KnowledgeBase:id、user_id、name、description、enabled、secret_token_hash、created_at、updated_at。一个用户可以创建多个知识库(公司知识库、项目A、项目B、个人AI资料)。
|
||||
|
||||
## 十六、AI 专属链接 ⬜ Phase 9
|
||||
|
||||
每个知识库生成一个 Secret Token(`https://example.com/k/7fA92xKpQ8m...`)。必须使用密码学安全随机数(`secrets.token_urlsafe()` 或同等方案);禁止自增 ID、时间戳、用户名、UUID 短截断。数据库保存 token_hash 而不是明文 Token。用户复制的完整 URL 只在生成时提供给用户。
|
||||
|
||||
## 十七、AI 链接安全模型 ⬜ Phase 9/13
|
||||
|
||||
公共 AI 访问不需要登录(第三方 AI 无法使用用户后台登录状态),Secret URL 本身就是访问凭证,相当于 Bearer Credential——任何获得 URL 的人理论上都可以读取知识库,必须在产品中明确这一点。提供启用/禁用链接、重新生成链接;重新生成后旧链接立即失效。
|
||||
|
||||
## 十八、AI 知识库首页 ⬜ Phase 10
|
||||
|
||||
`GET /k/{token}` 必须返回服务端生成的 HTML,不能依赖 Vue / JavaScript(第三方 AI 可能不执行 JS),页面必须首屏直接包含核心信息。页面结构:title=知识库名称、`<meta name="robots" content="noindex,nofollow,noarchive">`、知识库名称、知识库描述、Documents 列表(每篇含标题、描述、类型、更新时间、URL)。
|
||||
|
||||
## 十九、多格式输出 ⬜ Phase 11
|
||||
|
||||
- `GET /k/{token}` → HTML
|
||||
- `GET /k/{token}.md` → Markdown
|
||||
- `GET /k/{token}.txt` → 纯文本
|
||||
- `GET /k/{token}.json` → JSON
|
||||
|
||||
JSON 只能返回必要的信息,禁止:用户密码、token hash、内部数据库 ID、服务器路径、MinIO 地址、内部配置。
|
||||
|
||||
## 二十、单文档访问 ⬜ Phase 10/11
|
||||
|
||||
- `GET /k/{token}/doc/{document_token}` → HTML
|
||||
- `GET /k/{token}/doc/{document_token}.md` → Markdown
|
||||
- `GET /k/{token}/doc/{document_token}.txt` → TXT
|
||||
|
||||
文档页面:标题、描述、文件类型、关键词、更新时间、Markdown 正文。默认不直接提供原始文件下载。
|
||||
|
||||
## 二十一、文档 Token ⬜ Phase 9
|
||||
|
||||
不要使用 /doc/1 这种容易猜测的 URL。每个文档生成随机 document_token,数据库保存 hash。例如 `/k/knowledge-token/doc/document-token`,两个 Token 都必须随机。
|
||||
|
||||
## 二十二、文档上传 ⬜ Phase 6
|
||||
|
||||
支持 .docx、.pdf;暂不支持 .doc、.xls、.xlsx、.ppt、.pptx,但代码架构预留扩展。
|
||||
|
||||
上传流程:POST /api/documents/upload → 检查登录 → 检查知识库权限 → 检查文件大小 → 检查扩展名 → 检查 MIME → 计算 SHA256 → 检查存储额度 → 保存原始文件 → 解析 → 转换 Markdown → 提取标题 → 生成摘要 → 提取关键词 → 保存 Markdown → 更新 Document → 返回结果。
|
||||
|
||||
## 二十三、文件目录 📌 持续约束(key 生成已实现于 storage/object_keys.py)
|
||||
|
||||
推荐布局:
|
||||
|
||||
```
|
||||
data/
|
||||
├── app.db
|
||||
└── users/{user_id}/knowledge_bases/{knowledge_base_id}/
|
||||
├── original/ (random-name.docx / random-name.pdf)
|
||||
└── markdown/ (random-name.md)
|
||||
```
|
||||
|
||||
不要使用用户原始文件名直接作为物理存储路径(如 `../../evil.pdf` 必须安全处理)。
|
||||
|
||||
## 二十四、文档解析 ⬜ Phase 7
|
||||
|
||||
优先 MarkItDown;PDF 用 PyMuPDF 作为辅助/fallback;Word 用 python-docx 作为 fallback。Markdown 需尽可能保留:标题、段落、列表、表格、粗体、斜体、代码块、引用。解析失败时 Document.status = FAILED,不向用户显示 Traceback / 服务器路径 / Python 异常堆栈,只显示友好错误("文档解析失败,请检查文件是否损坏或格式是否受支持。"),服务器日志记录详细错误。
|
||||
|
||||
## 二十五、扫描 PDF ⬜ Phase 7
|
||||
|
||||
第一版不强制 OCR。如果 PDF 没有文本层,提示"该 PDF 可能是扫描件,当前版本暂不支持 OCR。"未来预留 OCRProcessor(PaddleOCR 等),第一版不加入 OCR。
|
||||
|
||||
## 二十六、文档状态 ⬜ Phase 2(状态模型)/ Phase 7(流转)
|
||||
|
||||
PENDING、PROCESSING、READY、FAILED、DELETED。虽然第一版同步处理,但状态模型必须保留,未来异步任务可以直接使用。
|
||||
|
||||
## 二十七、知识库搜索 ⬜ Phase 12
|
||||
|
||||
`GET /k/{token}/search?q=关键词` 返回:文档、标题、匹配摘要、URL。第一版用 SQLite FTS 或简单关键词搜索,实现 Retriever 接口(第一版 KeywordRetriever)。不引入 Qdrant、Embedding。
|
||||
|
||||
## 二十八、AI 选择多个文档 ⬜ Phase 10(产品最重要功能之一)
|
||||
|
||||
知识库首页不能把所有文档正文全部输出。首页应提供:文档标题、文档描述、关键词、文档类型、文档 URL。例如"产品A技术架构(关键词:产品A、架构、服务器、数据库、Redis)→ /k/xxxx/doc/yyyy"。这样 AI 可以根据用户描述选择文档——用户说"请阅读产品A的架构和数据库设计",AI 应能发现并访问"产品A技术架构"和"产品A数据库设计"两个文档。
|
||||
|
||||
## 二十九、AI 兼容性 ⬜ Phase 10/11(README 声明 Phase 17)
|
||||
|
||||
不能假设第三方 AI 一定支持 JavaScript、Cookie、登录、API 调用、自动跟随所有链接、搜索接口。AI 页面必须:服务端渲染、HTML 标准、正文直接返回、链接使用标准 `<a href>`、不依赖 JS、不使用复杂 SPA、内容结构清晰。同时提供 HTML / Markdown / TXT / JSON,尽可能提高 DeepSeek、豆包、Kimi、ChatGPT 及其他支持网页访问的 AI 的兼容性。README 必须明确:第三方 AI 是否支持访问外部 URL、跟随链接和读取页面,由第三方 AI 自身能力决定,本系统不能保证所有 AI 都一定会访问后续文档。
|
||||
|
||||
## 三十、前端 🔄 骨架完成(Phase 1);完整 UI ⬜ Phase 14
|
||||
|
||||
Vue 3、TypeScript、Vite、Element Plus、Pinia。UI 要求:现代、简洁、SaaS 风格、中文、不要花哨、不要大量动画。主要页面:/login、/register、/dashboard、/knowledge-bases、/knowledge-bases/:id、/settings。
|
||||
|
||||
## 三十一、登录页面 🔄 占位页已有(Phase 1);功能 ⬜ Phase 3/14
|
||||
|
||||
登录:用户名/邮箱、密码、登录。注册:用户名、邮箱、密码、确认密码。基本校验。
|
||||
|
||||
## 三十二、Dashboard 🔄 占位页已有(Phase 1);功能 ⬜ Phase 14
|
||||
|
||||
显示:知识库数量、文档数量、已使用空间/总空间(如 32MB / 100MB)、最近知识库、最近上传、解析失败文件。
|
||||
|
||||
## 三十三、知识库管理 ⬜ Phase 4(接口)/ Phase 14(UI)
|
||||
|
||||
显示:知识库名称、描述、文档数量、空间、创建时间、AI 链接。操作:管理、复制 AI 链接、预览、启用/禁用、重新生成链接、删除。
|
||||
|
||||
## 三十四、文档管理 ⬜ Phase 6/8(接口)/ Phase 14(UI)
|
||||
|
||||
支持拖拽上传、多文件上传。显示:文件名、类型、大小、状态、更新时间。操作:预览、重新解析、删除。
|
||||
|
||||
## 三十五、文档编辑 ⬜ Phase 8
|
||||
|
||||
用户可以修改:标题、描述、关键词、分类。Markdown 正文默认由系统生成。第一版可提供 Markdown 预览;是否允许直接编辑 Markdown 不是最高优先级,时间有限可暂时只读。
|
||||
|
||||
## 三十六、权限 ⬜ Phase 13(编码时全程遵守)
|
||||
|
||||
必须防止 IDOR:用户A访问 /api/knowledge-bases/10,不能通过修改 10 → 11 读取用户B知识库。所有后台接口必须验证:当前登录用户 → 资源 owner → 允许操作。不能只验证资源 ID 存在。
|
||||
|
||||
## 三十七、公共 AI 链接权限 ⬜ Phase 9
|
||||
|
||||
公共 AI URL 不要求登录,但必须验证 Secret Token。enabled = false 返回 404。重新生成 Token 后旧 Token 立即失效。
|
||||
|
||||
## 三十八、安全 ⬜ Phase 13(编码时全程遵守)
|
||||
|
||||
考虑:SQL 注入、XSS、CSRF、路径穿越、恶意文件、超大文件、Zip Bomb、暴力破解、Token 猜测、IDOR、资源耗尽。上传文件:限制大小、限制扩展名、限制 MIME、随机物理文件名。Markdown 输出必须防存储型 XSS;用户输入的标题、描述、关键词不能直接插入 HTML;Markdown 渲染需要安全处理。
|
||||
|
||||
## 三十九、限流 ✅ RateLimiter 抽象完成(Phase 1);接入公共 URL ⬜ Phase 13
|
||||
|
||||
第一版不需要 Redis,可以使用内存限流(如单 IP 每分钟一定次数),保护公共 AI URL。注意内存限流只适合单实例 MVP,代码中抽象 RateLimiter,未来可实现 RedisRateLimiter。(已实现 `core/rate_limit.py`:内存 TokenBucket + 配置化阈值。)
|
||||
|
||||
## 四十、访问日志 ⬜ Phase 2(模型)/ Phase 10(记录)
|
||||
|
||||
第一版可简单记录:knowledge_base_id、document_id、timestamp、user_agent、请求类型。不要默认长期保存完整 IP。后台可显示访问次数、最近访问时间。不要声称可以准确判断访问者是不是 AI,使用"外部访问"而不是"AI 访问"。
|
||||
|
||||
## 四十一、数据库模型 ⬜ Phase 2
|
||||
|
||||
至少:User、Plan、KnowledgeBase、Document、DocumentCategory、AccessLog。
|
||||
|
||||
Document:id、knowledge_base_id、user_id、document_token_hash、original_filename、storage_path、markdown_path、file_size、mime_type、sha256、title、description、keywords、category_id、status、created_at、updated_at。
|
||||
|
||||
KnowledgeBase:id、user_id、name、description、secret_token_hash、enabled、created_at、updated_at。
|
||||
|
||||
## 四十二、数据库迁移 ⬜ Phase 2
|
||||
|
||||
使用 Alembic,即使 SQLite 也必须使用迁移。不要手工修改生产数据库。README 提供:初始化迁移、升级迁移、回滚迁移。
|
||||
|
||||
## 四十三、API 设计 🔄 分 Phase 实现
|
||||
|
||||
认证:POST /api/auth/register、POST /api/auth/login、POST /api/auth/logout、GET /api/me
|
||||
知识库:GET/POST /api/knowledge-bases、GET/PUT/DELETE /api/knowledge-bases/{id}
|
||||
知识库链接:POST /api/knowledge-bases/{id}/regenerate-token、/enable、/disable
|
||||
文档:GET /api/knowledge-bases/{id}/documents、POST /api/knowledge-bases/{id}/documents、GET/PUT/DELETE /api/documents/{id}、POST /api/documents/{id}/reprocess
|
||||
存储:GET /api/storage
|
||||
公共:GET /k/{token}(+.md/.txt/.json)、GET /k/{token}/search?q=、GET /k/{token}/doc/{document_token}(+.md/.txt)
|
||||
|
||||
## 四十四、统一 Service 层 🔄 分 Phase 实现(Phase 1 已落 StorageService)
|
||||
|
||||
不要把业务逻辑全部写进 FastAPI 路由。至少:AuthService、UserService、KnowledgeBaseService、DocumentService、DocumentProcessor、StorageService、KnowledgeBasePublicService、Retriever、RateLimiter。
|
||||
|
||||
## 四十五、公共 AI 访问统一 Service ⬜ Phase 10
|
||||
|
||||
HTML / Markdown / TXT / JSON 各 Renderer 都必须:KnowledgeBasePublicService → 返回知识库数据 → 各格式 Renderer。所有格式必须使用同一套数据来源。
|
||||
|
||||
## 四十六、Docker 🔄 compose/Dockerfile 已写(Phase 1);验证 ⬜ Phase 16
|
||||
|
||||
虽然 Windows 开发不需要 Docker,但项目必须提供生产 Docker 配置。docker-compose.yml:frontend、backend、nginx。SQLite 和文件:宿主机 ./data 挂载到 /app/data。重要:数据库和用户文件不能写进 Docker 镜像;删除容器以后数据仍然存在。
|
||||
|
||||
## 四十七、生产目录 ⬜ Phase 17
|
||||
|
||||
服务器 /opt/ai-knowledge-link/:docker-compose.yml、.env、data/(app.db + users/)、nginx/、project/(或合理目录)。data 必须独立于代码。
|
||||
|
||||
## 四十八、备份 ⬜ Phase 17
|
||||
|
||||
第一版使用 SQLite,必须提供备份脚本:app.db + users/。建议每日备份,保留最近 7 天、最近 4 周。备份不能影响正在运行的服务。README 说明如何备份、如何恢复。
|
||||
|
||||
## 四十九、Git ✅ 完成(Phase 1)
|
||||
|
||||
已提供 .gitignore(禁止提交:.env、data/、*.db、上传文件、虚拟环境、node_modules)与 .env.example。
|
||||
|
||||
## 五十、Windows 开发 ✅ 完成(Phase 1,README 已含完整步骤;实测通过)
|
||||
|
||||
README 必须详细说明:安装 Python、创建 venv(`python -m venv .venv`、Windows 激活 `.venv\Scripts\activate`)、安装依赖(`pip install -r requirements.txt`)、启动 FastAPI(`uvicorn app.main:app --reload`);安装 Node、`npm install`、`npm run dev`。
|
||||
|
||||
## 五十一、生产部署 ⬜ Phase 17
|
||||
|
||||
README 必须详细说明:Ubuntu 安装 Docker、克隆 Git 仓库、配置 .env、创建 data 目录、启动(`docker compose up -d --build`)、查看(`docker compose ps`)、日志(`docker compose logs -f`)、更新(`git pull && docker compose up -d --build`)。数据不能因为更新丢失。
|
||||
|
||||
## 五十二、测试 🔄 冒烟测试 3 条通过(Phase 1);完整覆盖 ⬜ Phase 15
|
||||
|
||||
必须编写测试,至少覆盖:注册、登录、错误密码、创建知识库、删除知识库、用户隔离、上传 DOCX、上传 PDF、文件大小限制、存储空间限制、文档解析、Markdown 生成、Token 生成、Token 禁用、Token 重新生成、旧 Token 失效、公共知识库 HTML、公共 Markdown、公共 TXT、公共 JSON、单文档访问、文档 Token、搜索、XSS 防护、路径穿越、IDOR。
|
||||
|
||||
## 五十三、项目目录 ✅ 完成(Phase 1)
|
||||
|
||||
```
|
||||
project/
|
||||
├── backend/(app/{api,core,models,schemas,repositories,services,processors,storage,retrieval,public}、alembic/、tests/、requirements.txt、Dockerfile)
|
||||
├── frontend/(src/{api,components,layouts,views,stores,router,types}、package.json、Dockerfile)
|
||||
├── nginx/nginx.conf
|
||||
├── data/.gitkeep
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 五十四、代码原则 📌 持续约束(Phase 1 已遵循)
|
||||
|
||||
不要:一个 main.py 写完所有东西;所有数据库查询写在路由;业务代码直接 open 文件;硬编码路径;硬编码 100MB;硬编码 Secret Token;把密码明文保存;把用户 A/B 数据混在一起;使用 Vue 渲染 AI 公共页面。
|
||||
|
||||
## 五十五、产品第一阶段验收标准 ⬜ 最终验收(Phase 15 后)
|
||||
|
||||
1. 用户注册。2. 用户登录。3. 创建"公司知识库"。4. 上传 公司介绍.docx、产品说明.pdf、技术架构.pdf。5. 系统自动转换 Markdown。6. 后台显示 3 个文档。7. 用户复制 AI 专属链接。8. 退出登录。9. 未登录状态打开链接仍能访问知识库。10. 页面显示知识库名称、描述、文档列表、文档描述、文档 URL。11. 访问其中一个文档能看到 Markdown 正文。12. 访问 .md 得到 Markdown。13. 访问 .txt 得到纯文本。14. 访问 .json 得到机器可读数据。15. /search?q=产品A 能找到相关文档。16. 重新生成 AI 链接后旧链接立即失效。17. 禁用 AI 链接后公共访问失败。18. 用户A无法访问用户B后台数据。19. 用户A无法通过修改 ID 读取用户B资源。20. Docker 部署后删除容器并重新创建,用户数据不能丢失。
|
||||
|
||||
## 五十六、第一阶段开发顺序 ✅ Phase 1 完成,按序推进中
|
||||
|
||||
Phase 1 项目初始化 → Phase 2 数据库和 Alembic → Phase 3 用户注册登录 → Phase 4 知识库 CRUD → Phase 5 本地 StorageService → Phase 6 文档上传 → Phase 7 文档解析和 Markdown → Phase 8 文档管理 → Phase 9 Secret URL → Phase 10 AI 公共页面 → Phase 11 Markdown/TXT/JSON 输出 → Phase 12 搜索 → Phase 13 安全 → Phase 14 前端完善 → Phase 15 测试 → Phase 16 Docker → Phase 17 Ubuntu 部署文档。
|
||||
|
||||
每完成一个 Phase:1. 检查代码 2. 运行测试 3. 修复错误 4. 确认没有破坏之前功能 5. 再进入下一阶段。
|
||||
|
||||
## 五十七、当前任务 ✅ 完成(技术审查报告见 technical-review.md)
|
||||
|
||||
第一步不要立即创建大量业务代码,先对整个需求进行技术审查,输出:最终架构、数据库 ER 结构、API 结构、项目目录、六个未来扩展接口如何设计、安全模型、文档处理流程、AI 访问流程、Windows 开发流程、Docker 生产流程、发现的风险、建议修改的地方。确认后从 Phase 1 开始,不要跳过架构审查。
|
||||
@@ -0,0 +1,536 @@
|
||||
# AI Knowledge Link — 技术审查报告(MVP v1)
|
||||
|
||||
> 审查范围:需求文档全文(57 节)。
|
||||
> 核心原则:**MVP 保持简单,6 个扩展接口预留但不实现,SQLite + 本地文件 + 同步解析**。
|
||||
> 结论:架构成立,核心需求可实现。发现 1 个必须修改的设计、若干建议补充。
|
||||
|
||||
---
|
||||
|
||||
## 1. 最终架构
|
||||
|
||||
### 1.1 架构总览
|
||||
|
||||
```
|
||||
开发环境(Windows) 生产环境(Ubuntu Docker)
|
||||
───────────────── ─────────────────────
|
||||
Vue3 dev server ─┐ nginx :80
|
||||
├─► FastAPI :8000 ├── /api/** → backend:8000
|
||||
uvicorn --reload ─┘ ├── /k/** → backend:8000
|
||||
└── / → frontend:80
|
||||
|
||||
FastAPI Docker Compose:
|
||||
├── /api/** 管理端(需登录) ├── frontend (Vue build → nginx serve)
|
||||
├── /k/** 公共AI页(SSR, 零JS) ├── backend (uvicorn)
|
||||
└── SQLite + 本地文件系统 └── nginx
|
||||
|
||||
/data/ /data/ (宿主机 volume)
|
||||
├── app.db ├── app.db
|
||||
└── users/{uid}/kbs/{kb}/... └── users/{uid}/kbs/{kb}/...
|
||||
├── original/ ├── original/
|
||||
└── markdown/ └── markdown/
|
||||
```
|
||||
|
||||
### 1.2 分层
|
||||
|
||||
```
|
||||
api/ → 路由层:参数校验 + 鉴权 + 调 Service + 组装响应
|
||||
services/ → 业务逻辑:配额、Token、编排
|
||||
repositories/→ 数据访问:隔离 SQLAlchemy 细节
|
||||
processors/ → 文档解析:MarkItDown → PyMuPDF/python-docx fallback
|
||||
storage/ → 存储抽象:LocalStorageService(未来 MinIO/S3)
|
||||
retrieval/ → 检索抽象:KeywordRetriever(未来 Vector/Hybrid)
|
||||
public/ → AI 公共页面渲染:Jinja2 SSR + md/txt/json 生成器
|
||||
```
|
||||
|
||||
### 1.3 关键技术决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 数据库 | **SQLite** + SQLAlchemy + Alembic | MVP;DATABASE_URL 可切换 PostgreSQL(§7 扩展接口) |
|
||||
| 文件存储 | **本地文件系统** + StorageService 抽象 | MVP;不引入 MinIO(§8 扩展接口) |
|
||||
| 文档解析 | **同步** + DocumentProcessor 抽象 | MVP 单用户低并发;未来 Celery(§9 扩展接口) |
|
||||
| 检索 | **SQLite FTS5** + Retriever 抽象 | 中文 FTS5 支持 better than LIKE;未来 Qdrant(§10 扩展接口) |
|
||||
| 鉴权 | **服务端 Session**(签发 opaque token 存 SQLite,Cookie 传递) | 同源 SPA;比 JWT 简单且可即时吊销;SQLite 即 session store |
|
||||
| 密码哈希 | **Argon2id**(argon2-cffi) | 需求指定 |
|
||||
| 限流 | **内存 TokenBucket**(单进程够用) + RateLimiter 抽象 | 无 Redis 依赖;未来 RedisRateLimiter(§39) |
|
||||
| 公共页渲染 | **Jinja2 SSR**,零 JS 零外链 | AI 兼容性第一原则 |
|
||||
| Token 存储 | **SHA-256 hash**(DB)+ **Fernet 加密原文**(可解密,SECRET_KEY 派生) | 满足"后台显示完整链接"需求;DB 泄漏 ≠ 链接泄漏(需 + SECRET_KEY) |
|
||||
| 后缀路由 | 显式注册 `/k/{token}.md` / `.txt` / `.json` **先于** `/k/{token}` | FastAPI path param 默认匹配 `[^/]+`,`{token}` 会吞掉 `.md`(§19) |
|
||||
| Python 版本 | 3.12+ 编写;本机 3.13.9 运行 | FastAPI / SQLAlchemy 2 / Pydantic v2 均已支持 |
|
||||
| PyMuPDF 许可 | **MVP 阶段可接受**(AGPL-3.0);商品化前必须替换为 pypdfium2 | 需求 §24 明确指定 PyMuPDF;AGPL 对**未分发**的 SaaS 后端(仅服务端运行)在多数法域下合规,但商品化闭源分发时需替换。MVP 阶段标记 TODO 并在 requirements 中注释 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库 ER 设计
|
||||
|
||||
### 2.1 实体关系
|
||||
|
||||
```
|
||||
plans 1 ──── N users 1 ──── N knowledge_bases 1 ──── N documents 1 ──── 1 document_categories
|
||||
│ │
|
||||
│ └ 1 ──── N access_logs
|
||||
└ 1 ──── N (配额归集)
|
||||
```
|
||||
|
||||
### 2.2 表定义
|
||||
|
||||
**plans**
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | integer PK | |
|
||||
| code | text unique | `free` / `basic` / `pro` |
|
||||
| name | text | 套餐名 |
|
||||
| storage_quota | integer | 字节;free = 104857600 (100MB) |
|
||||
| max_file_size | integer | 字节;free = 20971520 (20MB) |
|
||||
| is_active | boolean | |
|
||||
|
||||
**users**
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | text PK (UUID4 hex) | |
|
||||
| username | text unique not null | |
|
||||
| email | text unique not null | |
|
||||
| password_hash | text not null | Argon2id |
|
||||
| status | text | `active` / `disabled` |
|
||||
| plan_id | integer FK → plans | 默认 free |
|
||||
| storage_used | integer default 0 | 字节计数器 |
|
||||
| created_at / updated_at | text (ISO8601) | |
|
||||
|
||||
**knowledge_bases**
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | text PK (UUID4 hex) | |
|
||||
| user_id | text FK → users not null | 隔离边界 |
|
||||
| name | text not null | |
|
||||
| description | text | |
|
||||
| enabled | boolean default true | 链接开关 |
|
||||
| token_hash | text unique not null | SHA-256(secret_token) hex |
|
||||
| token_encrypted | text | Fernet 加密原文(可解密供后台显示) |
|
||||
| token_hint | text | token 末 8 位明文,供后台识别 |
|
||||
| created_at / updated_at | text | |
|
||||
|
||||
> SQLite 不支持 citext / timestamptz / uuid 原生类型,统一用 text + 应用层校验。
|
||||
> 迁移到 PostgreSQL 时,Alembic 迁移负责类型映射,业务层无感。
|
||||
|
||||
**document_categories**
|
||||
| 列 | 类型 |
|
||||
|---|---|
|
||||
| id text PK, knowledge_base_id text FK, name text not null, sort_order integer, created_at text |
|
||||
|
||||
**documents**
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | text PK (UUID4 hex) | |
|
||||
| knowledge_base_id | text FK | |
|
||||
| user_id | text FK | 冗余存,便于隔离校验 + 配额统计 |
|
||||
| category_id | text FK nullable | |
|
||||
| original_filename | text | 仅展示用,不用于物理路径 |
|
||||
| storage_path | text | 相对于 data/ 的物理路径,不外泄 |
|
||||
| markdown_path | text | 相对于 data/ 的 Markdown 文件路径 |
|
||||
| file_size | integer | 字节 |
|
||||
| mime_type | text | |
|
||||
| file_ext | text | `.docx` / `.pdf` |
|
||||
| sha256 | text | 去重与完整性 |
|
||||
| doc_token_hash | text unique | 单文档公共 URL 凭证 |
|
||||
| doc_token_encrypted | text | Fernet 加密 |
|
||||
| doc_token_hint | text | |
|
||||
| title | text | 用户可改,默认从 Markdown H1 提取 |
|
||||
| description | text | 用户可改,默认自动生成摘要 |
|
||||
| keywords | text | 逗号分隔,jieba 提取 + 用户可改 |
|
||||
| content_summary | text | 抽取式摘要 ~200 字 |
|
||||
| status | text | PENDING / PROCESSING / READY / FAILED / DELETED |
|
||||
| error_code | text | 安全错误码(如 `SCANNED_PDF_NO_TEXT_LAYER`) |
|
||||
| created_at / updated_at | text | |
|
||||
|
||||
**access_logs**
|
||||
| 列 | 类型 |
|
||||
|---|---|
|
||||
| id integer PK AUTOINCREMENT, knowledge_base_id text FK, document_id text nullable FK, path text, accessed_at text, user_agent text, request_type text |
|
||||
|
||||
> SQLite FTS5 虚拟表(documents_content)单独建,与 documents 通过 id 关联。
|
||||
|
||||
### 2.3 索引
|
||||
|
||||
- `knowledge_bases(token_hash)` unique
|
||||
- `documents(doc_token_hash)` unique
|
||||
- `documents(knowledge_base_id, status)`
|
||||
- `documents(user_id, status)`
|
||||
- `users(username)` / `users(email)` unique
|
||||
|
||||
---
|
||||
|
||||
## 3. API 设计
|
||||
|
||||
### 3.1 管理端(/api,需登录)
|
||||
|
||||
```
|
||||
POST /api/auth/register {username, email, password} → 201
|
||||
POST /api/auth/login {username|email, password} → Set-Cookie + 200
|
||||
POST /api/auth/logout 清除 Cookie
|
||||
GET /api/me 用户信息 + 套餐
|
||||
PATCH /api/me 改密码
|
||||
|
||||
GET /api/knowledge-bases 分页列表
|
||||
POST /api/knowledge-bases {name, description} → 201(返回完整 AI URL,仅此一次)
|
||||
GET /api/knowledge-bases/{id} 详情
|
||||
PUT /api/knowledge-bases/{id} 改名/改描述
|
||||
DELETE /api/knowledge-bases/{id} 软删 + 异步清理文件
|
||||
|
||||
POST /api/knowledge-bases/{id}/regenerate-token 旧链立即失效
|
||||
POST /api/knowledge-bases/{id}/enable
|
||||
POST /api/knowledge-bases/{id}/disable
|
||||
|
||||
GET /api/knowledge-bases/{id}/documents 文档列表
|
||||
POST /api/knowledge-bases/{id}/documents/upload multipart 上传
|
||||
GET /api/documents/{id} 详情 + markdown 预览
|
||||
PUT /api/documents/{id} 改 title/description/keywords/category
|
||||
DELETE /api/documents/{id} 软删 + 清理文件
|
||||
POST /api/documents/{id}/reprocess 重新解析
|
||||
|
||||
GET /api/knowledge-bases/{id}/categories 分类 CRUD
|
||||
GET /api/storage {storage_used, storage_quota}
|
||||
```
|
||||
|
||||
统一错误体:`{"code": "STORAGE_QUOTA_EXCEEDED", "message": "存储空间不足", "detail": null}`
|
||||
|
||||
### 3.2 公共 AI 端(/k,无登录,限流)
|
||||
|
||||
```
|
||||
GET /k/{token} HTML 入口页(Jinja2 SSR)
|
||||
GET /k/{token}.md Markdown 入口
|
||||
GET /k/{token}.txt 纯文本入口
|
||||
GET /k/{token}.json JSON 目录(白名单字段)
|
||||
|
||||
GET /k/{token}/search?q=&page= HTML(默认)/ .json
|
||||
|
||||
GET /k/{token}/doc/{doc_token} 文档 HTML 页
|
||||
GET /k/{token}/doc/{doc_token}.md 原始 Markdown
|
||||
GET /k/{token}/doc/{doc_token}.txt 纯文本
|
||||
```
|
||||
|
||||
公共端硬性规则:
|
||||
1. token 不存在 / enabled=false / 软删 → **一律 404**
|
||||
2. `<meta name="robots" content="noindex,nofollow,noarchive">`
|
||||
3. `<meta name="referrer" content="no-referrer">`
|
||||
4. JSON 输出白名单:`name, description, documents[{title, file_type, description, summary, keywords, updated_at, url}]`
|
||||
5. 限流:内存 TokenBucket,单 IP 30/min,单 token 60/min
|
||||
|
||||
---
|
||||
|
||||
## 4. 项目目录
|
||||
|
||||
```
|
||||
amb_rag/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # app 工厂、路由挂载、异常处理、lifespan
|
||||
│ │ ├── api/ # 管理端路由: auth.py, knowledge_bases.py, documents.py, me.py, deps.py
|
||||
│ │ ├── public/ # 公共AI路由: routes.py, render.py, serializers.py
|
||||
│ │ ├── core/ # config.py, security.py, errors.py, session.py, rate_limit.py, logging.py
|
||||
│ │ ├── models/ # SQLAlchemy 2.0 (Mapped/mapped_column)
|
||||
│ │ ├── schemas/ # Pydantic v2 request/response
|
||||
│ │ ├── services/ # auth_service.py, kb_service.py, doc_service.py, kb_public_service.py, storage_service.py
|
||||
│ │ ├── repositories/ # user_repo.py, kb_repo.py, doc_repo.py, access_log_repo.py
|
||||
│ │ ├── processors/ # base.py(DocumentProcessor), local_processor.py, parsers/markitdown_parser.py, pdf_parser.py, docx_parser.py
|
||||
│ │ ├── retrieval/ # base.py(Retriever), keyword_retriever.py
|
||||
│ │ ├── storage/ # base.py(StorageService), local_storage.py
|
||||
│ │ └── templates/ # kb_index.html.j2, doc_page.html.j2
|
||||
│ ├── alembic/ # env.py + versions/
|
||||
│ ├── tests/ # conftest.py + 各模块测试
|
||||
│ ├── requirements.txt
|
||||
│ ├── pyproject.toml # ruff + mypy + pytest 配置
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # axios 实例 + 各资源 client
|
||||
│ │ ├── views/ # Login, Register, Dashboard, KnowledgeBases, KbDetail, Settings
|
||||
│ │ ├── components/
|
||||
│ │ ├── layouts/
|
||||
│ │ ├── stores/ # Pinia stores
|
||||
│ │ ├── router/
|
||||
│ │ └── types/
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tsconfig.json
|
||||
│ └── Dockerfile
|
||||
├── nginx/
|
||||
│ └── nginx.conf
|
||||
├── data/ # .gitkeep;运行时生成
|
||||
│ └── .gitkeep
|
||||
├── docs/
|
||||
│ └── technical-review.md # 本文件
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── CLAUDE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 六个未来扩展接口设计
|
||||
|
||||
### 5.1 SQLite → PostgreSQL
|
||||
|
||||
| 层 | 影响 |
|
||||
|---|---|
|
||||
| `core/config.py` | `DATABASE_URL` 从 `sqlite:///./data/app.db` 切为 `postgresql+asyncpg://...`;引擎换 async |
|
||||
| `core/db.py` | 同步引擎 → 异步引擎 + async_sessionmaker;get_session 改 async generator |
|
||||
| `repositories/` | 现有同步调用改 `await`;SQLAlchemy 2.x 统一风格使改动最小 |
|
||||
| Alembic | 新增迁移处理类型映射(text→varchar/citext/uuid, integer→bigint) |
|
||||
| 业务层 | **零改动**(Repository 已隔离) |
|
||||
|
||||
**第一版必须遵守的规则**:
|
||||
- 不用 PRAGMA 做业务逻辑(只用 WAL 模式设置)
|
||||
- 不用 SQLite 专属函数(`group_concat` → SQLAlchemy `func` 通用)
|
||||
- UUID 存 text 而非 blob(跨 DB 兼容)
|
||||
- 时间存 ISO8601 text 而非 SQLite datetime 函数
|
||||
- 布尔存 integer 0/1(SQLAlchemy Boolean 在 SQLite 自动映射)
|
||||
|
||||
### 5.2 本地文件 → MinIO/S3
|
||||
|
||||
```python
|
||||
# storage/base.py
|
||||
class StorageService(Protocol):
|
||||
async def save(self, key: str, data: bytes | IO) -> str: ...
|
||||
async def read(self, key: str) -> bytes: ...
|
||||
async def delete(self, key: str) -> None: ...
|
||||
async def exists(self, key: str) -> bool: ...
|
||||
async def get_size(self, key: str) -> int: ...
|
||||
```
|
||||
|
||||
- `LocalStorageService`:key 即相对路径,拼 `data/` 前缀;save → 写文件,read → 读文件。
|
||||
- `MinioStorageService`:key 即对象 key;save → minio.put_object,read → minio.get_object。
|
||||
- 业务层通过 `core/config.py` 中的 `STORAGE_BACKEND` 选择实现,注入 Service。
|
||||
- **第一版**:所有 `open()` / `Path()` 操作只在 `local_storage.py` 中出现。
|
||||
|
||||
### 5.3 同步解析 → 异步任务
|
||||
|
||||
```python
|
||||
# processors/base.py
|
||||
class DocumentProcessor(Protocol):
|
||||
async def process(self, document_id: str) -> None: ...
|
||||
```
|
||||
|
||||
- `LocalDocumentProcessor`:同步调用 parsers,在同一进程中完成。
|
||||
- `CeleryDocumentProcessor`:提交 Celery task,轮询状态。
|
||||
- **第一版**:上传 endpoint 中调 `await processor.process(doc_id)`(同步包在 `run_in_executor` 里避免阻塞事件循环)。
|
||||
- 文档状态机(PENDING→PROCESSING→READY/FAILED)在两个实现中完全相同。
|
||||
|
||||
### 5.4 关键词搜索 → RAG
|
||||
|
||||
```python
|
||||
# retrieval/base.py
|
||||
class Retriever(Protocol):
|
||||
async def search(self, query: str, knowledge_base_id: str, limit: int = 10) -> list[SearchResult]: ...
|
||||
```
|
||||
|
||||
- `KeywordRetriever`:SQLite FTS5 `MATCH` 查询 + `snippet()` 高亮。
|
||||
- `VectorRetriever`:Embedding + Qdrant search。
|
||||
- `HybridRetriever`:关键词 + 向量加权融合。
|
||||
- **第一版**:FTS5 在 Alembic 迁移中建虚拟表 `documents_fts`;写入时同步更新 FTS。
|
||||
|
||||
### 5.5 个人 → 团队/企业
|
||||
|
||||
- **第一版**:`knowledge_bases.user_id` 是 owner;所有查询 `WHERE user_id = :me`。
|
||||
- **扩展**:新增 `organizations` 表 + `org_members`(role);`knowledge_bases.owner_type` + `owner_id`(多态外键);Repository 查询加 `owner_scope` 参数。
|
||||
- **关键**:第一版不把 `user_id` 硬编码成无法泛化的东西(如拼接进 URL 路径)。
|
||||
|
||||
### 5.6 网页访问 → API/MCP
|
||||
|
||||
- `public/` 路由调用 `KnowledgeBasePublicService` 获取数据 → 由 Renderer(HTML/MD/TXT/JSON)格式化输出。
|
||||
- **扩展**:新增 `/api/v1/public/...` 路由,鉴权用 API Key,**复用同一个 Service**。
|
||||
- MCP Server 是独立进程,调 Repository 层。
|
||||
- **关键**:公共数据获取逻辑只在 Service 中写一次,Renderer 各格式只做序列化。
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全模型
|
||||
|
||||
### 6.1 Secret Token
|
||||
|
||||
```
|
||||
生成:token = secrets.token_urlsafe(16) # 22 字符, 128bit 熵
|
||||
存储:knowledge_bases.token_hash = SHA-256(token).hexdigest()
|
||||
knowledge_bases.token_encrypted = Fernet(SECRET_KEY 派生).encrypt(token)
|
||||
knowledge_bases.token_hint = token[-8:]
|
||||
校验:请求 token → 正则校验 → SHA-256 → 单条索引查询 → enabled/软删检查 → 404 或放行
|
||||
轮换:regenerate → 新 token,旧 hash 失效,旧缓存失效
|
||||
```
|
||||
|
||||
安全属性:
|
||||
- 128bit 熵 + 限流 → 暴力枚举不可行
|
||||
- DB 泄漏 ≠ 链接泄漏(需 + SECRET_KEY)
|
||||
- 统一 404 防存在性探测
|
||||
- `<meta name="referrer" content="no-referrer">` 防文档页跳出时 Referer 泄漏 token
|
||||
|
||||
### 6.2 IDOR 防护
|
||||
|
||||
所有管理端 API 在 Service 层强制校验 `resource.user_id == current_user.id`;不匹配 → 404(不暴露存在性)。
|
||||
|
||||
### 6.3 文件上传安全
|
||||
|
||||
- 扩展名白名单(`.docx`, `.pdf`)
|
||||
- MIME 嗅探(filetype 库,前 8KB)
|
||||
- 随机物理文件名(`secrets.token_urlsafe(8)_safe_original_name`)
|
||||
- 路径穿越清洗(取 basename,剔除 `..` / `\` / 控制字符)
|
||||
- 文件大小前置校验(Nginx `client_max_body_size` + 应用层双重保险)
|
||||
|
||||
### 6.4 XSS 防护
|
||||
|
||||
- AI 公共页:Markdown → HTML 走 Jinja2 `{{ content | safe }}`,但 Markdown 本身是服务端生成(非用户直接输入 HTML),风险可控;用户手改 Markdown 时需 sanitize(bleach 或 markdown 配置 `sanitize=True`)。
|
||||
- 管理端:Vue SPA,API 返回 JSON,Element Plus 组件默认 escape。
|
||||
|
||||
### 6.5 Session 安全
|
||||
|
||||
- Cookie: `HttpOnly`, `SameSite=Lax`, 生产环境 `Secure`
|
||||
- Session token: `secrets.token_urlsafe(32)`, 存 SQLite `sessions` 表(或内存 dict — MVP 足够)
|
||||
- 过期: 7 天不活动自动清除
|
||||
|
||||
---
|
||||
|
||||
## 7. 文档处理流程
|
||||
|
||||
```
|
||||
[HTTP 上传](同步返回)
|
||||
鉴权 → KB 归属 → MIME 嗅探 → 扩展名白名单 → 大小校验
|
||||
→ 配额原子扣减(UPDATE users SET storage_used = storage_used + :size WHERE id=:uid AND storage_used + :size <= storage_quota)
|
||||
失败 → STORAGE_QUOTA_EXCEEDED / FILE_TOO_LARGE
|
||||
→ 流式 SHA256(边读边算,内存峰值 = 一个 chunk)
|
||||
→ StorageService.save() 保存原始文件(随机文件名)
|
||||
→ Document(PENDING) 入库
|
||||
→ DocumentProcessor.process(doc_id) 同步解析:
|
||||
┌ .pdf → markitdown → 失败/空 → PyMuPDF fallback → 仍空 → SCANNED_PDF_NO_TEXT_LAYER
|
||||
├ .docx → markitdown → 失败 → python-docx fallback
|
||||
└ 其他 → Phase 2+ 扩展
|
||||
→ Markdown 清洗(压空行、规整标题层级、截超长行)
|
||||
→ 提取 H1 → title(未填时)
|
||||
→ 抽取前 ~200 字纯文本 → content_summary
|
||||
→ jieba.analyse.extract_tags top-10 → keywords
|
||||
→ StorageService.save() 保存 Markdown 文件
|
||||
→ 更新 FTS 索引
|
||||
→ Document(READY)
|
||||
任何异常 → Document(FAILED, error_code),用户只看友好文案
|
||||
→ 返回 201 + Document 信息
|
||||
```
|
||||
|
||||
> **同步解析的注意事项**:大 PDF 解析可能耗时数秒,会阻塞当前请求。MVP 阶段可接受(单用户低并发),但必须:
|
||||
> 1. 设置请求级超时(uvicorn `--timeout-keep-alive` 不够,需应用层 middleware 或 nginx `proxy_read_timeout`)
|
||||
> 2. 前端上传组件显示 loading + 预估时间
|
||||
> 3. 未来切 Celery 时,上传立即返回 202,前端轮询状态
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 访问流程
|
||||
|
||||
```
|
||||
用户 → 在 AI 对话中粘贴 https://example.com/k/7fA92xKpQ8m... + 描述需求
|
||||
AI 抓取器 → GET /k/7fA92xKpQ8m...(无 Cookie/JS)
|
||||
服务端:限流 → SHA256(token) → 查 KB → 未命中 404
|
||||
→ 渲染入口页:
|
||||
· KB 名称/描述
|
||||
· 文档列表(每条:标题、类型、描述、一句话摘要、关键词、更新时间、完整文档 URL)
|
||||
· 分页(≤50 条/页)
|
||||
· 页脚双语机器可读说明
|
||||
AI → 根据用户描述匹配文档 → GET /k/{token}/doc/{doc_token}.md(优先 .md)
|
||||
或 .html;需精确定位 → /k/{token}/search?q=
|
||||
AI → 汇总回答用户
|
||||
```
|
||||
|
||||
**兜底**:若 AI 不跟随链接,用户可直接粘贴文档级 URL。README 必须说明。
|
||||
|
||||
---
|
||||
|
||||
## 9. Windows 开发流程
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
# Git Bash:
|
||||
source .venv/Scripts/activate
|
||||
# 或 CMD:
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
# 初始化数据库
|
||||
alembic upgrade head
|
||||
# 启动
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
# 访问
|
||||
# http://localhost:8000/api/health → 健康检查
|
||||
# http://localhost:8000/api/docs → Swagger(开发期)
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# http://localhost:5173
|
||||
# Vite proxy: /api → localhost:8000, /k → localhost:8000
|
||||
```
|
||||
|
||||
**关键**:`data/` 目录在首次运行时由 backend 自动创建(`os.makedirs`);`.env` 仅生产需要(开发期 config 用默认值)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Docker 生产流程
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml(Ubuntu 服务器)
|
||||
services:
|
||||
frontend: # multi-stage: node build → nginx:alpine serve dist
|
||||
backend: # python:3.12-slim → uvicorn
|
||||
nginx: # 反代 /api + /k → backend:8000;/ → frontend:80
|
||||
|
||||
volumes:
|
||||
- ./data:/app/data # SQLite + 用户文件,宿主机持久化
|
||||
```
|
||||
|
||||
```bash
|
||||
# 部署
|
||||
git clone <repo> /opt/ai-knowledge-link
|
||||
cd /opt/ai-knowledge-link
|
||||
cp .env.example .env # 修改全部密钥
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
|
||||
# 更新
|
||||
git pull
|
||||
docker compose up -d --build # 数据在 ./data 不受影响
|
||||
|
||||
# 备份
|
||||
sqlite3 data/app.db ".backup data/backup/app_$(date +%Y%m%d).db"
|
||||
tar czf data/backup/files_$(date +%Y%m%d).tar.gz data/users/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险清单
|
||||
|
||||
| # | 风险 | 等级 | 缓解 |
|
||||
|---|---|---|---|
|
||||
| R1 | **同步解析阻塞请求**:大 PDF 解析耗时数秒到十几秒 | 中 | MVP 可接受;前端显示 loading;设请求超时 60s;未来切 Celery |
|
||||
| R2 | **SQLite 并发写锁**:多用户同时上传时 WAL 模式允许并发读但写仍串行 | 中 | `PRAGMA journal_mode=WAL`;写操作短事务;未来切 PostgreSQL |
|
||||
| R3 | **PyMuPDF AGPL-3.0**:商品化闭源分发时许可风险 | 中 | MVP 阶段可接受(仅服务端运行,不分发);requirements 中注释标记;商品化前换 pypdfium2 |
|
||||
| R4 | **SQLite FTS5 中文分词**:FTS5 的 `unicode61` tokenizer 对中文按字分词(非词),召回率低于 jieba | 中 | 第一版仍用 FTS5(比 LIKE 好很多);关键词字段用 jieba 分词写入辅助;Phase 9 验收时若不满意可切换到 jieba 预分词 + LIKE 方案 |
|
||||
| R5 | **FastAPI 后缀路由被 path param 吞掉** | 低 | 显式后缀路由注册在无后缀路由之前;token 用正则 `^[A-Za-z0-9_-]{22}$` |
|
||||
| R6 | **内存限流单进程局限** | 低 | MVP 单 uvicorn worker 够用;未来多 worker/容器需 Redis 限流 |
|
||||
| R7 | **Markdown XSS**:用户手改 Markdown 注入 `<script>` | 中 | Jinja2 渲染 AI 页时 sanitize(bleach 或 Markdown 库 safe 模式);管理端 Vue 默认 escape |
|
||||
| R8 | **配额并发超额**:SQLite 写锁串行化已天然防竞态,但应用层"先查后写"仍有窗口 | 低 | 原子 SQL `UPDATE ... WHERE storage_used + :n <= quota` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 建议修改 / 补充
|
||||
|
||||
1. **【建议】Token 加密存储**(Fernet)以满足"后台显示完整链接"。需求 §16 说"只在生成时提供",但 §33 需要"复制 AI 链接"按钮 → 后台需能取回原文。两处表述矛盾,Fernet 方案同时满足。
|
||||
2. **【建议】入口页每篇文档附一句话摘要**:显著提高 AI 不点进去也能判断相关性的概率;代价是页面变大,用 50 条/页分页对冲。
|
||||
3. **【建议】公共页加 `<meta name="referrer" content="no-referrer">`**:需求未提,防文档页跳出时 Referer 泄漏 token。
|
||||
4. **【建议】文档 Token 也用 Fernet 加密存储**:与 KB Token 同方案,便于构建文档 URL。
|
||||
5. **【建议】Session 用内存 dict + TTL 而非 SQLite 表**:MVP 更简单,重启丢失可接受;未来切 Redis 时无缝。
|
||||
6. **【明确化】需求 §24 指定 PyMuPDF**:MVP 阶段接受,但必须在代码中标记 `# TODO: 商品化前替换为 pypdfium2(AGPL 风险)`。
|
||||
7. **【明确化】配额扣减必须用原子 SQL**,不能"先查 storage_used 再写"(即使 SQLite 串行写也尽量用原子语句,养成习惯以备 PostgreSQL 切换)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 开发阶段确认
|
||||
|
||||
按需求 §56,共 17 个 Phase。技术审查通过后从 Phase 1(项目初始化)开始,每 Phase 完成必须验证后再进入下一 Phase。
|
||||
@@ -0,0 +1,18 @@
|
||||
# AI Knowledge Link — Frontend Dockerfile (multi-stage)
|
||||
# Stage 1: build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve with nginx
|
||||
FROM nginx:1.27-alpine AS serve
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 自定义 nginx 配置由外层 docker-compose 挂载,这里只 serve 静态产物
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Knowledge Link</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "ai-knowledge-link-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.ts,.tsx --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5",
|
||||
"vue-router": "^4.4",
|
||||
"pinia": "^2.2",
|
||||
"element-plus": "^2.9",
|
||||
"axios": "^1.7",
|
||||
"@element-plus/icons-vue": "^2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2",
|
||||
"vite": "^6.1",
|
||||
"typescript": "^5.7",
|
||||
"vue-tsc": "^2.2",
|
||||
"@types/node": "^22.0",
|
||||
"eslint": "^9.0",
|
||||
"eslint-plugin-vue": "^9.28",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0",
|
||||
"@typescript-eslint/parser": "^8.0",
|
||||
"prettier": "^3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RouterView } from 'vue-router';
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
const __VLS_0 = {}.RouterView;
|
||||
/** @type {[typeof __VLS_components.RouterView, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({}));
|
||||
const __VLS_2 = __VLS_1({}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
var __VLS_4 = {};
|
||||
var __VLS_3;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
RouterView: RouterView,
|
||||
};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
// 响应拦截:统一错误处理
|
||||
apiClient.interceptors.response.use((response) => response, (error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
// 未登录 → 跳转登录页
|
||||
if (status === 401) {
|
||||
// 避免在登录页循环跳转
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// 业务错误:显示服务端中文文案
|
||||
const message = data?.message || '请求失败';
|
||||
ElMessage.error(message);
|
||||
}
|
||||
else if (error.request) {
|
||||
ElMessage.error('网络错误,请检查连接。');
|
||||
}
|
||||
else {
|
||||
ElMessage.error('请求配置错误。');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
// 响应拦截:统一错误处理
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
// 未登录 → 跳转登录页
|
||||
if (status === 401) {
|
||||
// 避免在登录页循环跳转
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 业务错误:显示服务端中文文案
|
||||
const message = data?.message || '请求失败'
|
||||
ElMessage.error(message)
|
||||
} else if (error.request) {
|
||||
ElMessage.error('网络错误,请检查连接。')
|
||||
} else {
|
||||
ElMessage.error('请求配置错误。')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import 'element-plus/dist/index.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/knowledge-bases',
|
||||
name: 'KnowledgeBases',
|
||||
component: () => import('@/views/KnowledgeBases.vue'),
|
||||
},
|
||||
{
|
||||
path: '/knowledge-bases/:id',
|
||||
name: 'KbDetail',
|
||||
component: () => import('@/views/KbDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
export default router;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/knowledge-bases',
|
||||
name: 'KnowledgeBases',
|
||||
component: () => import('@/views/KnowledgeBases.vue'),
|
||||
},
|
||||
{
|
||||
path: '/knowledge-bases/:id',
|
||||
name: 'KbDetail',
|
||||
component: () => import('@/views/KbDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const isLoggedIn = ref(false);
|
||||
const username = ref('');
|
||||
const userId = ref('');
|
||||
function setUser(data) {
|
||||
isLoggedIn.value = true;
|
||||
userId.value = data.id;
|
||||
username.value = data.username;
|
||||
}
|
||||
function clearUser() {
|
||||
isLoggedIn.value = false;
|
||||
userId.value = '';
|
||||
username.value = '';
|
||||
}
|
||||
return { isLoggedIn, username, userId, setUser, clearUser };
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const isLoggedIn = ref(false)
|
||||
const username = ref('')
|
||||
const userId = ref('')
|
||||
|
||||
function setUser(data: { id: string; username: string }) {
|
||||
isLoggedIn.value = true
|
||||
userId.value = data.id
|
||||
username.value = data.username
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
isLoggedIn.value = false
|
||||
userId.value = ''
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
return { isLoggedIn, username, userId, setUser, clearUser }
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 14 完善仪表盘
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h1>仪表盘</h1>
|
||||
<p>Dashboard(Phase 14 完善)</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "dashboard-page" },
|
||||
});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
/** @type {__VLS_StyleScopedClasses['dashboard-page']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 4 实现知识库详情
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>知识库详情</h1>
|
||||
<p>知识库详情(Phase 4 实现)</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 4 实现知识库列表
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>知识库</h1>
|
||||
<p>知识库列表(Phase 4 实现)</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 3 实现登录逻辑
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card">
|
||||
<template #header>
|
||||
<h2>AI Knowledge Link</h2>
|
||||
</template>
|
||||
<p>登录页面(Phase 3 实现)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
// CSS variable injection
|
||||
// CSS variable injection end
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "login-page" },
|
||||
});
|
||||
const __VLS_0 = {}.ElCard;
|
||||
/** @type {[typeof __VLS_components.ElCard, typeof __VLS_components.elCard, typeof __VLS_components.ElCard, typeof __VLS_components.elCard, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
|
||||
...{ class: "login-card" },
|
||||
}));
|
||||
const __VLS_2 = __VLS_1({
|
||||
...{ class: "login-card" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
__VLS_3.slots.default;
|
||||
{
|
||||
const { header: __VLS_thisSlot } = __VLS_3.slots;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({});
|
||||
}
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
var __VLS_3;
|
||||
/** @type {__VLS_StyleScopedClasses['login-page']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['login-card']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 3 实现注册逻辑
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<el-card class="register-card">
|
||||
<template #header>
|
||||
<h2>注册</h2>
|
||||
</template>
|
||||
<p>注册页面(Phase 3 实现)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.register-card {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
// CSS variable injection
|
||||
// CSS variable injection end
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||
...{ class: "register-page" },
|
||||
});
|
||||
const __VLS_0 = {}.ElCard;
|
||||
/** @type {[typeof __VLS_components.ElCard, typeof __VLS_components.elCard, typeof __VLS_components.ElCard, typeof __VLS_components.elCard, ]} */ ;
|
||||
// @ts-ignore
|
||||
const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({
|
||||
...{ class: "register-card" },
|
||||
}));
|
||||
const __VLS_2 = __VLS_1({
|
||||
...{ class: "register-card" },
|
||||
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
|
||||
__VLS_3.slots.default;
|
||||
{
|
||||
const { header: __VLS_thisSlot } = __VLS_3.slots;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({});
|
||||
}
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
var __VLS_3;
|
||||
/** @type {__VLS_StyleScopedClasses['register-page']} */ ;
|
||||
/** @type {__VLS_StyleScopedClasses['register-card']} */ ;
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// Phase 14 实现设置页
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>设置页面(Phase 14 实现)</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
|
||||
const __VLS_ctx = {};
|
||||
let __VLS_components;
|
||||
let __VLS_directives;
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({});
|
||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({});
|
||||
var __VLS_dollars;
|
||||
const __VLS_self = (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
export default (await import('vue')).defineComponent({
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
; /* PartiallyEnd: #4569/main.vue */
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/router/index.ts","./src/stores/user.ts","./src/app.vue","./src/views/dashboard.vue","./src/views/kbdetail.vue","./src/views/knowledgebases.vue","./src/views/login.vue","./src/views/register.vue","./src/views/settings.vue"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 管理端 API
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 公共 AI 页面
|
||||
'/k': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
# AI Knowledge Link — Nginx 生产配置
|
||||
# Docker Compose 中 frontend 容器 serve 静态产物,此配置用于独立 nginx 容器反代。
|
||||
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 25m;
|
||||
|
||||
# --- 管理端 API ---
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# --- 公共 AI 页面 ---
|
||||
# 禁止 Nginx 层缓存(公共页缓存只允许应用内,键含 token_hash)
|
||||
location /k/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header Cache-Control "private, no-cache, no-store" always;
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
|
||||
}
|
||||
|
||||
# --- 前端静态 ---
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user