baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
app/core/__init__.py
|
||||
"""
|
||||
80
backend/app/core/config.py
Normal file
80
backend/app/core/config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
app/core/config.py — 环境变量加载 + 启动校验
|
||||
必填项缺失则启动失败,不静默。
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ── 必填(缺失启动保护)───────────────────────────
|
||||
FERNET_KEY: str
|
||||
JWT_SECRET: str
|
||||
DATABASE_URL: str
|
||||
MONGO_URI: str
|
||||
REDIS_URL: str
|
||||
|
||||
# ── AI 提供商(不写死默认,可配置)──────────────────
|
||||
IMAGE_PROVIDER_PRIMARY: str = "gpt"
|
||||
IMAGE_PROVIDER_FALLBACK: str = "gemini"
|
||||
IMAGE_API_BASE: str = ""
|
||||
IMAGE_MODEL: str = "gpt-image-2"
|
||||
MODEL_IMAGE: str = "gpt-image-2" # 别名,与 IMAGE_MODEL 同义
|
||||
MODEL_TEXT: str = "claude-opus-4-8"
|
||||
MODEL_VISION: str = "" # 视觉模型(verify_real_image 脚本用)
|
||||
CLAUDE_API_URL: str = ""
|
||||
GEMINI_API_URL: str = ""
|
||||
|
||||
# ── 多 Provider Key(各人自录,测试/真实出图脚本用)──
|
||||
APIPORTS_BASE_URL: str = ""
|
||||
APIPORTS_KEY: str = ""
|
||||
CODEPROXY_BASE_URL: str = ""
|
||||
CODEPROXY_KEY: str = ""
|
||||
|
||||
# ── 应用配置 ──────────────────────────────────────
|
||||
APP_ENV: str = "development"
|
||||
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||
CELERY_BROKER_URL: str = ""
|
||||
CELERY_RESULT_BACKEND: str = ""
|
||||
|
||||
# ── 并发限制(可配置,不写死)─────────────────────
|
||||
MAX_CONCURRENT_TASKS_PER_USER: int = 2
|
||||
|
||||
# ── 文件存储路径 ──────────────────────────────────
|
||||
UPLOAD_BASE_PATH: str = "uploads/packages"
|
||||
# StaticFiles 实际服务的绝对目录(与 main.py app.mount("/uploads", .../uploads) 一致)。
|
||||
# 容器内 main.py 在 /app/main.py,故 uploads 绝对根 = /app/uploads。
|
||||
# 所有写盘/读盘统一锚定此根,避免 api(cwd=/app) 与 worker(cwd=/) 相对路径解析不一致。
|
||||
UPLOAD_ABS_ROOT: str = "/app/uploads"
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}
|
||||
|
||||
@field_validator("FERNET_KEY")
|
||||
@classmethod
|
||||
def fernet_key_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 32:
|
||||
raise ValueError("FERNET_KEY must be set and at least 32 chars")
|
||||
return v
|
||||
|
||||
@field_validator("JWT_SECRET")
|
||||
@classmethod
|
||||
def jwt_secret_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 32:
|
||||
raise ValueError("JWT_SECRET must be at least 32 characters")
|
||||
return v
|
||||
|
||||
def celery_broker(self) -> str:
|
||||
return self.CELERY_BROKER_URL or self.REDIS_URL
|
||||
|
||||
def celery_backend(self) -> str:
|
||||
return self.CELERY_RESULT_BACKEND or self.REDIS_URL
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""单例配置,启动时校验一次。"""
|
||||
return Settings()
|
||||
53
backend/app/core/database.py
Normal file
53
backend/app/core/database.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
app/core/database.py — SQLAlchemy 双库连接(MySQL + MongoDB)
|
||||
"""
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ── MySQL(主业务库)──────────────────────────────────
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=(settings.APP_ENV == "development"),
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有 ORM 模型的基类。"""
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI 依赖注入:获取 DB Session,用完自动关闭。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── MongoDB(只存 AI debug trace,业务不依赖)──────────
|
||||
_mongo_client: AsyncIOMotorClient | None = None
|
||||
|
||||
|
||||
def get_mongo_client() -> AsyncIOMotorClient:
|
||||
global _mongo_client
|
||||
if _mongo_client is None:
|
||||
_mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
|
||||
return _mongo_client
|
||||
|
||||
|
||||
def get_mongo_db():
|
||||
"""获取 MongoDB 数据库实例(clover_trace)。"""
|
||||
client = get_mongo_client()
|
||||
return client["clover_trace"]
|
||||
63
backend/app/core/response.py
Normal file
63
backend/app/core/response.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
app/core/response.py — 统一响应包络
|
||||
成功:{ "code": 0, "data": {...} }
|
||||
失败:{ "code": <错误码>, "message": "用户可读信息" }
|
||||
HTTP 状态码与业务 code 分离(契约§0)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.constants.enums import ErrorCode
|
||||
|
||||
|
||||
def ok(data: Any = None) -> dict:
|
||||
"""标准成功响应。"""
|
||||
return {"code": ErrorCode.SUCCESS, "data": data}
|
||||
|
||||
|
||||
def err(code: int, message: str) -> dict:
|
||||
"""标准失败响应体(不含 HTTP 状态,由调用方决定)。"""
|
||||
return {"code": code, "message": message}
|
||||
|
||||
|
||||
def paginate(items: list, total: int, page: int, page_size: int) -> dict:
|
||||
"""分页数据包装。"""
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
# ── 常用异常 ──────────────────────────────────────────────
|
||||
class CloverHTTPException(HTTPException):
|
||||
"""携带业务 code 的 HTTP 异常,供全局 handler 格式化。"""
|
||||
|
||||
def __init__(self, http_status: int, code: int, message: str):
|
||||
super().__init__(status_code=http_status, detail=message)
|
||||
self.biz_code = code
|
||||
self.biz_message = message
|
||||
|
||||
|
||||
def raise_not_found(message: str = "资源不存在") -> None:
|
||||
raise CloverHTTPException(404, ErrorCode.NOT_FOUND, message)
|
||||
|
||||
|
||||
def raise_forbidden(message: str = "无权限访问") -> None:
|
||||
raise CloverHTTPException(403, ErrorCode.FORBIDDEN, message)
|
||||
|
||||
|
||||
def raise_unauthorized(message: str = "未认证或 Token 失效") -> None:
|
||||
raise CloverHTTPException(401, ErrorCode.UNAUTHORIZED, message)
|
||||
|
||||
|
||||
def raise_business(message: str) -> None:
|
||||
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
||||
|
||||
|
||||
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
||||
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
||||
73
backend/app/core/security.py
Normal file
73
backend/app/core/security.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
app/core/security.py — JWT + Fernet 工具函数
|
||||
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# ── Fernet 加密(API Key 存取)──────────────────────────
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(settings.FERNET_KEY.encode())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_api_key(plain_key: str) -> str:
|
||||
"""加密 API Key,返回密文字符串。绝不打印 plain_key。"""
|
||||
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted_key: str) -> str:
|
||||
"""
|
||||
解密 API Key。只在 Celery worker 内部调用。
|
||||
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||
"""
|
||||
try:
|
||||
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||
except InvalidToken:
|
||||
logger.error("Fernet decrypt failed: invalid token (key may be rotated)")
|
||||
raise ValueError("API key decryption failed")
|
||||
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
workspace_id: int,
|
||||
role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"user_id": user_id,
|
||||
"current_workspace_id": workspace_id,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""验签并返回 payload。无效则抛 jwt.PyJWTError。"""
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
def mask_api_key(plain_key: str) -> str:
|
||||
"""只返回后4位(展示用),不暴露完整 key。"""
|
||||
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||
43
backend/app/core/sse_ticket.py
Normal file
43
backend/app/core/sse_ticket.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证
|
||||
倩倩姐2026-06-08拍板:SSE 认证不传 JWT,改传 ticket。
|
||||
ticket 存 Redis,TTL 60s,验一次即删,换不了其他接口。
|
||||
"""
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
TICKET_TTL_SECONDS = 60
|
||||
TICKET_KEY_PREFIX = "sse_ticket:"
|
||||
|
||||
|
||||
def _redis_key(ticket: str) -> str:
|
||||
return f"{TICKET_KEY_PREFIX}{ticket}"
|
||||
|
||||
|
||||
def issue_ticket(redis_client, task_id: int, workspace_id: int) -> str:
|
||||
"""
|
||||
签发一次性 SSE ticket,写入 Redis,TTL 60s。
|
||||
返回 ticket 字符串(32字节 hex,共64字符)。
|
||||
"""
|
||||
ticket = secrets.token_hex(32)
|
||||
value = f"{task_id}:{workspace_id}"
|
||||
redis_client.setex(_redis_key(ticket), TICKET_TTL_SECONDS, value)
|
||||
return ticket
|
||||
|
||||
|
||||
def consume_ticket(redis_client, ticket: str) -> Optional[tuple[int, int]]:
|
||||
"""
|
||||
验证并消费 ticket(用后即删,一次性)。
|
||||
成功返回 (task_id, workspace_id),失败返回 None。
|
||||
"""
|
||||
if not ticket:
|
||||
return None
|
||||
key = _redis_key(ticket)
|
||||
value = redis_client.getdel(key) # 原子取出并删除
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
task_id_str, ws_str = value.split(":", 1)
|
||||
return int(task_id_str), int(ws_str)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
Reference in New Issue
Block a user