Files
2026-09-02 18:04:41 +08:00

916 lines
34 KiB
Python

"""公共 AI 页面路由(/k/**)。
核心设计:
- 主页 /k/{token} = 目录索引(TOC),显示所有目录及其文档数量
- 目录页 /k/{token}/category/{path} = 该目录下的文档列表
- 文档页 /k/{token}/doc/{doc_token} = 单个文档内容
- AI 先看目录索引 → 根据用户描述选择目录 → 进入该目录看文档
规则:
- 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML
- <meta name="robots" content="noindex,nofollow,noarchive">
- <meta name="referrer" content="no-referrer">
- 限流:内存 TokenBucket
- 统一 404 防存在性探测
"""
import json
import re
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 LinkExpiredError, 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"])
async def link_expired_handler(request: Request, exc: LinkExpiredError) -> Response:
"""链接过期:HTML 路径返回友好失效页,JSON/MD/TXT 返回对应格式的错误信息。"""
path = request.url.path
message = "链接已失效,请联系分享者重新生成链接。"
if path.endswith(".json"):
return Response(
status_code=410,
content=json.dumps({"code": "LINK_EXPIRED", "message": message, "detail": None}, ensure_ascii=False),
media_type="application/json",
)
if path.endswith(".md") or path.endswith(".txt"):
return PlainTextResponse(content=message, status_code=410, media_type="text/plain; charset=utf-8")
return HTMLResponse(
status_code=410,
content=f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>链接已失效</title>
<meta name="robots" content="noindex,nofollow,noarchive">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
margin: 0; background: #f5f7fa; color: #333; text-align: center; }}
.box {{ background: #fff; padding: 50px 40px; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 420px; }}
.icon {{ font-size: 52px; margin-bottom: 16px; }}
h1 {{ font-size: 20px; margin: 0 0 12px; }}
p {{ color: #888; font-size: 14px; line-height: 1.8; margin: 0; }}
</style>
</head>
<body>
<div class="box">
<div class="icon">⏰</div>
<h1>链接无效或已失效</h1>
<p>该知识库链接已过期。<br>请联系分享者重新生成链接后,获取新的访问地址。</p>
</div>
</body>
</html>""",
)
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 '<meta name="robots" content="noindex,nofollow,noarchive">'
def _referrer_meta() -> str:
return '<meta name="referrer" content="no-referrer">'
def _get_category_by_path(db: Session, kb_id: str, path: str) -> DocumentCategory | None:
"""根据路径查找分类。"""
from sqlalchemy import select
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path == path,
)
return db.scalars(stmt).first()
# ============================================================
# 目录索引(主页)- AI 从这里了解知识库结构
# ============================================================
@router.get("/{token}.json")
def kb_index_json(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""知识库完整内容(JSON)- 按目录分组,包含所有文档内容。
AI 访问此接口获取知识库全部内容,按目录结构组织。
"""
_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")
category_tree = svc.get_category_tree(kb)
# 获取所有文档(按目录分组)
docs, _ = svc.list_documents(kb)
# 按 category_id 分组
doc_map: dict[str, list] = {}
for doc in docs:
cat_id = doc.category_id or "__uncategorized__"
if cat_id not in doc_map:
doc_map[cat_id] = []
doc_map[cat_id].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,
})
# 递归构建目录 + 文档
def build_tree_with_docs(nodes: list) -> list:
result = []
for node in nodes:
cat_docs = doc_map.get(node["id"], [])
node_data = {
"name": node["name"],
"path": node["path"],
"is_folder": node["is_folder"],
"documents": cat_docs,
"children": build_tree_with_docs(node.get("children", [])),
}
result.append(node_data)
return result
tree_with_docs = build_tree_with_docs(category_tree)
# 未分类文档
uncategorized = doc_map.get("__uncategorized__", [])
data = {
"name": kb.name,
"description": kb.description,
"categories": tree_with_docs,
"uncategorized_documents": uncategorized,
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
)
@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")
category_tree = svc.get_category_tree(kb)
lines = [f"# {kb.name}", ""]
if kb.description:
lines.append(kb.description)
lines.append("")
lines.append("## 目录结构")
lines.append("")
lines.append("请通过以下目录链接访问对应内容:")
lines.append("")
def render_tree_md(nodes: list, level: int = 0):
for node in nodes:
prefix = " " * level
icon = "📁" if node["is_folder"] else "📄"
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
url = f"/k/{token}/category{node['path']}"
lines.append(f"{prefix}- {icon} [{node['name']}]({url}){doc_count}")
if node.get("children"):
render_tree_md(node["children"], level + 1)
render_tree_md(category_tree)
lines.append("")
lines.append("---")
lines.append(f"访问各目录链接查看具体文档。如需搜索:/k/{token}/search?q=关键词")
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")
category_tree = svc.get_category_tree(kb)
lines = [kb.name, "=" * len(kb.name), ""]
if kb.description:
lines.append(kb.description)
lines.append("")
lines.append("目录结构:")
lines.append("")
def render_tree_txt(nodes: list, level: int = 0):
for node in nodes:
prefix = " " * level
icon = "[文件夹]" if node["is_folder"] else "[文档]"
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
url = f"/k/{token}/category{node['path']}"
lines.append(f"{prefix}{icon} {node['name']}{doc_count}")
lines.append(f"{prefix}{url}")
if node.get("children"):
render_tree_txt(node["children"], level + 1)
render_tree_txt(category_tree)
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
@router.get("/{token}")
def kb_index_html(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""知识库完整内容(HTML)- 按目录分组显示所有文档。
AI 直接读取此页面即可获取全部内容,无需点击。
人类用户可通过左侧目录快速跳转到对应章节。
"""
_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)
docs, _ = svc.list_documents(kb)
# 按 category_id 分组
doc_map: dict[str, list] = {}
for doc in docs:
cat_id = doc.category_id or "__uncategorized__"
if cat_id not in doc_map:
doc_map[cat_id] = []
doc_map[cat_id].append(doc)
# 递归渲染目录 + 文档(按目录顺序)
section_counter = [0]
def render_section(nodes: list, level: int = 0) -> tuple[str, str]:
"""返回 (内容HTML, 目录HTML)"""
content_html = ""
toc_html = ""
for node in nodes:
section_counter[0] += 1
section_id = f"section-{section_counter[0]}"
icon = "📁" if node["is_folder"] else "📄"
heading_tag = "h2" if level == 0 else "h3" if level == 1 else "h4"
indent = " " * level
# 目录项
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
toc_html += f'{indent}<div class="toc-item" style="padding-left: {level * 16}px">'
toc_html += f'<a href="#{section_id}">{icon} {node["name"]}</a>{doc_count}</div>\n'
# 内容标题
content_html += f'<{heading_tag} id="{section_id}">{icon} {node["name"]}</{heading_tag}>\n'
# 该目录下的文档
cat_docs = doc_map.get(node["id"], [])
for doc in cat_docs:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_url = f"/k/{token}/doc/{doc_token}"
summary = doc.content_summary or ""
keywords = doc.keywords or ""
status_badge = f' <span style="color:#e67e22;font-size:0.8em">[{doc.status}]</span>' if doc.status != "READY" else ""
content_html += f'<div class="doc-item">\n'
content_html += f' <h4><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</h4>\n'
content_html += f' <p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>\n'
if summary:
content_html += f' <p class="summary">{summary}</p>\n'
if keywords:
content_html += f' <p class="keywords">关键词:{keywords}</p>\n'
content_html += f'</div>\n'
# 子目录
if node.get("children"):
child_content, child_toc = render_section(node["children"], level + 1)
content_html += child_content
toc_html += child_toc
return content_html, toc_html
all_content, all_toc = render_section(category_tree)
# 未分类文档
uncategorized = doc_map.get("__uncategorized__", [])
if uncategorized:
all_content += '<h2 id="uncategorized">📄 未分类文档</h2>\n'
for doc in uncategorized:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_url = f"/k/{token}/doc/{doc_token}"
all_content += f'<div class="doc-item"><h4><a href="{doc_url}">{doc.title or doc.original_filename}</a></h4></div>\n'
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{kb.name}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
:root {{ --font-size: 15px; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: var(--font-size); line-height: 1.8; color: #333; }}
/* 手机端:单列布局 */
.container {{ display: flex; min-height: 100vh; }}
.sidebar {{ width: 260px; background: #f8f9fa; border-right: 1px solid #e4e7ed; padding: 20px; overflow-y: auto; position: fixed; height: 100vh; }}
.main {{ flex: 1; padding: 20px 30px; margin-left: 260px; max-width: 900px; }}
.sidebar h2 {{ font-size: 16px; margin-bottom: 15px; color: #333; }}
.toc-item {{ padding: 6px 8px; border-radius: 4px; font-size: 14px; }}
.toc-item:hover {{ background: #e8e8e8; }}
.toc-item a {{ color: #333; text-decoration: none; }}
.toc-item a:hover {{ color: #409eff; }}
h1 {{ font-size: 24px; color: #333; border-bottom: 2px solid #409eff; padding-bottom: 10px; margin-bottom: 20px; }}
h2 {{ font-size: 20px; color: #333; margin: 30px 0 15px; padding-bottom: 8px; border-bottom: 1px solid #eee; }}
h3 {{ font-size: 18px; color: #333; margin: 25px 0 10px; }}
h4 {{ font-size: 16px; color: #333; margin: 15px 0 8px; }}
.doc-item {{ padding: 12px 0; border-bottom: 1px solid #f0f0f0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }}
.doc-item a:hover {{ text-decoration: underline; }}
.meta {{ color: #888; font-size: 0.9em; margin: 4px 0; }}
.summary {{ color: #555; font-size: 0.95em; margin: 6px 0; }}
.keywords {{ color: #888; font-size: 0.85em; }}
.footer {{ margin-top: 40px; padding: 20px 0; border-top: 1px solid #eee; color: #999; font-size: 0.9em; }}
/* 字体大小控制 */
.font-controls {{ position: fixed; bottom: 20px; right: 20px; background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 8px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }}
.font-controls button {{ width: 36px; height: 36px; border: 1px solid #ddd; border-radius: 4px; background: #fff; cursor: pointer; font-size: 16px; }}
.font-controls button:hover {{ background: #f5f5f5; }}
/* 手机端适配 */
@media (max-width: 768px) {{
.sidebar {{ display: none; }}
.main {{ margin-left: 0; padding: 15px; }}
h1 {{ font-size: 20px; }}
h2 {{ font-size: 18px; }}
h3 {{ font-size: 16px; }}
h4 {{ font-size: 15px; }}
.font-controls {{ bottom: 10px; right: 10px; }}
/* 手机端显示目录按钮 */
.mobile-toc {{ display: block; }}
.sidebar.mobile-show {{ display: block; position: fixed; top: 0; left: 0; width: 80%; max-width: 300px; z-index: 200; box-shadow: 2px 0 10px rgba(0,0,0,0.2); }}
.sidebar.mobile-show ~ .overlay {{ display: block; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 199; }}
}}
@media (min-width: 769px) {{
.mobile-toc {{ display: none; }}
.overlay {{ display: none; }}
}}
</style>
</head>
<body>
<div class="container">
<aside class="sidebar" id="sidebar">
<h2>📚 目录</h2>
{all_toc}
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #ddd;">
<a href="/k/{token}/search?q=" style="color: #0066cc; font-size: 14px;">🔍 搜索文档</a>
</div>
</aside>
<div class="overlay" id="overlay" onclick="document.getElementById('sidebar').classList.remove('mobile-show');this.style.display='none'"></div>
<main class="main">
<button class="mobile-toc" onclick="document.getElementById('sidebar').classList.add('mobile-show');document.getElementById('overlay').style.display='block'" style="margin-bottom: 15px; padding: 8px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer;">📚 目录</button>
<h1>{kb.name}</h1>
{"<p style='color:#666;margin-bottom:20px'>" + kb.description + "</p>" if kb.description else ""}
{all_content}
<div class="footer">
<p>本页为 AI 可读知识库页面,包含全部文档内容。</p>
<p><a href="/k/{token}.json" style="color:#0066cc">JSON 格式</a> | <a href="/k/{token}.md" style="color:#0066cc">Markdown 格式</a> | <a href="/k/{token}.txt" style="color:#0066cc">纯文本格式</a></p>
</div>
</main>
</div>
<!-- 字体大小控制 -->
<div class="font-controls">
<button onclick="changeFontSize(-1)" title="缩小字体">A-</button>
<button onclick="changeFontSize(1)" title="放大字体">A+</button>
</div>
<script>
function changeFontSize(delta) {{
const root = document.documentElement;
const current = parseInt(getComputedStyle(root).getPropertyValue('--font-size'));
const newSize = Math.max(12, Math.min(24, current + delta));
root.style.setProperty('--font-size', newSize + 'px');
localStorage.setItem('kb-font-size', newSize);
}}
// 恢复保存的字体大小
const saved = localStorage.getItem('kb-font-size');
if (saved) document.documentElement.style.setProperty('--font-size', saved + 'px');
</script>
</body>
</html>"""
return HTMLResponse(content=html)
# ============================================================
# 目录页面 - 显示某个目录下的文档
# ============================================================
@router.get("/{token}/category/{path:path}.json")
def category_json(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""目录下的文档列表(JSON)。
path 格式:01公司层/公司基本信息(不含前后斜杠)
"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
# 规范化路径
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}.json", request, req_type="category_json")
# 获取该目录下的文档
docs, total = svc.list_documents(kb, category_id=cat.id)
doc_list = []
for doc in docs:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
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,
"url": f"/k/{token}/doc/{doc_token}",
})
data = {
"category": cat.name,
"category_path": cat.path,
"document_count": total,
"documents": doc_list,
"back_to_index": f"/k/{token}.json",
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
)
@router.get("/{token}/category/{path:path}.md")
def category_markdown(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""目录下的文档列表(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}.md", request, req_type="category_md")
docs, _ = svc.list_documents(kb, category_id=cat.id)
lines = [f"# {cat.name}", ""]
lines.append(f"知识库:{kb.name}")
lines.append("")
if docs:
lines.append("## 文档列表")
lines.append("")
for doc in docs:
title = doc.title or doc.original_filename
lines.append(f"### {title}")
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("")
else:
lines.append("暂无文档。")
return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
@router.get("/{token}/category/{path:path}.txt")
def category_text(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""目录下的文档列表(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}.txt", request, req_type="category_txt")
docs, _ = svc.list_documents(kb, category_id=cat.id)
lines = [cat.name, "=" * len(cat.name), ""]
lines.append(f"知识库:{kb.name}")
lines.append("")
if docs:
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("")
else:
lines.append("暂无文档。")
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
@router.get("/{token}/category/{path:path}")
def category_html(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""目录下的文档列表(HTML)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}", request, req_type="category_html")
docs, total = svc.list_documents(kb, category_id=cat.id)
doc_rows = ""
for doc in docs:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_url = f"/k/{token}/doc/{doc_token}"
keywords_html = f'<p class="keywords">关键词:{doc.keywords}</p>' 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>
"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{cat.name} - {kb.name}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
h1 {{ color: #333; }}
.breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
.breadcrumb a {{ color: #0066cc; text-decoration: none; }}
.breadcrumb a:hover {{ text-decoration: underline; }}
.doc-item {{ border-bottom: 1px solid #eee; padding: 20px 0; }}
.doc-item h3 {{ margin: 0 0 8px 0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
.doc-item a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 14px; margin: 5px 0; }}
.summary {{ color: #444; font-size: 14px; margin: 8px 0; }}
.keywords {{ color: #888; font-size: 13px; }}
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
.footer a {{ color: #0066cc; }}
</style>
</head>
<body>
<div class="breadcrumb">
<a href="/k/{token}">📚 {kb.name}</a> &gt; {cat.name}
</div>
<h1>{cat.name}</h1>
<p style="color: #666;">共 {total} 篇文档</p>
{doc_rows if doc_rows else "<p style='color: #999;'>该目录暂无文档。</p>"}
<div class="footer">
<p><a href="/k/{token}">← 返回目录索引</a></p>
<p>This is an AI-readable document list. Click document links to view content.</p>
<p>本页为 AI 可读文档列表。请点击文档链接查看具体内容。</p>
</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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
return PlainTextResponse(content=svc.get_document_content(doc), 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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
content = svc.get_document_content(doc)
text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain")
@router.get("/{token}/doc/{doc_token}")
def doc_page_html(
token: str,
doc_token: str = PathParam(pattern=r"^[A-Za-z0-9_-]+$"),
request: Request = None,
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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
content = svc.get_document_content(doc)
html_content = _markdown_to_html(content)
# 获取文档所属目录
category_name = ""
category_path = ""
if doc.category_id:
cat = db.get(DocumentCategory, doc.category_id)
if cat:
category_name = cat.name
category_path = cat.path
keywords_html = f"<p><strong>关键词:</strong>{doc.keywords}</p>" if doc.keywords else ""
breadcrumb = f'<a href="/k/{token}">📚 {kb.name}</a>'
if category_name:
breadcrumb += f' &gt; <a href="/k/{token}/category{category_path}">{category_name}</a>'
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: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
h1 {{ color: #333; }}
.breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
.breadcrumb a {{ color: #0066cc; text-decoration: none; }}
.breadcrumb a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 14px; 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; }}
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
.footer a {{ color: #0066cc; }}
</style>
</head>
<body>
<div class="breadcrumb">{breadcrumb} &gt; {doc.title or doc.original_filename}</div>
<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="footer">
<p><a href="/k/{token}">← 返回目录索引</a>{" | <a href='/k/" + token + "/category" + category_path + "'>← 返回" + category_name + "</a>" if category_name else ""}</p>
</div>
</body>
</html>"""
return HTMLResponse(content=html)
# ============================================================
# 搜索
# ============================================================
@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)
_log_access(db, kb.id, f"/k/{token}/search?q={q}", request, req_type="search")
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 ''}"
category_path = item.get('category_path', '')
category_badge = f' <span style="color:#999;font-size:12px">[{category_path}]</span>' if category_path else ""
result_items += f"""
<div class="result-item">
<h3><a href="{doc_url}">{item['title']}</a>{category_badge}</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: 900px; margin: 0 auto; padding: 30px; line-height: 1.6; }}
.result-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.result-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
.meta {{ color: #666; font-size: 14px; }}
</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 转换(安全处理)。"""
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)
html = re.sub(r"\n\n+", "</p><p>", html)
html = f"<p>{html}</p>"
return html