871 lines
32 KiB
Python
871 lines
32 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 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"])
|
|
|
|
|
|
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> > {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' > <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} > {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("&", "&").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)
|
|
html = re.sub(r"\n\n+", "</p><p>", html)
|
|
html = f"<p>{html}</p>"
|
|
return html
|