3
This commit is contained in:
@@ -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("&", "&").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
|
||||
Reference in New Issue
Block a user