{kb.name}
{"" + kb.description + "
" if kb.description else ""} {"← 返回全部文档" if category else ""}{"当前分类:" + category if category else "文档列表"}
{doc_rows if doc_rows else "暂无文档。
"} {pagination}"""公共 AI 页面路由(/k/**)。 规则: - 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML - - - 限流:内存 TokenBucket - 统一 404 防存在性探测 """ import json from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi import Path as PathParam 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.models.document_category import DocumentCategory from app.services.access_log_service import AccessLogService from app.services.kb_public_service import KbPublicService router = APIRouter(prefix="/k", tags=["public"]) def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None: """记录访问日志(best-effort,不阻塞响应)。""" try: ua = request.headers.get("user-agent", "") AccessLogService(db).record( knowledge_base_id=kb_id, document_id=doc_id, path=path, user_agent=ua, request_type=req_type, ) except Exception: pass # 日志失败不影响响应 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 '' def _referrer_meta() -> str: return '' # --- 知识库入口(后缀路由必须先于无后缀路由注册)--- @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) _log_access(db, kb.id, f"/k/{token}.md", request, req_type="md") 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) _log_access(db, kb.id, f"/k/{token}.txt", request, req_type="txt") 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) _log_access(db, kb.id, f"/k/{token}.json", request, req_type="json") 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, }) # 获取目录树 category_tree = svc.get_category_tree(kb) data = { "name": kb.name, "description": kb.description, "document_count": len(doc_list), "categories": category_tree, "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, category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"), 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) _log_access(db, kb.id, f"/k/{token}", request, req_type="html") # 获取目录树 category_tree = svc.get_category_tree(kb) # 按分类过滤 category_id = None if category: # 根据路径查找分类 ID from sqlalchemy import select stmt = select(DocumentCategory).where( DocumentCategory.knowledge_base_id == kb.id, DocumentCategory.path == category, ) cat = db.scalars(stmt).first() if cat: category_id = cat.id docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50) # 渲染目录树侧边栏 def render_tree(nodes: list, level: int = 0) -> str: html = "" for node in nodes: indent = " " * level is_active = category == node["path"] active_class = ' class="active"' if is_active else "" doc_count = f' ({node["doc_count"]})' if node["doc_count"] > 0 else "" if node["is_folder"]: html += f'{indent}
{doc.content_summary or ""}
' if doc.content_summary else "" status_badge = "" if doc.status != "READY": status_badge = f' [{doc.status}]' doc_rows += f"""第 {page} / {total_pages} 页
' html = f"""" + kb.description + "
" if kb.description else ""} {"← 返回全部文档" if category else ""}暂无文档。
"} {pagination}关键词:{doc.keywords}
" if doc.keywords else "" html = f"""" + (item.get('description') or '') + "
"}共找到 {total} 个结果
{result_items if result_items else "未找到相关文档。
"} """ 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("&", "&").replace("<", "<").replace(">", ">") # 标题 html = re.sub(r"^#### (.+)$", r"{m.group(0)[3:-3]}", html)
# 行内代码
html = re.sub(r"`([^`]+)`", r"\1", html)
# 段落(双换行 → ) html = re.sub(r"\n\n+", "
", html) html = f"
{html}
" return html