c4b4a1e9c0
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m51s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m12s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m17s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m12s
AI Code Review / AI Code Review (pull_request) Successful in 7m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 11m34s
CI/CD Pipeline / Validate - Style (push) Successful in 12m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 14m46s
CI/CD Pipeline / Unit Tests (push) Failing after 21m58s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
## 背景 关联 #2246。Ditto LLM 情绪分析(#2245)已上线,但 5 项配置只能靠环境变量/改代码调整。本 PR 提供后台配置页,不改代码、不发版即可调优,保存后立即生效。 ## 改动文件 ### 后端 - `packages/domain/system_setting.py`(新):SystemSetting 实体 + bool/int/float/string/json 序列化反序列化 - `packages/adapters/sqlalchemy_impl/models.py`:新增 SystemSettingModel - `packages/adapters/sqlalchemy_impl/system_setting_repository.py`(新):Repository(get/list/upsert/delete) - `packages/application/system_config_service.py`(新):SystemConfigService 进程内缓存,set_config 后失效并联动 reset Ditto 单例 - `packages/application/ditto_emotion_service.py`:配置优先读 system_config、env 兜底;新增 reset_ditto_emotion_service(含 LRU cache_clear) - `packages/application/ditto_service.py`:blend_frames 同上;新增 reset_ditto_client - `apps/api/app/api/routes/admin/__init__.py`、`admin/ditto_emotion.py`(新):GET/PUT `/config`、POST `/config/test`,X-API-Key 鉴权 + 5 项白名单 - `apps/api/app/api/router.py`:注册新路由 - `alembic/versions/106_system_settings.py`(新):正式建表(IF NOT EXISTS,staging 手工表幂等) ### 前端 - `apps/web/src/pages/admin/DittoEmotionConfig.tsx`(新):Key 弹窗(localStorage)、5 表单项、保存/测试/重置默认 - `apps/web/src/api/admin/dittoEmotion.ts`(新):独立 axios admin client(X-API-Key,不走 Bearer token) - `apps/web/src/router/appRoutes.tsx`:`/app/admin` 重定向到配置页;未动用户端 `/app/*` ## 测试 - 新增 `tests/unit/test_system_config.py`:**27 passed**(默认值/DB覆盖/白名单拒绝/单例 reset/校验) - `tests/unit/test_viral_video_v8_bugs.py`:**39 passed**(无回归) - 全量 `tests/unit`:16704 passed,仅 2 个 storage `sign_url(slash_safe=True)` 失败——本地容器 oss2 版本高于 requirements 锁定版导致,与本 PR 无关,CI 固定版本不受影响 - 前端 `tsc --noEmit`:零错误 ## 截图 **1. 首次进入:输入后台 X-API-Key(仅存本机 localStorage)**  **2. 配置页:5 项配置 + 立即生效 + 配置测试**  ## 严禁项确认 未改 lipsync_ditto 调用流程 / ditto 核心逻辑 / generate 重试逻辑 / config 字段名默认值;未建新业务表(用 system_settings);未动用户端;API Key 不硬编码;worker 不直接 HTTP 读配置。
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""系统配置领域实体 — #2246.
|
||
|
||
承载 setting_type(bool/int/float/string/json)及 setting_value 的
|
||
序列化/反序列化规则;与 DB、框架无关。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
SETTING_TYPE_BOOL = "bool"
|
||
SETTING_TYPE_INT = "int"
|
||
SETTING_TYPE_FLOAT = "float"
|
||
SETTING_TYPE_STRING = "string"
|
||
SETTING_TYPE_JSON = "json"
|
||
|
||
VALID_SETTING_TYPES = {
|
||
SETTING_TYPE_BOOL,
|
||
SETTING_TYPE_INT,
|
||
SETTING_TYPE_FLOAT,
|
||
SETTING_TYPE_STRING,
|
||
SETTING_TYPE_JSON,
|
||
}
|
||
|
||
|
||
class SystemSettingError(ValueError):
|
||
"""系统配置类型或序列化错误."""
|
||
|
||
|
||
def infer_setting_type(value: Any) -> str:
|
||
"""根据 Python 值推断 setting_type(bool 必须先于 int 判断)."""
|
||
if isinstance(value, bool):
|
||
return SETTING_TYPE_BOOL
|
||
if isinstance(value, int):
|
||
return SETTING_TYPE_INT
|
||
if isinstance(value, float):
|
||
return SETTING_TYPE_FLOAT
|
||
if isinstance(value, str):
|
||
return SETTING_TYPE_STRING
|
||
return SETTING_TYPE_JSON
|
||
|
||
|
||
def serialize_setting_value(value: Any, setting_type: str) -> str:
|
||
"""把 Python 值按 setting_type 序列化为可入库的字符串."""
|
||
if setting_type == SETTING_TYPE_BOOL:
|
||
if not isinstance(value, bool):
|
||
raise SystemSettingError(f"bool 配置值必须是布尔类型,收到 {value!r}")
|
||
return "true" if value else "false"
|
||
if setting_type == SETTING_TYPE_INT:
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
raise SystemSettingError(f"int 配置值必须是整数,收到 {value!r}")
|
||
return str(value)
|
||
if setting_type == SETTING_TYPE_FLOAT:
|
||
if isinstance(value, bool):
|
||
raise SystemSettingError(f"float 配置值不能是布尔类型,收到 {value!r}")
|
||
try:
|
||
return repr(float(value))
|
||
except (TypeError, ValueError) as exc:
|
||
raise SystemSettingError(f"float 配置值非法:{value!r}") from exc
|
||
if setting_type == SETTING_TYPE_STRING:
|
||
if not isinstance(value, str):
|
||
raise SystemSettingError(f"string 配置值必须是字符串,收到 {value!r}")
|
||
return value
|
||
if setting_type == SETTING_TYPE_JSON:
|
||
try:
|
||
return json.dumps(value, ensure_ascii=False)
|
||
except (TypeError, ValueError) as exc:
|
||
raise SystemSettingError(f"json 配置值无法序列化:{value!r}") from exc
|
||
raise SystemSettingError(f"未知 setting_type: {setting_type}")
|
||
|
||
|
||
def deserialize_setting_value(raw: str | None, setting_type: str) -> Any:
|
||
"""把入库字符串按 setting_type 反序列化为 Python 值."""
|
||
if raw is None:
|
||
return None
|
||
if setting_type == SETTING_TYPE_BOOL:
|
||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||
if setting_type == SETTING_TYPE_INT:
|
||
try:
|
||
return int(str(raw).strip())
|
||
except ValueError as exc:
|
||
raise SystemSettingError(f"int 配置值损坏:{raw!r}") from exc
|
||
if setting_type == SETTING_TYPE_FLOAT:
|
||
try:
|
||
return float(str(raw).strip())
|
||
except ValueError as exc:
|
||
raise SystemSettingError(f"float 配置值损坏:{raw!r}") from exc
|
||
if setting_type == SETTING_TYPE_STRING:
|
||
return raw
|
||
if setting_type == SETTING_TYPE_JSON:
|
||
try:
|
||
return json.loads(raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise SystemSettingError(f"json 配置值损坏:{raw!r}") from exc
|
||
raise SystemSettingError(f"未知 setting_type: {setting_type}")
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class SystemSetting:
|
||
"""系统配置领域实体."""
|
||
|
||
setting_key: str
|
||
setting_value: str | None = None
|
||
setting_type: str = SETTING_TYPE_STRING
|
||
description: str = ""
|
||
is_public: bool = False
|
||
category: str = "general"
|
||
updated_by: str | None = None
|
||
id: str = field(default_factory=lambda: uuid4().hex)
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||
|
||
def get_typed_value(self) -> Any:
|
||
return deserialize_setting_value(self.setting_value, self.setting_type)
|
||
|
||
@classmethod
|
||
def from_value(
|
||
cls,
|
||
setting_key: str,
|
||
value: Any,
|
||
setting_type: str | None = None,
|
||
**kwargs: Any,
|
||
) -> "SystemSetting":
|
||
st = setting_type or infer_setting_type(value)
|
||
return cls(
|
||
setting_key=setting_key,
|
||
setting_value=serialize_setting_value(value, st),
|
||
setting_type=st,
|
||
**kwargs,
|
||
)
|