Files
amb_rag/backend/app/public/routes.py
T
2026-09-01 14:49:07 +08:00

449 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""公共 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.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 '<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)
_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,
})
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)
_log_access(db, kb.id, f"/k/{token}", request, req_type="html")
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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
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)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
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)
_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 ''}"
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