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:
+370
-97
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user