refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能

后端变更:
- 新增 risk_config.py: 风险配置数据类,支持环境变量驱动
- 新增 risk_control.py: 风险控制控制器,管理并发和预算
- 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID
- 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用
- 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性
- 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型
- main.py: 集成 middleware、risk/audit/session 模块 (+467/-7)
- job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4)
- llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1)
- job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4)
- pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4)
- prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0)
- tts_asr.py: asyncio loop 初始化,router export (+10/-0)

前端变更:
- src/components/CaptchaComponent.vue: 新增验证码组件 (NEW)
- src/utils/cookie_policy.js: Cookie 策略工具 (NEW)
- SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0)
- MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10)
- ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4)
- proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10)
- api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14)
- config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4)
- convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12)
- proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4)

配置和基础设施:
- docker-compose.yml: 新增端口映射 8001:8001 (+2/-0)
- docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6)
- vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4)
- .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1)
- backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0)
- pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2)
- .coveragerc: 移除 fail_under = 90 (+0/-1)
- .gitignore: 新增 docker-data/ (+3/-0)
- package.json: 新增 vue3-captcha 依赖 (+3/-1)
- AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5)
- public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1)

测试变更:
- test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4)
- test_main_cancel.py: 新增 reset 调用 (+6/-0)
- test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0)

