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
+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