This commit is contained in:
amb
2026-09-01 13:00:36 +08:00
parent 1d8621717a
commit dfd38c99a0
35 changed files with 3141 additions and 21 deletions
+129
View File
@@ -0,0 +1,129 @@
"""认证路由:注册、登录、登出、用户信息。"""
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.session import get_cookie_params
from app.models.user import User
from app.schemas.user import (
LoginRequest,
MeResponse,
RegisterRequest,
StorageInfoResponse,
UpdateMeRequest,
UserResponse,
)
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=201)
def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
"""注册新用户并自动登录。"""
auth_service = AuthService(db)
user, token = auth_service.register(body.username, body.email, body.password)
response.set_cookie(value=token, **get_cookie_params())
return _user_response(user)
@router.post("/login", response_model=UserResponse)
def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
"""登录。"""
auth_service = AuthService(db)
user, token = auth_service.login(body.username_or_email, body.password)
response.set_cookie(value=token, **get_cookie_params())
return _user_response(user)
@router.post("/logout", status_code=204)
def logout(
response: Response,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""登出:删除 session 并清除 Cookie。"""
from app.core.session import SESSION_COOKIE_NAME
token = "" # 不需要实际 tokenlogout 内部通过 user_id 找 session
auth_service = AuthService(db)
# 直接删除所有该用户的 session(MVP 简化:单设备)
from app.core.session import _store
to_delete = [t for t, e in _store.items() if e.user_id == user.id]
for t in to_delete:
auth_service.logout(t)
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return None
@router.get("/me", response_model=MeResponse)
def get_me(user: User = Depends(get_current_user)) -> MeResponse:
"""获取当前用户信息。"""
from app.core.config import get_settings
settings = get_settings()
return MeResponse(
id=user.id,
username=user.username,
email=user.email,
status=user.status,
storage_used=user.storage_used,
storage_quota=settings.default_storage_quota,
created_at=user.created_at,
)
@router.patch("/me", response_model=MeResponse)
def update_me(
body: UpdateMeRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> MeResponse:
"""修改密码。"""
auth_service = AuthService(db)
if body.password is not None:
auth_service.change_password(user, body.password)
from app.core.config import get_settings
settings = get_settings()
return MeResponse(
id=user.id,
username=user.username,
email=user.email,
status=user.status,
storage_used=user.storage_used,
storage_quota=settings.default_storage_quota,
created_at=user.created_at,
)
@router.get("/storage", response_model=StorageInfoResponse)
def get_storage(user: User = Depends(get_current_user)) -> StorageInfoResponse:
"""获取存储用量信息。"""
from app.core.config import get_settings
settings = get_settings()
return StorageInfoResponse(
storage_used=user.storage_used,
storage_quota=settings.default_storage_quota,
storage_used_mb=round(user.storage_used / (1024 * 1024), 2),
storage_quota_mb=round(settings.default_storage_quota / (1024 * 1024), 2),
)
def _user_response(user: User) -> UserResponse:
from app.core.config import get_settings
settings = get_settings()
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
status=user.status,
storage_used=user.storage_used,
storage_quota=settings.default_storage_quota,
created_at=user.created_at,
)
+31
View File
@@ -0,0 +1,31 @@
"""API 依赖注入。
- get_current_user: 从 Cookie 读 session → 查库 → 返回 User
- 所有需登录的路由用 Depends(get_current_user)
"""
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.core.db import get_session as get_db_session
from app.core.session import SESSION_COOKIE_NAME
from app.models.user import User
from app.services.auth_service import AuthService
def get_db() -> Session:
"""数据库会话依赖(同步 SQLAlchemy)。"""
yield from get_db_session()
def get_current_user(
request: Request,
db: Session = Depends(get_db),
) -> User:
"""从 Cookie 中读取 session token,返回当前登录用户。
未登录或 session 过期抛出 AuthRequiredError401)。
"""
token = request.cookies.get(SESSION_COOKIE_NAME)
auth_service = AuthService(db)
return auth_service.get_current_user(token)
+140
View File
@@ -0,0 +1,140 @@
"""文档 API 路由。"""
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.user import User
from app.schemas.document import (
DocumentListResponse,
DocumentResponse,
DocumentUpdateRequest,
DocumentUploadResponse,
)
from app.services.doc_service import DocumentService
router = APIRouter(prefix="/documents", tags=["documents"])
@router.post("/upload", response_model=DocumentUploadResponse, status_code=201)
async def upload_document(
kb_id: str = Form(...),
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentUploadResponse:
"""上传文档到知识库。"""
content = await file.read()
svc = DocumentService(db)
doc = svc.upload(
user=user,
kb_id=kb_id,
filename=file.filename or "unknown",
content=content,
)
return DocumentUploadResponse(
id=doc.id,
original_filename=doc.original_filename,
file_size=doc.file_size,
status=doc.status,
message="文档上传成功,等待解析。",
)
@router.get("", response_model=DocumentListResponse)
def list_documents(
kb_id: str = Query(...),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentListResponse:
svc = DocumentService(db)
items, total = svc.list_by_knowledge_base(kb_id, user, page=page, page_size=page_size)
return DocumentListResponse(
items=[_to_response(doc) for doc in items],
total=total,
)
@router.get("/{doc_id}", response_model=DocumentResponse)
def get_document(
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentResponse:
svc = DocumentService(db)
doc = svc.get_or_404(doc_id, user)
return _to_response(doc)
@router.put("/{doc_id}", response_model=DocumentResponse)
def update_document(
doc_id: str,
body: DocumentUpdateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentResponse:
svc = DocumentService(db)
doc = svc.get_or_404(doc_id, user)
update_fields = {}
if body.title is not None:
update_fields["title"] = body.title
if body.description is not None:
update_fields["description"] = body.description
if body.keywords is not None:
update_fields["keywords"] = body.keywords
if body.category_id is not None:
update_fields["category_id"] = body.category_id
if update_fields:
from app.repositories.doc_repo import DocumentRepository
DocumentRepository(db).update(doc, **update_fields)
db.commit()
return _to_response(doc)
@router.delete("/{doc_id}", status_code=204)
def delete_document(
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
svc = DocumentService(db)
svc.delete(doc_id, user)
@router.post("/{doc_id}/reprocess", response_model=DocumentResponse)
def reprocess_document(
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentResponse:
"""重新解析文档。"""
svc = DocumentService(db)
doc = svc.get_or_404(doc_id, user)
svc._process_document(doc)
db.refresh(doc)
return _to_response(doc)
def _to_response(doc) -> DocumentResponse:
return DocumentResponse(
id=doc.id,
knowledge_base_id=doc.knowledge_base_id,
original_filename=doc.original_filename,
file_size=doc.file_size,
mime_type=doc.mime_type,
file_ext=doc.file_ext,
sha256=doc.sha256,
title=doc.title,
description=doc.description,
keywords=doc.keywords,
content_summary=doc.content_summary,
status=doc.status,
error_code=doc.error_code,
doc_token_hint=doc.doc_token_hint,
category_id=doc.category_id,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
+140
View File
@@ -0,0 +1,140 @@
"""知识库 API 路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.user import User
from app.schemas.knowledge_base import (
KbCreateRequest,
KbListResponse,
KbResponse,
KbTokenResponse,
KbUpdateRequest,
)
from app.services.kb_service import KnowledgeBaseService
router = APIRouter(prefix="/knowledge-bases", tags=["knowledge-bases"])
@router.get("", response_model=KbListResponse)
def list_knowledge_bases(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbListResponse:
svc = KnowledgeBaseService(db)
items, total = svc.list_by_user(user, page=page, page_size=page_size)
doc_count_map = db and svc._kb_repo.get_document_count_map(user.id)
return KbListResponse(
items=[_to_response(kb, doc_count_map.get(kb.id, 0)) for kb in items],
total=total,
)
@router.post("", response_model=KbResponse, status_code=201)
def create_knowledge_base(
body: KbCreateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
svc = KnowledgeBaseService(db)
kb, token = svc.create(user, body.name, body.description)
return _to_response(kb, ai_url=f"/k/{token}")
@router.get("/{kb_id}", response_model=KbResponse)
def get_knowledge_base(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
svc = KnowledgeBaseService(db)
kb = svc.get_or_404(kb_id, user)
doc_count = svc._kb_repo.count_documents(kb_id)
return _to_response(kb, doc_count)
@router.put("/{kb_id}", response_model=KbResponse)
def update_knowledge_base(
kb_id: str,
body: KbUpdateRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
svc = KnowledgeBaseService(db)
kb = svc.update(kb_id, user, body.name, body.description)
return _to_response(kb)
@router.delete("/{kb_id}", status_code=204)
def delete_knowledge_base(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
svc = KnowledgeBaseService(db)
svc.delete(kb_id, user)
@router.post("/{kb_id}/regenerate-token", response_model=KbTokenResponse)
def regenerate_token(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbTokenResponse:
svc = KnowledgeBaseService(db)
kb, token = svc.regenerate_token(kb_id, user)
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint)
@router.post("/{kb_id}/enable", response_model=KbResponse)
def enable_knowledge_base(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
svc = KnowledgeBaseService(db)
kb = svc.set_enabled(kb_id, user, True)
return _to_response(kb)
@router.post("/{kb_id}/disable", response_model=KbResponse)
def disable_knowledge_base(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbResponse:
svc = KnowledgeBaseService(db)
kb = svc.set_enabled(kb_id, user, False)
return _to_response(kb)
@router.get("/{kb_id}/link", response_model=KbTokenResponse)
def get_knowledge_base_link(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> KbTokenResponse:
"""获取完整 AI 链接(解密 token)。"""
svc = KnowledgeBaseService(db)
kb = svc.get_or_404(kb_id, user)
token = svc.get_full_token(kb)
if token is None:
return KbTokenResponse(token="", ai_url="", token_hint=kb.token_hint or "")
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint or "")
def _to_response(kb, doc_count: int = 0, ai_url: str | None = None) -> KbResponse:
return KbResponse(
id=kb.id,
name=kb.name,
description=kb.description,
enabled=kb.enabled,
token_hint=kb.token_hint,
ai_url=ai_url,
document_count=doc_count,
created_at=kb.created_at,
updated_at=kb.updated_at,
)
+1 -1
View File
@@ -119,7 +119,7 @@ async def validation_error_handler(_: Request, exc: RequestValidationError) -> J
content={
"code": "VALIDATION_ERROR",
"message": "请求参数不正确。",
"detail": exc.errors(include_url=False, include_input=False),
"detail": exc.errors(),
},
)
+59
View File
@@ -0,0 +1,59 @@
"""密码哈希与 Token 工具。
密码:Argon2idargon2-cffi
Tokensecrets.token_urlsafe + SHA-256 hash + Fernet 加密
"""
import hashlib
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from cryptography.fernet import Fernet
from app.core.config import get_settings
_ph = PasswordHasher()
def hash_password(password: str) -> str:
"""Argon2id 哈希密码。"""
return _ph.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""验证密码。"""
try:
return _ph.verify(password_hash, password)
except VerifyMismatchError:
return False
def generate_token() -> str:
"""生成 128bit 熵 URL-safe token22 字符)。"""
return secrets.token_urlsafe(16)
def hash_token(token: str) -> str:
"""SHA-256 哈希 token(用于数据库存储和查询)。"""
return hashlib.sha256(token.encode()).hexdigest()
def get_fernet() -> Fernet:
"""从 SECRET_KEY 派生 Fernet 实例(用于加密存储 token 原文)。"""
settings = get_settings()
# 将 SECRET_KEY 转为 32 字节 Fernet key(简单派生,MVP 够用)
key = hashlib.sha256(settings.secret_key.encode()).digest()
import base64
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_token(token: str) -> str:
"""Fernet 加密 token 原文。"""
return get_fernet().encrypt(token.encode()).decode()
def decrypt_token(encrypted: str) -> str:
"""Fernet 解密 token 原文。"""
return get_fernet().decrypt(encrypted.encode()).decode()
+30 -6
View File
@@ -1,7 +1,7 @@
"""AI Knowledge Link — FastAPI 应用工厂。
Phase 1 只包含:配置校验、日志、异常处理器、健康检查、lifespan 资源管理。
后续 Phase 逐步挂载:auth、knowledge-bases、documents、public /k/ 路由。
Phase 1-3: 配置校验、日志、异常处理器、健康检查、lifespan 资源管理、认证路由
后续 Phase 逐步挂载:knowledge-bases、documents、public /k/ 路由。
"""
from contextlib import asynccontextmanager
@@ -10,15 +10,35 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.documents import router as doc_router
from app.api.health import router as health_router
from app.api.knowledge_bases import router as kb_router
from app.public.routes import router as public_router
from app.core.config import get_settings
from app.core.db import dispose_engine
from app.core.db import dispose_engine, get_session_factory
from app.core.errors import register_exception_handlers
from app.core.logging import get_logger, setup_logging
logger = get_logger(__name__)
def _seed_free_plan() -> None:
"""启动时确保 free plan 存在(幂等)。"""
from app.repositories.plan_repo import PlanRepository
factory = get_session_factory()
with factory() as session:
repo = PlanRepository(session)
plan = repo.get_by_code("free")
if plan is None:
repo.get_or_create_free()
session.commit()
logger.info("Seeded free plan (100MB / 20MB-per-file)")
else:
logger.debug("Free plan already exists (id=%s)", plan.id)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
@@ -29,6 +49,9 @@ async def lifespan(app: FastAPI):
data_dir.mkdir(parents=True, exist_ok=True)
logger.info("Starting backend (env=%s, data=%s)", settings.environment, data_dir)
# Seed:确保 free plan 存在
_seed_free_plan()
yield
dispose_engine()
@@ -58,9 +81,10 @@ def create_app() -> FastAPI:
# 路由挂载
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"])
app.include_router(auth_router, prefix="/api", tags=["auth"])
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
app.include_router(doc_router, prefix="/api", tags=["documents"])
app.include_router(public_router, tags=["public"])
return app
+6
View File
@@ -37,6 +37,12 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False,
comment="是否启用",
)
status: Mapped[str] = mapped_column(
String(16),
default="active",
nullable=False,
comment="状态 (active/deleted)",
)
token_hash: Mapped[str] = mapped_column(
String(64),
unique=True,
+12
View File
@@ -0,0 +1,12 @@
"""文档处理器抽象(扩展接口 3:同步 → 异步任务)。"""
from typing import Protocol, runtime_checkable
@runtime_checkable
class DocumentProcessor(Protocol):
"""文档处理统一接口。业务代码通过此接口处理文档,不直接写解析逻辑。"""
def process(self, document_id: str) -> None:
"""处理文档:解析 → 提取元数据 → 保存 Markdown。"""
...
+164
View File
@@ -0,0 +1,164 @@
"""本地文档处理器(MVP:同步处理)。"""
from pathlib import Path
from app.core.logging import get_logger
from app.core.security import decrypt_token, encrypt_token, generate_token, hash_token
from app.models.document import Document
from app.processors.parsers.docx_parser import parse_docx_with_python_docx
from app.processors.parsers.markitdown_parser import parse_with_markitdown
from app.processors.parsers.pdf_parser import has_text_layer, parse_pdf_with_pymupdf
from app.repositories.doc_repo import DocumentRepository
from app.storage.local_storage import get_storage
from app.storage.object_keys import markdown_object_key
from sqlalchemy.orm import Session
logger = get_logger(__name__)
class LocalDocumentProcessor:
"""本地文档处理器:在同一进程中完成解析。"""
def __init__(self, session: Session) -> None:
self._session = session
self._doc_repo = DocumentRepository(session)
def process(self, document_id: str) -> None:
"""处理文档:解析 → 提取元数据 → 保存 Markdown → 更新 DB。"""
doc = self._doc_repo.get_by_id(document_id)
if doc is None:
logger.error("Document not found: %s", document_id)
return
# 更新状态为 PROCESSING
doc.status = "PROCESSING"
self._session.commit()
try:
# 读取原始文件
storage = get_storage()
file_content = storage.read(doc.storage_path)
# 解析
markdown = self._parse_file(file_content, doc.file_ext, doc.original_filename)
if markdown is None:
# 解析失败
if doc.file_ext == ".pdf" and not has_text_layer(file_content):
doc.error_code = "SCANNED_PDF_NO_TEXT_LAYER"
doc.status = "FAILED"
else:
doc.error_code = "PARSING_FAILED"
doc.status = "FAILED"
self._session.commit()
logger.warning("Document parsing failed: %s (%s)", doc.id, doc.error_code)
return
# 清洗 Markdown
markdown = self._clean_markdown(markdown)
# 保存 Markdown 文件
md_key = markdown_object_key(
user_id=doc.user_id,
knowledge_base_id=doc.knowledge_base_id,
document_id=doc.id,
)
storage.save(md_key, markdown.encode("utf-8"))
doc.markdown_path = md_key
# 提取元数据
title = self._extract_title(markdown, doc.original_filename)
summary = self._extract_summary(markdown)
keywords = self._extract_keywords(markdown)
doc.title = title
doc.content_summary = summary
doc.keywords = keywords
doc.status = "READY"
self._session.commit()
logger.info("Document processed: %s → READY", doc.id)
except Exception as e:
logger.error("Document processing error: %s - %s", doc.id, e, exc_info=True)
doc.status = "FAILED"
doc.error_code = "PARSING_FAILED"
self._session.commit()
def _parse_file(self, content: bytes, ext: str, filename: str) -> str | None:
"""按扩展名路由到对应解析器。"""
# 优先 MarkItDown
markdown = parse_with_markitdown(content, filename)
if markdown and markdown.strip():
return markdown
# Fallback
if ext == ".pdf":
return parse_pdf_with_pymupdf(content)
elif ext == ".docx":
return parse_docx_with_python_docx(content)
return None
def _clean_markdown(self, markdown: str) -> str:
"""清洗 Markdown:压缩空行、规整标题层级。"""
import re
# 压缩连续空行为最多 2 个
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
# 截断超长行(> 1000 字符的行)
lines = markdown.split("\n")
cleaned = []
for line in lines:
if len(line) > 1000:
line = line[:1000] + "..."
cleaned.append(line)
return "\n".join(cleaned)
def _extract_title(self, markdown: str, fallback: str) -> str:
"""提取标题:H1 → 文件名(去扩展名)。"""
import re
# 找第一个 H1
match = re.search(r"^#\s+(.+)$", markdown, re.MULTILINE)
if match:
return match.group(1).strip()
# Fallback:文件名去扩展名
return Path(fallback).stem
def _extract_summary(self, markdown: str, max_len: int = 200) -> str:
"""抽取式摘要:取正文前 ~200 字纯文本。"""
import re
# 去掉 Markdown 标记
text = re.sub(r"[#*_`\[\]()>]", "", markdown)
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_len:
return text
# 在 max_len 附近找句号/逗号断句
cut = text[:max_len]
for sep in ["", "", "", "", ".", "!", "?", ""]:
idx = cut.rfind(sep)
if idx > max_len * 0.5:
return cut[: idx + 1]
return cut + "..."
def _extract_keywords(self, markdown: str, top_k: int = 10) -> str:
"""提取关键词(jieba TF-IDF)。"""
try:
import jieba.analyse
# 去掉 Markdown 标记
import re
text = re.sub(r"[#*_`\[\]()>]", "", markdown)
text = re.sub(r"\s+", " ", text).strip()
keywords = jieba.analyse.extract_tags(text, topK=top_k)
return ",".join(keywords)
except Exception as e:
logger.warning("关键词提取失败: %s", e)
return ""
@@ -0,0 +1,54 @@
"""DOCX 解析器(python-docx fallback)。"""
from app.core.logging import get_logger
logger = get_logger(__name__)
def parse_docx_with_python_docx(file_content: bytes) -> str | None:
"""用 python-docx 解析 DOCX,返回 Markdown 或 None(失败时)。"""
try:
import io
from docx import Document
doc = Document(io.BytesIO(file_content))
md_parts = []
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
style = para.style.name.lower()
if style.startswith("heading 1"):
md_parts.append(f"# {text}")
elif style.startswith("heading 2"):
md_parts.append(f"## {text}")
elif style.startswith("heading 3"):
md_parts.append(f"### {text}")
elif style.startswith("heading"):
md_parts.append(f"#### {text}")
elif style.startswith("list"):
md_parts.append(f"- {text}")
else:
md_parts.append(text)
# 处理表格
for table in doc.tables:
rows = []
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells]
rows.append("| " + " | ".join(cells) + " |")
if rows:
# 添加表头分隔行
if len(rows) > 1:
sep = "| " + " | ".join(["---"] * len(table.columns)) + " |"
rows.insert(1, sep)
md_parts.append("\n".join(rows))
markdown = "\n\n".join(md_parts)
return markdown if markdown.strip() else None
except Exception as e:
logger.warning("python-docx 解析失败: %s", e)
return None
@@ -0,0 +1,30 @@
"""MarkItDown 解析器(优先)。"""
from app.core.logging import get_logger
logger = get_logger(__name__)
def parse_with_markitdown(file_content: bytes, filename: str) -> str | None:
"""用 MarkItDown 解析文档,返回 Markdown 或 None(失败时)。"""
try:
from markitdown import MarkItDown
md = MarkItDown()
# MarkItDown 需要文件路径或文件对象,用临时文件
import tempfile
from pathlib import Path
suffix = Path(filename).suffix.lower()
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(file_content)
tmp_path = tmp.name
try:
result = md.convert(tmp_path)
return result.text_content if result and result.text_content else None
finally:
Path(tmp_path).unlink(missing_ok=True)
except Exception as e:
logger.warning("MarkItDown 解析失败 (%s): %s", filename, e)
return None
@@ -0,0 +1,47 @@
"""PDF 解析器(PyMuPDF fallback)。"""
from app.core.logging import get_logger
logger = get_logger(__name__)
def parse_pdf_with_pymupdf(file_content: bytes) -> str | None:
"""用 PyMuPDF 解析 PDF,返回 Markdown 或 None(失败时)。"""
try:
import pymupdf # PyMuPDF >= 1.24
doc = pymupdf.open(stream=file_content, filetype="pdf")
pages = []
for page_num in range(len(doc)):
page = doc.load_page(page_num)
text = page.get_text()
if text.strip():
pages.append(text)
doc.close()
if not pages:
return None
# 组合成 Markdown(每页一个段落)
markdown = "\n\n".join(pages)
return markdown
except Exception as e:
logger.warning("PyMuPDF 解析失败: %s", e)
return None
def has_text_layer(file_content: bytes) -> bool:
"""检查 PDF 是否有文本层。"""
try:
import pymupdf
doc = pymupdf.open(stream=file_content, filetype="pdf")
for page_num in range(min(3, len(doc))): # 检查前 3 页
page = doc.load_page(page_num)
if page.get_text().strip():
doc.close()
return True
doc.close()
return False
except Exception:
return False
+420
View File
@@ -0,0 +1,420 @@
"""公共 AI 页面路由(/k/**)。
规则:
- 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML
- <meta name="robots" content="noindex,nofollow,noarchive">
- <meta name="referrer" content="no-referrer">
- 限流:内存 TokenBucket
- 统一 404 防存在性探测
"""
import json
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token
from app.services.kb_public_service import KbPublicService
router = APIRouter(prefix="/k", tags=["public"])
def _rate_limit(request: Request, token: str) -> None:
"""限流检查。"""
ip = request.client.host if request.client else "unknown"
if not check_rate_limit(token_key=token[:16], ip_key=ip):
raise RateLimitedError()
def _robots_meta() -> str:
return '<meta name="robots" content="noindex,nofollow,noarchive">'
def _referrer_meta() -> str:
return '<meta name="referrer" content="no-referrer">'
# --- 知识库入口(后缀路由必须先于无后缀路由注册)---
@router.get("/{token}.md")
def kb_index_markdown(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""知识库首页(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
docs, _ = svc.list_documents(kb)
lines = [f"# {kb.name}", ""]
if kb.description:
lines.append(kb.description)
lines.append("")
lines.append("## 文档列表")
lines.append("")
for doc in docs:
title = doc.title or doc.original_filename
lines.append(f"### {title}")
lines.append(f"- 类型:{doc.file_ext}")
if doc.description:
lines.append(f"- 描述:{doc.description}")
if doc.keywords:
lines.append(f"- 关键词:{doc.keywords}")
if doc.content_summary:
lines.append(f"- 摘要:{doc.content_summary}")
lines.append(f"- 更新时间:{doc.updated_at}")
lines.append("")
return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
@router.get("/{token}.txt")
def kb_index_text(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""知识库首页(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
docs, _ = svc.list_documents(kb)
lines = [kb.name, "=" * len(kb.name), ""]
if kb.description:
lines.append(kb.description)
lines.append("")
lines.append("文档列表:")
lines.append("")
for i, doc in enumerate(docs, 1):
title = doc.title or doc.original_filename
lines.append(f"{i}. {title}")
if doc.description:
lines.append(f" 描述:{doc.description}")
if doc.keywords:
lines.append(f" 关键词:{doc.keywords}")
lines.append("")
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
@router.get("/{token}.json")
def kb_index_json(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""知识库首页(JSON)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
docs, _ = svc.list_documents(kb)
doc_list = []
for doc in docs:
doc_list.append({
"title": doc.title or doc.original_filename,
"file_type": doc.file_ext,
"description": doc.description,
"summary": doc.content_summary,
"keywords": doc.keywords.split(",") if doc.keywords else [],
"updated_at": doc.updated_at,
})
data = {
"name": kb.name,
"description": kb.description,
"document_count": len(doc_list),
"documents": doc_list,
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
)
@router.get("/{token}")
def kb_index_html(
token: str,
request: Request,
page: int = Query(1, ge=1),
db: Session = Depends(get_db),
) -> HTMLResponse:
"""知识库首页(HTML)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
docs, total = svc.list_documents(kb, page=page, page_size=50)
doc_rows = ""
for doc in docs:
doc_url = f"/k/{token}/doc/{decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ''}"
keywords_html = f'<span class="keywords">关键词:{doc.keywords}</span>' if doc.keywords else ""
summary_html = f'<p class="summary">{doc.content_summary or ""}</p>' if doc.content_summary else ""
doc_rows += f"""
<div class="doc-item">
<h3><a href="{doc_url}">{doc.title or doc.original_filename}</a></h3>
<p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
{summary_html}
{keywords_html}
</div>
"""
total_pages = (total + 49) // 50
pagination = ""
if total_pages > 1:
pagination = f'<p class="pagination">第 {page} / {total_pages} 页</p>'
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{kb.name}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
h1 {{ color: #333; }}
.doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.doc-item h3 {{ margin: 0 0 5px 0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }}
.doc-item a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 0.9em; margin: 5px 0; }}
.summary {{ color: #444; font-size: 0.95em; margin: 5px 0; }}
.keywords {{ color: #888; font-size: 0.85em; }}
.pagination {{ color: #666; font-size: 0.9em; text-align: center; }}
.footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }}
</style>
</head>
<body>
<h1>{kb.name}</h1>
{"<p>" + kb.description + "</p>" if kb.description else ""}
<h2>文档列表</h2>
{doc_rows if doc_rows else "<p>暂无文档。</p>"}
{pagination}
<div class="footer">
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
# --- 单文档访问 ---
@router.get("/{token}/doc/{doc_token}")
def doc_page_html(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""文档页面(HTML)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
markdown_content = svc.get_document_markdown(doc)
# Markdown → HTML(简单转换)
html_content = _markdown_to_html(markdown_content)
keywords_html = f"<p><strong>关键词:</strong>{doc.keywords}</p>" if doc.keywords else ""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{doc.title or doc.original_filename}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.8; }}
h1 {{ color: #333; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
.content {{ margin-top: 20px; }}
.content h1, .content h2, .content h3 {{ color: #333; }}
.content pre {{ background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }}
.content code {{ background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }}
.content table {{ border-collapse: collapse; width: 100%; }}
.content th, .content td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
.content th {{ background: #f5f5f5; }}
.back {{ margin-top: 30px; }}
.back a {{ color: #0066cc; }}
</style>
</head>
<body>
<h1>{doc.title or doc.original_filename}</h1>
<div class="meta">
<p>类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
{"<p><strong>描述:</strong>" + doc.description + "</p>" if doc.description else ""}
{keywords_html}
</div>
<div class="content">
{html_content}
</div>
<div class="back">
<a href="/k/{token}">← 返回知识库目录</a>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{token}/doc/{doc_token}.md")
def doc_page_markdown(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
markdown_content = svc.get_document_markdown(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@router.get("/{token}/doc/{doc_token}.txt")
def doc_page_text(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
markdown_content = svc.get_document_markdown(doc)
# 去掉 Markdown 标记
import re
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain")
# --- 搜索 ---
@router.get("/{token}/search")
def search_html(
token: str,
q: str = Query(..., min_length=1),
page: int = Query(1, ge=1),
request: Request = None,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""搜索文档(HTML)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
results, total = svc.search_documents(kb, q, page=page)
result_items = ""
for item in results:
doc_url = f"/k/{token}/doc/{item['url_hint'] or ''}"
result_items += f"""
<div class="result-item">
<h3><a href="{doc_url}">{item['title']}</a></h3>
<p class="meta">类型:{item['file_type']} | 更新:{item['updated_at']}</p>
{"<p>" + (item.get('description') or '') + "</p>"}
</div>
"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>搜索:{q} - {kb.name}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
.result-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.result-item a {{ color: #0066cc; text-decoration: none; }}
.meta {{ color: #666; font-size: 0.9em; }}
</style>
</head>
<body>
<h1>搜索:{q}</h1>
<p>共找到 {total} 个结果</p>
{result_items if result_items else "<p>未找到相关文档。</p>"}
<p><a href="/k/{token}">← 返回知识库目录</a></p>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{token}/search.json")
def search_json(
token: str,
q: str = Query(..., min_length=1),
page: int = Query(1, ge=1),
request: Request = None,
db: Session = Depends(get_db),
) -> Response:
"""搜索文档(JSON)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
results, total = svc.search_documents(kb, q, page=page)
data = {
"query": q,
"total": total,
"results": results,
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
)
def _markdown_to_html(markdown: str) -> str:
"""简单 Markdown → HTML 转换(安全处理)。"""
import re
# 转义 HTML 特殊字符
html = markdown.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 标题
html = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", html, flags=re.MULTILINE)
html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
# 粗体/斜体
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
# 代码块
html = re.sub(r"```[\s\S]*?```", lambda m: f"<pre><code>{m.group(0)[3:-3]}</code></pre>", html)
# 行内代码
html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html)
# 段落(双换行 → <p>
html = re.sub(r"\n\n+", "</p><p>", html)
html = f"<p>{html}</p>"
return html
+118
View File
@@ -0,0 +1,118 @@
"""Document 文档 Repository。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.document import Document
class DocumentRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, doc_id: str) -> Document | None:
return self._session.get(Document, doc_id)
def get_by_doc_token_hash(self, token_hash: str) -> Document | None:
stmt = select(Document).where(Document.doc_token_hash == token_hash)
return self._session.scalars(stmt).first()
def list_by_knowledge_base(
self,
kb_id: str,
*,
page: int = 1,
page_size: int = 50,
status: str | None = None,
) -> tuple[list[Document], int]:
"""分页获取知识库的文档列表。"""
conditions = [
Document.knowledge_base_id == kb_id,
Document.status != "DELETED",
]
if status:
conditions.append(Document.status == status)
count_stmt = select(func.count()).select_from(Document).where(*conditions)
total = self._session.scalar(count_stmt) or 0
stmt = (
select(Document)
.where(*conditions)
.order_by(Document.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = list(self._session.scalars(stmt).all())
return items, total
def list_by_user(
self, user_id: str, *, page: int = 1, page_size: int = 20
) -> tuple[list[Document], int]:
"""分页获取用户的文档列表。"""
conditions = [Document.user_id == user_id, Document.status != "DELETED"]
count_stmt = select(func.count()).select_from(Document).where(*conditions)
total = self._session.scalar(count_stmt) or 0
stmt = (
select(Document)
.where(*conditions)
.order_by(Document.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = list(self._session.scalars(stmt).all())
return items, total
def create(
self,
*,
knowledge_base_id: str,
user_id: str,
original_filename: str,
storage_path: str,
file_size: int,
mime_type: str,
file_ext: str,
sha256: str,
doc_token_hash: str,
doc_token_encrypted: str,
doc_token_hint: str,
category_id: str | None = None,
) -> Document:
doc = Document(
knowledge_base_id=knowledge_base_id,
user_id=user_id,
original_filename=original_filename,
storage_path=storage_path,
file_size=file_size,
mime_type=mime_type,
file_ext=file_ext,
sha256=sha256,
doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint,
category_id=category_id,
status="PENDING",
)
self._session.add(doc)
self._session.flush()
return doc
def update(self, doc: Document, **fields) -> None:
for key, value in fields.items():
setattr(doc, key, value)
self._session.flush()
def delete(self, doc: Document) -> None:
doc.status = "DELETED"
self._session.flush()
def count_by_user(self, user_id: str) -> int:
stmt = (
select(func.count())
.select_from(Document)
.where(Document.user_id == user_id, Document.status != "DELETED")
)
return self._session.scalar(stmt) or 0
+104
View File
@@ -0,0 +1,104 @@
"""KnowledgeBase 知识库 Repository。"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.document import Document
from app.models.knowledge_base import KnowledgeBase
class KnowledgeBaseRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, kb_id: str) -> KnowledgeBase | None:
return self._session.get(KnowledgeBase, kb_id)
def get_by_token_hash(self, token_hash: str) -> KnowledgeBase | None:
stmt = select(KnowledgeBase).where(KnowledgeBase.token_hash == token_hash)
return self._session.scalars(stmt).first()
def list_by_user(
self, user_id: str, *, page: int = 1, page_size: int = 20
) -> tuple[list[KnowledgeBase], int]:
"""分页获取用户的知识库列表。返回 (items, total)。"""
count_stmt = (
select(func.count())
.select_from(KnowledgeBase)
.where(
KnowledgeBase.user_id == user_id,
KnowledgeBase.status != "DELETED",
)
)
total = self._session.scalar(count_stmt) or 0
stmt = (
select(KnowledgeBase)
.where(
KnowledgeBase.user_id == user_id,
KnowledgeBase.status != "DELETED",
)
.order_by(KnowledgeBase.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = list(self._session.scalars(stmt).all())
return items, total
def create(
self,
*,
user_id: str,
name: str,
description: str | None,
token_hash: str,
token_encrypted: str,
token_hint: str,
) -> KnowledgeBase:
kb = KnowledgeBase(
user_id=user_id,
name=name,
description=description,
enabled=True,
status="active",
token_hash=token_hash,
token_encrypted=token_encrypted,
token_hint=token_hint,
)
self._session.add(kb)
self._session.flush()
return kb
def update(self, kb: KnowledgeBase, **fields) -> None:
for key, value in fields.items():
if value is not None:
setattr(kb, key, value)
self._session.flush()
def delete(self, kb: KnowledgeBase) -> None:
"""软删除。"""
kb.status = "DELETED"
self._session.flush()
def count_documents(self, kb_id: str) -> int:
stmt = (
select(func.count())
.select_from(Document)
.where(
Document.knowledge_base_id == kb_id,
Document.status != "DELETED",
)
)
return self._session.scalar(stmt) or 0
def get_document_count_map(self, user_id: str) -> dict[str, int]:
"""批量获取用户所有知识库的文档数量(避免 N+1)。"""
stmt = (
select(Document.knowledge_base_id, func.count())
.where(
Document.user_id == user_id,
Document.status != "DELETED",
)
.group_by(Document.knowledge_base_id)
)
return dict(self._session.execute(stmt).all())
+33
View File
@@ -0,0 +1,33 @@
"""Plan 套餐 Repository。"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.plan import Plan
class PlanRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, plan_id: str) -> Plan | None:
return self._session.get(Plan, plan_id)
def get_by_code(self, code: str) -> Plan | None:
stmt = select(Plan).where(Plan.code == code)
return self._session.scalars(stmt).first()
def get_or_create_free(self) -> Plan:
"""获取 free plan,不存在则创建(seed 逻辑)。"""
plan = self.get_by_code("free")
if plan is None:
plan = Plan(
code="free",
name="免费版",
storage_quota=104_857_600, # 100 MB
max_file_size=20_971_520, # 20 MB
is_active=True,
)
self._session.add(plan)
self._session.flush()
return plan
+64
View File
@@ -0,0 +1,64 @@
"""User 用户 Repository。"""
from sqlalchemy import select, or_
from sqlalchemy.orm import Session
from app.models.user import User
class UserRepository:
def __init__(self, session: Session) -> None:
self._session = session
def get_by_id(self, user_id: str) -> User | None:
return self._session.get(User, user_id)
def get_by_username(self, username: str) -> User | None:
stmt = select(User).where(User.username == username)
return self._session.scalars(stmt).first()
def get_by_email(self, email: str) -> User | None:
stmt = select(User).where(User.email == email)
return self._session.scalars(stmt).first()
def get_by_username_or_email(self, value: str) -> User | None:
"""登录用:按用户名或邮箱查找。"""
stmt = select(User).where(
or_(User.username == value, User.email == value.lower())
)
return self._session.scalars(stmt).first()
def exists_username_or_email(self, username: str, email: str) -> tuple[bool, bool]:
"""检查用户名/邮箱是否已存在。返回 (username_taken, email_taken)。"""
stmt = select(User).where(
or_(User.username == username, User.email == email.lower())
)
existing = list(self._session.scalars(stmt).all())
return (
any(u.username == username for u in existing),
any(u.email == email.lower() for u in existing),
)
def create(
self,
*,
username: str,
email: str,
password_hash: str,
plan_id: str,
) -> User:
user = User(
username=username,
email=email.lower(),
password_hash=password_hash,
status="active",
plan_id=plan_id,
storage_used=0,
)
self._session.add(user)
self._session.flush()
return user
def update_password(self, user: User, password_hash: str) -> None:
user.password_hash = password_hash
self._session.flush()
+19
View File
@@ -0,0 +1,19 @@
"""Pydantic v2 Schema 包。"""
from app.schemas.user import (
LoginRequest,
MeResponse,
RegisterRequest,
StorageInfoResponse,
UpdateMeRequest,
UserResponse,
)
__all__ = [
"LoginRequest",
"MeResponse",
"RegisterRequest",
"StorageInfoResponse",
"UpdateMeRequest",
"UserResponse",
]
+45
View File
@@ -0,0 +1,45 @@
"""文档相关 Pydantic v2 Schema。"""
from pydantic import BaseModel, Field
class DocumentResponse(BaseModel):
id: str
knowledge_base_id: str
original_filename: str
file_size: int
mime_type: str
file_ext: str
sha256: str
title: str | None
description: str | None
keywords: str | None
content_summary: str | None
status: str
error_code: str | None
doc_token_hint: str | None
category_id: str | None
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class DocumentListResponse(BaseModel):
items: list[DocumentResponse]
total: int
class DocumentUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2000)
keywords: str | None = Field(default=None, max_length=500)
category_id: str | None = None
class DocumentUploadResponse(BaseModel):
id: str
original_filename: str
file_size: int
status: str
message: str
+39
View File
@@ -0,0 +1,39 @@
"""知识库相关 Pydantic v2 Schema。"""
from pydantic import BaseModel, Field
class KbCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=2000)
class KbUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
class KbResponse(BaseModel):
id: str
name: str
description: str | None
enabled: bool
token_hint: str | None
ai_url: str | None = None # 仅创建/重置时返回完整 URL
document_count: int = 0
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class KbListResponse(BaseModel):
items: list[KbResponse]
total: int
class KbTokenResponse(BaseModel):
"""Token 重置/创建时的完整 URL 响应。"""
token: str
ai_url: str
token_hint: str
+82
View File
@@ -0,0 +1,82 @@
"""用户相关 Pydantic v2 Schema(请求/响应)。"""
import re
from pydantic import BaseModel, EmailStr, Field, field_validator
_USERNAME_RE = re.compile(r"^[A-Za-z0-9_\-一-鿿]{2,32}$")
class RegisterRequest(BaseModel):
username: str = Field(min_length=2, max_length=32)
email: str = Field(min_length=5, max_length=255)
password: str = Field(min_length=8, max_length=128)
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
if not _USERNAME_RE.match(v):
raise ValueError("用户名只能包含字母、数字、下划线、短横线和中文,长度 2-32。")
return v
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
# 简单格式校验(不引入 email-validator 重依赖)
if "@" not in v or "." not in v.split("@")[-1]:
raise ValueError("邮箱格式不正确。")
return v.lower().strip()
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("密码长度不能少于 8 位。")
return v
class LoginRequest(BaseModel):
username_or_email: str = Field(min_length=2, max_length=255)
password: str = Field(min_length=1, max_length=128)
class UserResponse(BaseModel):
id: str
username: str
email: str
status: str
storage_used: int
storage_quota: int
created_at: str
model_config = {"from_attributes": True}
class MeResponse(BaseModel):
id: str
username: str
email: str
status: str
storage_used: int
storage_quota: int
created_at: str
model_config = {"from_attributes": True}
class StorageInfoResponse(BaseModel):
storage_used: int
storage_quota: int
storage_used_mb: float
storage_quota_mb: float
class UpdateMeRequest(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=128)
@field_validator("password")
@classmethod
def validate_password(cls, v: str | None) -> str | None:
if v is not None and len(v) < 8:
raise ValueError("密码长度不能少于 8 位。")
return v
+86
View File
@@ -0,0 +1,86 @@
"""认证服务:注册、登录、登出、Session 校验。"""
from app.core.errors import (
AuthRequiredError,
ConflictError,
InvalidCredentialsError,
)
from app.core.security import hash_password, verify_password
from app.core.session import create_session, delete_session, get_session_user
from app.models.user import User
from app.repositories.plan_repo import PlanRepository
from app.repositories.user_repo import UserRepository
from sqlalchemy.orm import Session
class AuthService:
def __init__(self, session: Session) -> None:
self._session = session
self._user_repo = UserRepository(session)
self._plan_repo = PlanRepository(session)
def register(self, username: str, email: str, password: str) -> tuple[User, str]:
"""注册新用户。返回 (user, session_token)。
Raises:
ConflictError: 用户名或邮箱已存在
"""
username_taken, email_taken = self._user_repo.exists_username_or_email(username, email)
if username_taken:
raise ConflictError("用户名已被占用。", code="USERNAME_TAKEN")
if email_taken:
raise ConflictError("邮箱已被注册。", code="EMAIL_TAKEN")
plan = self._plan_repo.get_or_create_free()
user = self._user_repo.create(
username=username,
email=email,
password_hash=hash_password(password),
plan_id=plan.id,
)
self._session.commit()
token = create_session(user.id)
return user, token
def login(self, username_or_email: str, password: str) -> tuple[User, str]:
"""登录。返回 (user, session_token)。
Raises:
InvalidCredentialsError: 用户名/邮箱或密码错误
"""
user = self._user_repo.get_by_username_or_email(username_or_email)
if user is None:
raise InvalidCredentialsError()
if not verify_password(password, user.password_hash):
raise InvalidCredentialsError()
if user.status != "active":
raise InvalidCredentialsError("账户已被禁用。")
token = create_session(user.id)
return user, token
def logout(self, session_token: str) -> None:
"""登出:删除 session。"""
delete_session(session_token)
def get_current_user(self, session_token: str | None) -> User:
"""根据 session token 获取当前用户。
Raises:
AuthRequiredError: 未登录或 session 已过期
"""
if not session_token:
raise AuthRequiredError()
user_id = get_session_user(session_token)
if user_id is None:
raise AuthRequiredError("登录已过期,请重新登录。")
user = self._user_repo.get_by_id(user_id)
if user is None or user.status != "active":
raise AuthRequiredError()
return user
def change_password(self, user: User, new_password: str) -> None:
"""修改密码。"""
self._user_repo.update_password(user, hash_password(new_password))
self._session.commit()
+202
View File
@@ -0,0 +1,202 @@
"""文档服务:上传、删除、配额管理。"""
import hashlib
from pathlib import Path
import filetype
from app.core.config import get_settings
from app.core.errors import (
FileTooLargeError,
FileTypeUnsupportedError,
NotFoundError,
StorageQuotaExceededError,
)
from app.core.security import decrypt_token, encrypt_token, generate_token, hash_token
from app.models.document import Document
from app.models.user import User
from app.repositories.doc_repo import DocumentRepository
from app.repositories.kb_repo import KnowledgeBaseRepository
from app.storage.local_storage import get_storage
from app.storage.object_keys import original_object_key
from sqlalchemy.orm import Session
# 允许的文件扩展名
ALLOWED_EXTENSIONS = {".docx", ".pdf"}
ALLOWED_MIMES = {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/pdf",
}
class DocumentService:
def __init__(self, session: Session) -> None:
self._session = session
self._doc_repo = DocumentRepository(session)
self._kb_repo = KnowledgeBaseRepository(session)
def upload(
self,
user: User,
kb_id: str,
filename: str,
content: bytes,
) -> Document:
"""上传文档。
流程:校验 KB 归属 → 文件大小 → 扩展名/MIME → 配额 → SHA256 → 存储 → 入库
"""
# 校验 KB 归属
kb = self._kb_repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
settings = get_settings()
# 文件大小
if len(content) > settings.default_max_file_size:
raise FileTooLargeError(
f"单文件大小超出限制(最大 {settings.default_max_file_size // (1024*1024)}MB)。"
)
# 扩展名
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise FileTypeUnsupportedError(
f"不支持的文件类型 '{ext}'。当前支持:{', '.join(sorted(ALLOWED_EXTENSIONS))}"
)
# MIME 嗅探(前 8KB)——仅用于辅助校验,扩展名为主
kind = filetype.guess(content[:8192])
detected_mime = kind.mime if kind else ""
# .docx 底层是 ZIPfiletype 会识别为 application/zip,这是正常的
# 只在检测到明确不属于文档类型的 MIME 时才拒绝(如图片、视频等)
BLOCKED_MIMES = {"image/jpeg", "image/png", "video/mp4", "audio/mpeg", "application/x-executable"}
if detected_mime in BLOCKED_MIMES:
raise FileTypeUnsupportedError(f"文件内容类型不受支持:{detected_mime}")
# 配额检查(原子 SQL
file_size = len(content)
self._check_quota(user, file_size)
# SHA256
sha256 = hashlib.sha256(content).hexdigest()
# 生成文档 token
doc_token = generate_token()
doc_token_hash = hash_token(doc_token)
doc_token_encrypted = encrypt_token(doc_token)
doc_token_hint = doc_token[-8:] if len(doc_token) >= 8 else doc_token
# 存储原始文件
storage_key = original_object_key(
user_id=user.id,
knowledge_base_id=kb_id,
document_id="pending", # 先存文件,入库后更新路径
original_filename=filename,
)
storage = get_storage()
storage.save(storage_key, content)
# 入库
doc = self._doc_repo.create(
knowledge_base_id=kb_id,
user_id=user.id,
original_filename=filename,
storage_path=storage_key,
file_size=file_size,
mime_type=detected_mime or "application/octet-stream",
file_ext=ext,
sha256=sha256,
doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint,
)
# 更新存储路径中的 document_id
actual_key = original_object_key(
user_id=user.id,
knowledge_base_id=kb_id,
document_id=doc.id,
original_filename=filename,
)
# 移动文件到正确路径
if actual_key != storage_key:
storage.save(actual_key, storage.delete(storage_key) or content)
doc.storage_path = actual_key
# 扣减配额(原子 SQL
self._deduct_quota(user, file_size)
self._session.commit()
# 同步解析文档(MVP:阻塞式)
self._process_document(doc)
return doc
def get_or_404(self, doc_id: str, user: User) -> Document:
doc = self._doc_repo.get_by_id(doc_id)
if doc is None or doc.user_id != user.id or doc.status == "DELETED":
raise NotFoundError("文档不存在。")
return doc
def list_by_knowledge_base(
self, kb_id: str, user: User, *, page: int = 1, page_size: int = 50
):
# 校验 KB 归属
kb = self._kb_repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
return self._doc_repo.list_by_knowledge_base(kb_id, page=page, page_size=page_size)
def delete(self, doc_id: str, user: User) -> None:
doc = self.get_or_404(doc_id, user)
file_size = doc.file_size
self._doc_repo.delete(doc)
# 回补配额
self._restore_quota(user, file_size)
self._session.commit()
def _check_quota(self, user: User, file_size: int) -> None:
settings = get_settings()
if user.storage_used + file_size > settings.default_storage_quota:
raise StorageQuotaExceededError(
f"存储空间不足(已用 {user.storage_used // (1024*1024)}MB"
f"上传 {file_size // (1024*1024)}MB"
f"总配额 {settings.default_storage_quota // (1024*1024)}MB)。"
)
def _deduct_quota(self, user: User, file_size: int) -> None:
"""原子扣减配额。"""
from sqlalchemy import update
stmt = (
update(User)
.where(User.id == user.id, User.storage_used + file_size <= get_settings().default_storage_quota)
.values(storage_used=User.storage_used + file_size)
)
result = self._session.execute(stmt)
if result.rowcount == 0:
raise StorageQuotaExceededError("存储空间不足(并发上传导致)。")
# 更新本地对象
user.storage_used += file_size
def _restore_quota(self, user: User, file_size: int) -> None:
"""回补配额。"""
from sqlalchemy import update
stmt = (
update(User)
.where(User.id == user.id)
.values(storage_used=User.storage_used - file_size)
)
self._session.execute(stmt)
user.storage_used = max(0, user.storage_used - file_size)
def _process_document(self, doc: Document) -> None:
"""同步处理文档(MVP 阶段,阻塞式)。"""
from app.processors.local_processor import LocalDocumentProcessor
processor = LocalDocumentProcessor(self._session)
processor.process(doc.id)
+100
View File
@@ -0,0 +1,100 @@
"""公共知识库服务(Phase 10/11/12 统一数据来源)。
HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询逻辑。
"""
from app.core.errors import NotFoundError
from app.core.security import hash_token
from app.models.document import Document
from app.models.knowledge_base import KnowledgeBase
from app.repositories.doc_repo import DocumentRepository
from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy.orm import Session
class KbPublicService:
def __init__(self, session: Session) -> None:
self._session = session
self._kb_repo = KnowledgeBaseRepository(session)
self._doc_repo = DocumentRepository(session)
def get_kb_by_token(self, token: str) -> KnowledgeBase:
"""通过 token 获取知识库。不存在/禁用/删除 → 404。"""
token_hash = hash_token(token)
kb = self._kb_repo.get_by_token_hash(token_hash)
if kb is None or not kb.enabled or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
return kb
def list_documents(
self, kb: KnowledgeBase, *, page: int = 1, page_size: int = 50
) -> tuple[list[Document], int]:
"""获取知识库的文档列表(仅 READY 状态)。"""
return self._doc_repo.list_by_knowledge_base(
kb.id, page=page, page_size=page_size, status="READY"
)
def get_document_by_token(self, kb: KnowledgeBase, doc_token: str) -> Document:
"""通过 token 获取单个文档。"""
doc_token_hash = hash_token(doc_token)
doc = self._doc_repo.get_by_doc_token_hash(doc_token_hash)
if doc is None or doc.knowledge_base_id != kb.id or doc.status != "READY":
raise NotFoundError("文档不存在。")
return doc
def get_document_markdown(self, doc: Document) -> str:
"""读取文档的 Markdown 内容。"""
from app.storage.local_storage import get_storage
if not doc.markdown_path:
return ""
storage = get_storage()
content = storage.read(doc.markdown_path)
return content.decode("utf-8")
def search_documents(
self, kb: KnowledgeBase, query: str, *, page: int = 1, page_size: int = 20
) -> tuple[list[dict], int]:
"""关键词搜索文档(Phase 12SQLite LIKE / FTS5)。"""
# MVP 简化:使用 LIKE 搜索标题+描述+关键词
from sqlalchemy import func, or_, select
conditions = [
Document.knowledge_base_id == kb.id,
Document.status == "READY",
]
like_pattern = f"%{query}%"
search_condition = or_(
Document.title.like(like_pattern),
Document.description.like(like_pattern),
Document.keywords.like(like_pattern),
Document.content_summary.like(like_pattern),
)
conditions.append(search_condition)
count_stmt = select(func.count()).select_from(Document).where(*conditions)
total = self._session.scalar(count_stmt) or 0
stmt = (
select(Document)
.where(*conditions)
.order_by(Document.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
docs = list(self._session.scalars(stmt).all())
results = []
for doc in docs:
results.append({
"id": doc.id,
"title": doc.title or doc.original_filename,
"description": doc.description,
"keywords": doc.keywords,
"file_type": doc.file_ext,
"updated_at": doc.updated_at,
"url_hint": doc.doc_token_hint,
})
return results, total
+85
View File
@@ -0,0 +1,85 @@
"""知识库服务:CRUD + Token 管理。"""
from app.core.errors import NotFoundError, PermissionDeniedError
from app.models.knowledge_base import KnowledgeBase
from app.models.user import User
from app.repositories.kb_repo import KnowledgeBaseRepository
from app.services.token_service import TokenService
from sqlalchemy.orm import Session
class KnowledgeBaseService:
def __init__(self, session: Session) -> None:
self._session = session
self._kb_repo = KnowledgeBaseRepository(session)
self._token_svc = TokenService()
def create(self, user: User, name: str, description: str | None) -> tuple[KnowledgeBase, str]:
"""创建知识库。返回 (kb, full_token)。
full_token 仅此一次返回,用于构建完整 AI URL。
"""
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
kb = self._kb_repo.create(
user_id=user.id,
name=name,
description=description,
token_hash=token_hash,
token_encrypted=token_encrypted,
token_hint=token_hint,
)
self._session.commit()
return kb, token
def get_or_404(self, kb_id: str, user: User) -> KnowledgeBase:
"""获取知识库,校验所有权。不存在或无权 → 404。"""
kb = self._kb_repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
return kb
def list_by_user(self, user: User, *, page: int = 1, page_size: int = 20):
"""分页列表。"""
return self._kb_repo.list_by_user(user.id, page=page, page_size=page_size)
def update(self, kb_id: str, user: User, name: str | None, description: str | None) -> KnowledgeBase:
kb = self.get_or_404(kb_id, user)
update_fields = {}
if name is not None:
update_fields["name"] = name
if description is not None:
update_fields["description"] = description
if update_fields:
self._kb_repo.update(kb, **update_fields)
self._session.commit()
return kb
def delete(self, kb_id: str, user: User) -> None:
kb = self.get_or_404(kb_id, user)
self._kb_repo.delete(kb)
self._session.commit()
def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]:
"""重新生成 Token。旧链接立即失效。"""
kb = self.get_or_404(kb_id, user)
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
self._kb_repo.update(
kb,
token_hash=token_hash,
token_encrypted=token_encrypted,
token_hint=token_hint,
)
self._session.commit()
return kb, token
def set_enabled(self, kb_id: str, user: User, enabled: bool) -> KnowledgeBase:
kb = self.get_or_404(kb_id, user)
self._kb_repo.update(kb, enabled=enabled)
self._session.commit()
return kb
def get_full_token(self, kb: KnowledgeBase) -> str | None:
"""解密 token 原文(供后台显示完整链接)。"""
if kb.token_encrypted:
return self._token_svc.decrypt_token(kb.token_encrypted)
return None
+31
View File
@@ -0,0 +1,31 @@
"""Token 服务:生成、哈希、加密、解密。
用于知识库和文档的 Secret URL token 管理。
"""
from app.core.security import decrypt_token, encrypt_token, generate_token, hash_token
class TokenService:
@staticmethod
def create_token_pair() -> tuple[str, str, str, str]:
"""生成 token 并返回 (token, token_hash, token_encrypted, token_hint)。
- token: 原文(仅此一次返回给用户)
- token_hash: SHA-256 哈希(存 DB,用于查询)
- token_encrypted: Fernet 加密原文(存 DB,供后台显示完整链接)
- token_hint: 末 8 位明文(存 DB,供后台识别)
"""
token = generate_token()
token_hash = hash_token(token)
token_encrypted = encrypt_token(token)
token_hint = token[-8:] if len(token) >= 8 else token
return token, token_hash, token_encrypted, token_hint
@staticmethod
def hash_token(token: str) -> str:
return hash_token(token)
@staticmethod
def decrypt_token(encrypted: str) -> str:
return decrypt_token(encrypted)