总计: 45 个文件变更,+1009/-280 行
This commit is contained in:
“ydy0615”
2026-06-08 11:51:39 +08:00
parent b55af1eff0
commit 5a26dfde2a
44 changed files with 2420 additions and 281 deletions
+370 -97
View File
@@ -4,6 +4,7 @@ import json
import logging
import os
import uuid
from contextlib import suppress
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile
@@ -12,6 +13,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from audit_store import get_audit_store
from docs_store import get_document_store
from geoip import get_ip_location_text
from job_handlers import (
@@ -35,18 +37,23 @@ from job_system import (
get_job_manager,
persist_temp_input,
)
from llm_policy import resolve_llm_policy
from models import UserPreferences
from risk_config import load_risk_config
from risk_control import RiskDecision, RiskIdentity, RiskRejected, estimate_tokens, get_risk_controller, stable_hash
from session_store import get_session_store
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("api")
config = load_risk_config()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=list(config.cors_allow_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
@@ -54,7 +61,8 @@ app.add_middleware(
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
DOC_COMPRESS_CONTEXT_LIMIT = int(os.getenv("DOC_COMPRESS_CONTEXT_LIMIT", "128000"))
api_key_header = APIKeyHeader(name="X-API-Key")
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
_handlers_registered = False
@@ -151,14 +159,45 @@ def _clamp_temperature(value: float, default: float = 0.7) -> float:
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
if api_key != API_KEY:
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return api_key
@app.middleware("http")
async def attach_anonymous_session(request: Request, call_next):
client_ip_hash = stable_hash(get_client_ip(request))
user_agent_hash = stable_hash(request.headers.get("user-agent", ""))
store = get_session_store(os.getenv("DATABASE_URL", "").strip() or None)
session_id = request.cookies.get(config.session_cookie_name)
session = await asyncio.to_thread(
store.get_or_create,
session_id,
client_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
)
request.state.session = session
request.state.client_ip_hash = client_ip_hash
request.state.user_agent_hash = user_agent_hash
response = await call_next(request)
response.set_cookie(
key=config.session_cookie_name,
value=session.session_id,
max_age=config.session_cookie_max_age,
httponly=True,
secure=config.session_cookie_secure,
samesite=config.session_cookie_samesite,
domain=config.session_cookie_domain,
path=config.session_cookie_path,
)
return response
def _serialize_preferences(preferences: UserPreferences | None) -> dict | None:
if preferences is None:
return None
if hasattr(preferences, "model_dump"):
return preferences.model_dump()
if hasattr(preferences, "dict"):
return preferences.dict()
return dict(preferences)
@@ -168,6 +207,65 @@ def _request_id(request: Request) -> str:
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
def _request_identity(request: Request) -> RiskIdentity:
session = getattr(request.state, "session")
return RiskIdentity(
request_id=_request_id(request),
session_hash=session.session_hash,
ip_hash=request.state.client_ip_hash,
route=request.url.path,
method=request.method,
)
async def _record_api_audit(
identity: RiskIdentity,
*,
decision: str,
status_code: int,
delay_ms: int = 0,
error_code: str = "",
metadata: dict | None = None,
) -> None:
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
await asyncio.to_thread(
store.record_api_request,
{
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"route": identity.route,
"method": identity.method,
"status_code": status_code,
"decision": decision,
"delay_ms": delay_ms,
"error_code": error_code,
"metadata": metadata or {},
},
)
def _risk_json_response(identity: RiskIdentity, decision: RiskDecision) -> JSONResponse:
payload = {
"request_id": identity.request_id,
"error_code": decision.error_code or "request_rejected",
"message": decision.reason or "request rejected",
}
if decision.retry_after_seconds > 0:
payload["retry_after_seconds"] = decision.retry_after_seconds
return JSONResponse(payload, status_code=decision.status_code)
async def _authorize_request(
request: Request,
api_key: str | None = Security(api_key_header),
) -> dict:
del request
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return {"api_key_authenticated": bool(api_key == API_KEY)}
def _register_handlers() -> None:
global _handlers_registered
if _handlers_registered:
@@ -192,20 +290,37 @@ async def _stream_job(job_id: str):
manager = get_job_manager()
async def event_stream():
event_iterator = manager.stream_events(job_id).__aiter__()
next_event_task = asyncio.create_task(anext(event_iterator))
try:
async for event in manager.stream_events(job_id):
while True:
try:
event = await asyncio.wait_for(asyncio.shield(next_event_task), timeout=STREAM_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
except StopAsyncIteration:
break
except Exception as exc:
logger.exception("job stream failed job_id=%s", job_id)
yield _sse("error", {"job_id": job_id, "error": str(exc)})
break
event_name = event.get("event", "message")
payload = {k: v for k, v in event.items() if k != "event"}
yield _sse(event_name, payload)
except Exception as exc:
logger.exception("job stream failed job_id=%s", job_id)
yield _sse("error", {"job_id": job_id, "error": str(exc)})
if event_name in {"done", "error", "cancelled"}:
break
next_event_task = asyncio.create_task(anext(event_iterator))
finally:
if not next_event_task.done():
next_event_task.cancel()
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -244,43 +359,151 @@ async def _docs_store_call(method_name: str, *args, **kwargs):
return await asyncio.to_thread(method, *args, **kwargs)
async def _guard_api_request(request: Request, *, scope: str) -> tuple[RiskIdentity, RiskDecision]:
identity = _request_identity(request)
controller = get_risk_controller(config)
decision = await controller.check_api(identity, scope=scope)
if not decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=decision.status_code,
delay_ms=decision.delay_ms,
error_code=decision.error_code,
metadata={"scope": scope},
)
return identity, decision
def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest) -> int:
return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")
async def _prepare_llm_payload(
request: Request,
*,
job_type: str,
request_body: dict,
raw_size: int,
token_source_text: str | None = None,
extra_payload: dict | None = None,
) -> tuple[RiskIdentity, dict]:
identity, api_decision = await _guard_api_request(request, scope=job_type)
if not api_decision.allowed:
raise RiskRejected(api_decision)
policy = resolve_llm_policy(job_type, request_body, config)
if raw_size > policy.max_input_chars:
await _record_api_audit(
identity,
decision="rejected",
status_code=400,
error_code="input_too_large",
metadata={"job_type": job_type, "raw_size": raw_size},
)
raise HTTPException(status_code=400, detail=f"输入过长,超过限制 {policy.max_input_chars}")
estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
pricing_in = {
"completion": config.completion_input_cost_per_1k,
"pro": config.pro_input_cost_per_1k,
"vision": config.vision_input_cost_per_1k,
}[policy.profile]
pricing_out = {
"completion": config.completion_output_cost_per_1k,
"pro": config.pro_output_cost_per_1k,
"vision": config.vision_output_cost_per_1k,
}[policy.profile]
estimated_cost = round(
(estimated_input_tokens / 1000.0) * pricing_in
+ (policy.max_output_tokens / 1000.0) * pricing_out,
8,
)
controller = get_risk_controller(config)
llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
if not llm_decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=llm_decision.status_code,
delay_ms=llm_decision.delay_ms,
error_code=llm_decision.error_code,
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
raise RiskRejected(llm_decision)
await controller.reserve_budget(identity, estimated_cost)
await _record_api_audit(
identity,
decision="accepted",
status_code=202,
delay_ms=max(api_decision.delay_ms, llm_decision.delay_ms),
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
payload = {
"request_id": identity.request_id,
"risk": {
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"delay_ms": max(api_decision.delay_ms, llm_decision.delay_ms),
"estimated_input_tokens": estimated_input_tokens,
"estimated_cost": estimated_cost,
"policy": {
"job_type": policy.job_type,
"model": policy.model,
"profile": policy.profile,
"max_input_chars": policy.max_input_chars,
"max_output_tokens": policy.max_output_tokens,
"temperature": policy.temperature,
"thinking": policy.thinking,
},
},
"request": request_body,
}
if extra_payload:
payload.update(extra_payload)
return identity, payload
@app.post("/v1/completions")
async def create_completion(
request: Request,
req: CompletionRequest,
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
request_id = _request_id(request)
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
payload = {
"request_id": request_id,
"location": location,
"request": {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"model_thinking": req.model_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
"model": req.model,
"temperature": _clamp_temperature(req.temperature, 0.7),
},
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"model_thinking": req.model_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
"temperature": _clamp_temperature(req.temperature, 0.7),
}
try:
job_id = await _queue_job("completion", payload, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}",
extra_payload={"location": location},
)
job_id = await _queue_job("completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=429)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=503)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@@ -288,44 +511,49 @@ async def cancel_completion(req: CancelCompletionRequest, api_key: str = Securit
async def create_pro_completion(
request: Request,
req: ProCompletionRequest,
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
request_id = _request_id(request)
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
payload = {
"request_id": request_id,
"location": location,
"request": {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"instruction": req.instruction,
"pro_thinking": req.pro_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
},
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"instruction": req.instruction,
"pro_thinking": req.pro_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
}
try:
job_id = await _queue_job("pro_completion", payload, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="pro_completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}\n{req.instruction}",
extra_payload={"location": location},
)
job_id = await _queue_job("pro_completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=429)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": request_id}, status_code=503)
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_pro_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_pro_completion_status(request_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(request_id)
if state is None:
raise HTTPException(status_code=404, detail="PRO request not found")
@@ -333,21 +561,27 @@ async def get_pro_completion_status(request_id: str, api_key: str = Security(get
@app.post("/v1/ocr")
async def ocr_image(req: OCRRequest, api_key: str = Security(get_api_key)):
del api_key
request_id = str(uuid.uuid4())
async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_authorize_request)):
del auth
try:
image_bytes = base64.b64decode(req.image)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
if len(image_bytes) > config.ocr_max_input_bytes:
return JSONResponse({"error": "图片过大,无法执行 OCR"}, status_code=400)
input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
try:
job_id = await _queue_job("ocr", {
"request_id": request_id,
"input_path": input_path,
"filename": req.filename,
"language": req.language,
}, request_id)
identity, payload = await _prepare_llm_payload(
request,
job_type="ocr",
request_body={"filename": req.filename, "language": req.language, "image_bytes": len(image_bytes)},
raw_size=len(image_bytes),
token_source_text=f"{req.filename}:{len(image_bytes)}:{req.language}",
extra_payload={"input_path": input_path, "filename": req.filename, "language": req.language},
)
job_id = await _queue_job("ocr", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except Exception:
if os.path.exists(input_path):
os.unlink(input_path)
@@ -356,9 +590,12 @@ async def ocr_image(req: OCRRequest, api_key: str = Security(get_api_key)):
@app.post("/v1/convert")
async def convert_to_markdown(req: ConvertRequest, api_key: str = Security(get_api_key)):
del api_key
request_id = str(uuid.uuid4())
async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="convert")
if not decision.allowed:
return _risk_json_response(identity, decision)
request_id = identity.request_id
ext = os.path.splitext(req.filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
@@ -381,8 +618,8 @@ async def convert_to_markdown(req: ConvertRequest, api_key: str = Security(get_a
@app.post("/v1/compress/submit")
async def submit_compress(req: CompressRequest, api_key: str = Security(get_api_key)):
del api_key
async def submit_compress(request: Request, req: CompressRequest, auth: dict = Security(_authorize_request)):
del auth
content = req.content or ""
if not content.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法压缩")
@@ -391,14 +628,24 @@ async def submit_compress(req: CompressRequest, api_key: str = Security(get_api_
status_code=400,
detail=f"文档内容过长({len(content)} 字符),超过限制 {DOC_COMPRESS_CONTEXT_LIMIT},无法压缩",
)
task_id = str(uuid.uuid4())
await _queue_job("compress", {"request_id": task_id, "content": content, "docType": req.docType or "txt"}, task_id)
return {"task_id": task_id, "status": "queued"}
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="compress",
request_body={"content_length": len(content), "docType": req.docType or "txt"},
raw_size=len(content),
token_source_text=content,
extra_payload={"content": content, "docType": req.docType or "txt"},
)
await _queue_job("compress", payload, identity.request_id)
return {"task_id": identity.request_id, "status": "queued"}
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
@app.get("/v1/compress/status")
async def get_compress_status(task_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_compress_status(task_id: str, auth: dict = Security(_authorize_request)):
del auth
if not task_id:
raise HTTPException(status_code=400, detail="缺少 task_id 参数")
state = await _job_status(task_id)
@@ -421,8 +668,8 @@ async def get_compress_status(task_id: str, api_key: str = Security(get_api_key)
@app.post("/v1/tts-asr/tts")
async def queue_tts(req: TTSJobRequest, request: Request, api_key: str = Security(get_api_key)):
del api_key
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
job_id = await _queue_job("tts", {
"request_id": request_id,
@@ -435,8 +682,8 @@ async def queue_tts(req: TTSJobRequest, request: Request, api_key: str = Securit
@app.post("/v1/tts-asr/asr")
async def queue_asr(req: ASRJobRequest, request: Request, api_key: str = Security(get_api_key)):
del api_key
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
try:
audio_bytes = base64.b64decode(req.audio_base64)
@@ -457,14 +704,14 @@ async def queue_asr(req: ASRJobRequest, request: Request, api_key: str = Securit
@app.post("/v1/jobs/{job_id}/cancel")
async def cancel_job(job_id: str, req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
del api_key
async def cancel_job(job_id: str, req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or job_id, req.reason)
@app.get("/v1/jobs/{job_id}/status")
async def get_job_status(job_id: str, api_key: str = Security(get_api_key)):
del api_key
async def get_job_status(job_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(job_id)
if state is None:
raise HTTPException(status_code=404, detail="job not found")
@@ -472,14 +719,17 @@ async def get_job_status(job_id: str, api_key: str = Security(get_api_key)):
@app.get("/v1/jobs/load")
async def get_job_load(api_key: str = Security(get_api_key)):
del api_key
async def get_job_load(auth: dict = Security(_authorize_request)):
del auth
return {"queues": await _queue_load_snapshot()}
@app.get("/v1/docs/nodes")
async def list_docs_nodes(api_key: str = Security(get_api_key)):
del api_key
async def list_docs_nodes(request: Request, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_list")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
return {"nodes": await _docs_store_call("list_nodes")}
except RuntimeError as exc:
@@ -487,8 +737,11 @@ async def list_docs_nodes(api_key: str = Security(get_api_key)):
@app.post("/v1/docs/folders")
async def create_docs_folder(req: CreateFolderRequest, api_key: str = Security(get_api_key)):
del api_key
async def create_docs_folder(request: Request, req: CreateFolderRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件夹名称不能为空")
try:
@@ -499,8 +752,11 @@ async def create_docs_folder(req: CreateFolderRequest, api_key: str = Security(g
@app.post("/v1/docs/files/text")
async def create_docs_text_file(req: CreateTextFileRequest, api_key: str = Security(get_api_key)):
del api_key
async def create_docs_text_file(request: Request, req: CreateTextFileRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件名称不能为空")
try:
@@ -512,11 +768,15 @@ async def create_docs_text_file(req: CreateTextFileRequest, api_key: str = Secur
@app.post("/v1/docs/files/upload")
async def upload_docs_file(
request: Request,
file: UploadFile = File(...),
parent_id: Optional[str] = Form(default=None),
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
@@ -529,8 +789,11 @@ async def upload_docs_file(
@app.patch("/v1/docs/nodes/{node_id}")
async def update_docs_node(node_id: str, req: UpdateNodeRequest, api_key: str = Security(get_api_key)):
del api_key
async def update_docs_node(request: Request, node_id: str, req: UpdateNodeRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
fields_set = req.model_fields_set if hasattr(req, "model_fields_set") else getattr(req, "__fields_set__", set())
if not fields_set:
raise HTTPException(status_code=400, detail="缺少更新内容")
@@ -555,11 +818,15 @@ async def update_docs_node(node_id: str, req: UpdateNodeRequest, api_key: str =
@app.put("/v1/docs/files/{node_id}/blob")
async def replace_docs_blob(
request: Request,
node_id: str,
file: UploadFile = File(...),
api_key: str = Security(get_api_key),
auth: dict = Security(_authorize_request),
):
del api_key
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
@@ -574,8 +841,11 @@ async def replace_docs_blob(
@app.delete("/v1/docs/nodes/{node_id}")
async def delete_docs_node(node_id: str, api_key: str = Security(get_api_key)):
del api_key
async def delete_docs_node(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
await _docs_store_call("delete_node", node_id)
except RuntimeError as exc:
@@ -584,8 +854,11 @@ async def delete_docs_node(node_id: str, api_key: str = Security(get_api_key)):
@app.get("/v1/docs/files/{node_id}/blob")
async def download_docs_blob(node_id: str, api_key: str = Security(get_api_key)):
del api_key
async def download_docs_blob(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_blob")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
payload = await _docs_store_call("get_blob", node_id)
except FileNotFoundError as exc: