24 KiB
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 | |
| 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)uniquedocuments(doc_token_hash)uniquedocuments(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 纯文本
公共端硬性规则:
- token 不存在 / enabled=false / 软删 → 一律 404
<meta name="robots" content="noindex,nofollow,noarchive"><meta name="referrer" content="no-referrer">- JSON 输出白名单:
name, description, documents[{title, file_type, description, summary, keywords, updated_at, url}] - 限流:内存 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→ SQLAlchemyfunc通用) - UUID 存 text 而非 blob(跨 DB 兼容)
- 时间存 ISO8601 text 而非 SQLite datetime 函数
- 布尔存 integer 0/1(SQLAlchemy Boolean 在 SQLite 自动映射)
5.2 本地文件 → MinIO/S3
# 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 同步解析 → 异步任务
# 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
# retrieval/base.py
class Retriever(Protocol):
async def search(self, query: str, knowledge_base_id: str, limit: int = 10) -> list[SearchResult]: ...
KeywordRetriever:SQLite FTS5MATCH查询 +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), 存 SQLitesessions表(或内存 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 阶段可接受(单用户低并发),但必须:
- 设置请求级超时(uvicorn
--timeout-keep-alive不够,需应用层 middleware 或 nginxproxy_read_timeout)- 前端上传组件显示 loading + 预估时间
- 未来切 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 开发流程
# 后端
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 生产流程
# 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 + 用户文件,宿主机持久化
# 部署
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. 建议修改 / 补充
- 【建议】Token 加密存储(Fernet)以满足"后台显示完整链接"。需求 §16 说"只在生成时提供",但 §33 需要"复制 AI 链接"按钮 → 后台需能取回原文。两处表述矛盾,Fernet 方案同时满足。
- 【建议】入口页每篇文档附一句话摘要:显著提高 AI 不点进去也能判断相关性的概率;代价是页面变大,用 50 条/页分页对冲。
- 【建议】公共页加
<meta name="referrer" content="no-referrer">:需求未提,防文档页跳出时 Referer 泄漏 token。 - 【建议】文档 Token 也用 Fernet 加密存储:与 KB Token 同方案,便于构建文档 URL。
- 【建议】Session 用内存 dict + TTL 而非 SQLite 表:MVP 更简单,重启丢失可接受;未来切 Redis 时无缝。
- 【明确化】需求 §24 指定 PyMuPDF:MVP 阶段接受,但必须在代码中标记
# TODO: 商品化前替换为 pypdfium2(AGPL 风险)。 - 【明确化】配额扣减必须用原子 SQL,不能"先查 storage_used 再写"(即使 SQLite 串行写也尽量用原子语句,养成习惯以备 PostgreSQL 切换)。
13. 开发阶段确认
按需求 §56,共 17 个 Phase。技术审查通过后从 Phase 1(项目初始化)开始,每 Phase 完成必须验证后再进入下一 Phase。