第一步

This commit is contained in:
amb
2026-09-01 11:53:59 +08:00
commit 47bf6cc5ca
66 changed files with 6501 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# ============================================================
# AI Knowledge Link — 环境配置模板
# 复制为 .env 后修改;.env 已被 .gitignore 排除,绝不提交。
# 生成密钥: python -c "import secrets; print(secrets.token_urlsafe(48))"
# ============================================================
# --- 基础 ---
# local / productionproduction 下 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
View File
@@ -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/
+47
View File
@@ -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 LinkAI知识链接)** — 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.xMapped 风格, 同步引擎 + SQLite)、Pydantic v2、Alembic、Jinja2(公共页 SSR
- **Frontend**: Vue 3 + TypeScript + Vite + Element Plus + Pinia + Vue Router + Axios
- **Storage**: SQLite(元数据)+ 服务器本地文件系统(原始文件 + Markdown)
- **Parsing**: MarkItDown 优先, PyMuPDFPDF fallback, AGPL—商品化前需替换为 pypdfium2, python-docxDOCX 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 BashPOSIX 语法)
- 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 + mypybackend)、ESLint + Prettierfrontend
- v1 明确不做:支付、OCR、团队协作、Qdrant、自建聊天机器人、绑定特定 AI、强制 AI 端登录、Redis、Celery、MinIO、PostgreSQL
+86
View File
@@ -0,0 +1,86 @@
# AI Knowledge LinkAI知识链接)
把用户的 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(优先)· PyMuPDFPDF fallback)· python-docxDOCX 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 个扩展接口
+22
View File
@@ -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"]
View File
View File
+83
View File
@@ -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]
View File
+66
View File
@@ -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
+76
View File
@@ -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
+155
View File
@@ -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)
+31
View File
@@ -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)
+62
View File
@@ -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
+74
View File
@@ -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": "/",
}
+68
View File
@@ -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()
View File
View File
View File
View File
View File
View File
View File
+28
View File
@@ -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:
"""获取文件大小(字节)。"""
...
+67
View File
@@ -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
+50
View File
@@ -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"
)
+24
View File
@@ -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"]
+39
View File
@@ -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 嗅探:纯 PythonWindows 无需 libmagic DLL
filetype>=1.2
# MarkItDownMIT
markitdown>=0.1.0
# PDF fallback: PyMuPDFAGPL-3.0
# TODO: 商品化前替换为 pypdfium2Apache-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
View File
+36
View File
@@ -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)
+45
View File
@@ -0,0 +1,45 @@
# AI Knowledge Link — Docker Compose (生产)
# Ubuntu 服务器部署用。Windows 开发不需要 Docker。
#
# 数据持久化:./data 挂载到 backendSQLite + 用户文件均在其中。
# 删除容器后数据不丢失。
#
# 用法:
# 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
+342
View File
@@ -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 + Dockerfrontend / 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 2Phase 1 已建 db.py 引擎层)
不能在业务代码中到处直接调用 sqlite3。必须使用 SQLAlchemy,并通过 Repository / Service 分层(UserRepository、KnowledgeBaseRepository、DocumentRepository)。业务逻辑不能依赖 SQLite 具体实现。数据库配置通过 `DATABASE_URL`(如 `sqlite:///./data/app.db`),未来可以切换 PostgreSQL 而尽量不修改业务层。
## 七、未来扩展接口 1SQLite → 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 7DocumentProcessor 协议)
第一版:上传文件 → FastAPI → 同步解析 → 保存 Markdown → 返回结果。必须抽象 `DocumentProcessor`(如 `process_document()`):第一版 `LocalDocumentProcessor`,未来可增加 `CeleryDocumentProcessor`。业务代码不要把文档解析逻辑写进 API endpoint。
## 十、未来扩展接口 4:关键词搜索 → RAG ⬜ Phase 12Retriever 协议)
第一版不使用向量数据库。实现 `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
实现注册、登录、退出登录。
Userid、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 Plan100MB / 20MB-per-file),未来 Basic 1GB、Pro 5GB、Enterprise 自定义。第一版不实现支付。
## 十五、知识库 ⬜ Phase 4
KnowledgeBaseid、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
优先 MarkItDownPDF 用 PyMuPDF 作为辅助/fallbackWord 用 python-docx 作为 fallback。Markdown 需尽可能保留:标题、段落、列表、表格、粗体、斜体、代码块、引用。解析失败时 Document.status = FAILED,不向用户显示 Traceback / 服务器路径 / Python 异常堆栈,只显示友好错误("文档解析失败,请检查文件是否损坏或格式是否受支持。"),服务器日志记录详细错误。
## 二十五、扫描 PDF ⬜ Phase 7
第一版不强制 OCR。如果 PDF 没有文本层,提示"该 PDF 可能是扫描件,当前版本暂不支持 OCR。"未来预留 OCRProcessorPaddleOCR 等),第一版不加入 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/11README 声明 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 14UI
显示:知识库名称、描述、文档数量、空间、创建时间、AI 链接。操作:管理、复制 AI 链接、预览、启用/禁用、重新生成链接、删除。
## 三十四、文档管理 ⬜ Phase 6/8(接口)/ Phase 14UI
支持拖拽上传、多文件上传。显示:文件名、类型、大小、状态、更新时间。操作:预览、重新解析、删除。
## 三十五、文档编辑 ⬜ 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。
Documentid、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。
KnowledgeBaseid、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.ymlfrontend、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 开始,不要跳过架构审查。
+536
View File
@@ -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 | 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。
+18
View File
@@ -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;"]
+12
View File
@@ -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>
+3815
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+26
View File
@@ -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 */
+35
View File
@@ -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;
+41
View File
@@ -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
+7
View File
@@ -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
}
+12
View File
@@ -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');
+14
View File
@@ -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')
+37
View File
@@ -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;
+39
View File
@@ -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
+18
View File
@@ -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 };
});
+22
View File
@@ -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 }
})
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// Phase 14 完善仪表盘
</script>
<template>
<div class="dashboard-page">
<h1>仪表盘</h1>
<p>DashboardPhase 14 完善</p>
</div>
</template>
+22
View File
@@ -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 */
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// Phase 4 实现知识库详情
</script>
<template>
<div>
<h1>知识库详情</h1>
<p>知识库详情Phase 4 实现</p>
</div>
</template>
+19
View File
@@ -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 */
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// Phase 4 实现知识库列表
</script>
<template>
<div>
<h1>知识库</h1>
<p>知识库列表Phase 4 实现</p>
</div>
</template>
+19
View File
@@ -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 */
+27
View File
@@ -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>
+39
View File
@@ -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 */
+27
View File
@@ -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>
+39
View File
@@ -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 */
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// Phase 14 实现设置页
</script>
<template>
<div>
<h1>设置</h1>
<p>设置页面Phase 14 实现</p>
</div>
</template>
+19
View File
@@ -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 */
+25
View File
@@ -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"]
}
+1
View File
@@ -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"}
+27
View File
@@ -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,
},
},
},
})
+43
View File
@@ -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;
}
}