Files
amb_rag/docs/technical-review.md
2026-09-01 11:53:59 +08:00

537 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | MVPDATABASE_URL 可切换 PostgreSQL(§7 扩展接口) |
| 文件存储 | **本地文件系统** + StorageService 抽象 | MVP;不引入 MinIO(§8 扩展接口) |
| 文档解析 | **同步** + DocumentProcessor 抽象 | MVP 单用户低并发;未来 Celery(§9 扩展接口) |
| 检索 | **SQLite FTS5** + Retriever 抽象 | 中文 FTS5 支持 better than LIKE;未来 Qdrant(§10 扩展接口) |
| 鉴权 | **服务端 Session**(签发 opaque token 存 SQLiteCookie 传递) | 同源 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 明确指定 PyMuPDFAGPL 对**未分发**的 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_sessionmakerget_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/1SQLAlchemy 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 即对象 keysave → minio.put_objectread → 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` 获取数据 → 由 RendererHTML/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 时需 sanitizebleach 或 markdown 配置 `sanitize=True`)。
- 管理端:Vue SPAAPI 返回 JSONElement 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.ymlUbuntu 服务器)
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 页时 sanitizebleach 或 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: 商品化前替换为 pypdfium2AGPL 风险)`
7. **【明确化】配额扣减必须用原子 SQL**,不能"先查 storage_used 再写"(即使 SQLite 串行写也尽量用原子语句,养成习惯以备 PostgreSQL 切换)。
---
## 13. 开发阶段确认
按需求 §56,共 17 个 Phase。技术审查通过后从 Phase 1(项目初始化)开始,每 Phase 完成必须验证后再进入下一 Phase。