425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""公共 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 ""
|
||
status_badge = ""
|
||
if doc.status != "READY":
|
||
status_badge = f' <span class="status-badge">[{doc.status}]</span>'
|
||
doc_rows += f"""
|
||
<div class="doc-item">
|
||
<h3><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</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; }}
|
||
.status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }}
|
||
.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("&", "&").replace("<", "<").replace(">", ">")
|
||
|
||
# 标题
|
||
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
|