fix: 核心 bug 修复 - API Key 加密/解密 + AI 调用 + 数据安全

- schema: 用 @field_serializer 掩码 api_key,不再直接修改 SQLAlchemy 对象
- API: 移除所有 model.api_key='***' 赋值,防止数据库被覆盖
- security: SHA256(SECRET_KEY) 派生合法 Fernet 32 字节密钥
- ai_adapter: CustomAdapter 改用 OpenAI-compatible messages 格式
- ai_adapter: trust_env=False 防止代理干扰,增加详细异常信息
- templates: bleach HTML 净化防止 XSS
- .env.example: CORS_ORIGINS 修复为 JSON 数组格式
- 端到端验证:单点测试 + 异步生成任务均通过
This commit is contained in:
zwt13703 2026-07-06 17:26:32 +08:00
parent aeff8753ec
commit 40b861cfcc
5 changed files with 52 additions and 21 deletions

View File

@ -2,9 +2,25 @@
POSTGRES_USER=docforge
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=docforge
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# Security (generate with: python -c "import secrets; print(secrets.token_urlsafe(32))")
# Redis
REDIS_URL=redis://localhost:6379/0
# Backend
SECRET_KEY=change-me-to-a-random-secret-key
DEFAULT_MODEL_ID=
# File Storage
STORAGE_ROOT=./storage
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# CORS (JSON array format)
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# AI (optional, can be configured in the web UI)
OPENAI_API_KEY=

View File

@ -24,7 +24,6 @@ async def create_model(data: AIModelCreate, db: AsyncSession = Depends(get_db)):
db.add(model)
await db.flush()
await db.refresh(model)
model.api_key = "***"
return model
@ -41,8 +40,6 @@ async def list_models(
query = query.offset(skip).limit(limit).order_by(AIModel.created_at.desc())
result = await db.execute(query)
models = result.scalars().all()
for m in models:
m.api_key = "***"
return models
@ -52,7 +49,6 @@ async def get_model(model_id: str, db: AsyncSession = Depends(get_db)):
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model.api_key = "***"
return model
@ -72,7 +68,6 @@ async def update_model(model_id: str, data: AIModelUpdate, db: AsyncSession = De
await db.flush()
await db.refresh(model)
model.api_key = "***"
return model
@ -95,5 +90,4 @@ async def toggle_model(model_id: str, data: AIModelToggle, db: AsyncSession = De
model.is_enabled = data.is_enabled
await db.flush()
await db.refresh(model)
model.api_key = "***"
return model

View File

@ -1,4 +1,5 @@
import uuid
import bleach
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
@ -29,8 +30,6 @@ ALLOWED_ATTRS = {
"table": ["style"],
}
import bleach
router = APIRouter(prefix="/templates", tags=["模板管理"])

View File

@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer
class AIModelCreate(BaseModel):
@ -32,7 +32,7 @@ class AIModelResponse(BaseModel):
name: str
provider: str
endpoint: str
api_key: str = Field(default="***")
api_key: str
extra_params: dict
is_enabled: bool
remark: str | None = None
@ -40,3 +40,7 @@ class AIModelResponse(BaseModel):
updated_at: datetime
model_config = {"from_attributes": True}
@field_serializer("api_key")
def mask_api_key(self, value: str) -> str:
return "***"

View File

@ -105,12 +105,16 @@ class CustomAdapter(AIAdapter):
if ref_content:
user_content = f"参考以下内容:\n{ref_content}\n\n任务:{prompt}"
messages = [{"role": "system", "content": "你是一个专业的文档内容生成助手。"}]
messages.append({"role": "user", "content": user_content})
body = {
"prompt": user_content,
"model": extra.get("model", "gpt-3.5-turbo"),
"messages": messages,
"max_tokens": extra.get("max_tokens", 2000),
"temperature": extra.get("temperature", 0.7),
}
body.update({k: v for k, v in extra.items() if k not in ("max_tokens", "temperature")})
body.update({k: v for k, v in extra.items() if k not in ("model", "messages", "max_tokens", "temperature")})
return {
"url": model_config["endpoint"],
@ -153,7 +157,12 @@ async def call_ai_model(model_config: dict, prompt: str, ref_content: str | None
timeout = model_config.get("extra_params", {}).get("timeout", 120)
async with httpx.AsyncClient(timeout=timeout) as client:
try:
async with httpx.AsyncClient(
timeout=timeout,
proxy=None,
trust_env=False,
) as client:
response = await client.post(
request["url"],
headers=request["headers"],
@ -161,3 +170,12 @@ async def call_ai_model(model_config: dict, prompt: str, ref_content: str | None
)
response.raise_for_status()
return adapter.parse_response(response.json())
except httpx.HTTPStatusError as e:
detail = e.response.text[:500] if e.response else str(e)
raise RuntimeError(f"AI 服务返回错误 ({e.response.status_code}): {detail}")
except httpx.TimeoutException:
raise RuntimeError("AI 调用超时")
except httpx.ConnectError as e:
raise RuntimeError(f"无法连接 AI 服务: {e}")
except Exception as e:
raise RuntimeError(f"AI 调用异常: {str(e)}")