Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 8e6da8ea31 style(web): prettier 修复 DittoEmotionConfig #2246
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m28s
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 / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 12m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 13m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 17m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 20m47s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 34m11s
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 / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 31s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m48s
2026-10-08 21:46:14 +08:00
CI Bot 7d5d7e9aee feat(admin): Ditto 数字人表情后台配置页 #2246
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m4s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m11s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m37s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- domain: SystemSetting 实体 + bool/int/float/string/json 序列化反序列化
- adapters: SystemSettingModel + SQLAlchemySystemSettingRepository
- application: SystemConfigService 进程内缓存,set_config 后失效并联动 reset
- ditto_emotion_service/ditto_service: 配置优先读 system_config,env 兜底;
  新增 reset_ditto_emotion_service/reset_ditto_client(含 LRU cache_clear)
- routes/admin/ditto_emotion: GET/PUT /config、POST /config/test,
  X-API-Key 鉴权 + 5 项白名单
- migration 106: system_settings 正式建表(IF NOT EXISTS,幂等)
- web: DittoEmotionConfig 页 + admin api client,/admin 重定向到配置页
- tests: 27 用例覆盖默认值/DB覆盖/白名单拒绝/reset/校验
2026-10-08 21:40:41 +08:00
14 changed files with 1294 additions and 6 deletions
+43
View File
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""system_settings 正式建表(#2246)
Revision ID: 106_system_settings
Revises: 105_narration_first
Create Date: 2026-10-08
system_settings 表此前在 staging 手工创建(对应 034 占位迁移),
此处补正式迁移保证其他环境一致。CREATE TABLE/INDEX 使用 IF NOT EXISTS,
对已手工建表的环境幂等。
"""
from alembic import op
revision = "106_system_settings"
down_revision = "105_narration_first"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS system_settings (
id VARCHAR(36) NOT NULL,
setting_key VARCHAR(100) NOT NULL,
setting_value TEXT,
setting_type VARCHAR(20) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
is_public BOOLEAN NOT NULL DEFAULT FALSE,
updated_by VARCHAR(36),
category VARCHAR(50) NOT NULL DEFAULT 'general',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT pk_system_settings PRIMARY KEY (id),
CONSTRAINT uq_system_settings_setting_key UNIQUE (setting_key)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_system_settings_category " "ON system_settings (category)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_system_settings_category")
op.execute("DROP TABLE IF EXISTS system_settings")
+4
View File
@@ -1,3 +1,4 @@
from app.api.routes.admin.ditto_emotion import router as admin_ditto_emotion_router
from app.api.routes.ai import router as ai_router
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
@@ -242,3 +243,6 @@ api_router.include_router(
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
# #2246:后台 Ditto 表情配置(router 自带 /admin/ditto-emotion 前缀)
api_router.include_router(admin_ditto_emotion_router)
@@ -0,0 +1 @@
"""后台管理路由(#2246 起)."""
@@ -0,0 +1,163 @@
"""Ditto 数字人表情后台配置 — #2246.
路由前缀 /api/v1/admin/ditto-emotion,全部使用 _verify_internal_api_key 鉴权
(X-API-Key header)。仅开放 5 项白名单配置:
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
- ditto_emotion_prompt / ditto_blend_frames
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from packages.application.system_config_service import get_system_config_service
from packages.config import get_api_settings
from packages.domain.system_setting import (
SETTING_TYPE_BOOL,
SETTING_TYPE_FLOAT,
SETTING_TYPE_INT,
SETTING_TYPE_STRING,
)
from ..auth import _verify_internal_api_key
router = APIRouter(
prefix="/admin/ditto-emotion",
tags=["Admin"],
dependencies=[Depends(_verify_internal_api_key)],
)
MODEL_OPTIONS = [
"doubao-seed-2-1-lite-250915",
"doubao-seed-2-1-pro-250915",
"deepseek-v3",
]
# key → (类型, 分类)
_WHITELIST: dict[str, str] = {
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
"ditto_emotion_model": SETTING_TYPE_STRING,
"ditto_emotion_temperature": SETTING_TYPE_FLOAT,
"ditto_emotion_prompt": SETTING_TYPE_STRING,
"ditto_blend_frames": SETTING_TYPE_INT,
}
_DESCRIPTIONS: dict[str, str] = {
"ditto_emotion_enabled": "LLM 情绪分析开关。关闭时回退到原有关键词匹配模式,不影响正常出片。",
"ditto_emotion_model": "用于分析文案情绪的大模型。",
"ditto_emotion_temperature": "模型温度,0-1,越低越稳定保守。",
"ditto_emotion_prompt": "情绪分析提示词,核心调优入口,必须包含 {文案} 占位符。",
"ditto_blend_frames": "表情切换过渡帧数(6-30),越大越柔和。",
}
def _settings():
return get_api_settings()
def _default_value(key: str) -> Any:
return getattr(_settings(), key)
def _build_config_item(key: str) -> dict[str, Any]:
item: dict[str, Any] = {
"key": key,
"type": _WHITELIST[key],
"description": _DESCRIPTIONS.get(key, ""),
"default": _default_value(key),
}
service = get_system_config_service()
item["value"] = service.get_config(key, _default_value(key))
if key == "ditto_emotion_model":
item["model_options"] = list(MODEL_OPTIONS)
return item
class ConfigUpdatePayload(BaseModel):
configs: dict[str, Any]
class TestPayload(BaseModel):
test_text: str
def _validate_value(key: str, value: Any) -> Any:
st = _WHITELIST[key]
if st == SETTING_TYPE_BOOL:
if not isinstance(value, bool):
raise ValueError(f"{key} 必须是布尔值")
elif st == SETTING_TYPE_INT:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} 必须是整数")
if not 6 <= value <= 30:
raise ValueError(f"{key} 必须在 6-30 之间")
elif st == SETTING_TYPE_FLOAT:
if isinstance(value, bool):
raise ValueError(f"{key} 必须是数字")
try:
value = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} 必须是数字") from exc
if not 0.0 <= value <= 1.0:
raise ValueError(f"{key} 必须在 0-1 之间")
elif st == SETTING_TYPE_STRING:
if not isinstance(value, str):
raise ValueError(f"{key} 必须是字符串")
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
raise ValueError("提示词必须包含 {文案} 占位符")
if key == "ditto_emotion_model" and value not in MODEL_OPTIONS:
raise ValueError(f"模型必须是以下之一:{', '.join(MODEL_OPTIONS)}")
return value
@router.get("/config")
def get_config() -> dict[str, Any]:
return {"configs": [_build_config_item(k) for k in _WHITELIST]}
@router.put("/config")
def update_config(
payload: ConfigUpdatePayload,
x_api_key: str = Depends(_verify_internal_api_key),
) -> dict[str, Any]:
configs = payload.configs
illegal = [k for k in configs if k not in _WHITELIST]
if illegal:
return {
"ok": False,
"error": f"不允许修改的配置项:{', '.join(illegal)}",
}
service = get_system_config_service()
updated: dict[str, Any] = {}
for key, raw in configs.items():
try:
value = _validate_value(key, raw)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
service.set_config(
key,
value,
setting_type=_WHITELIST[key],
updated_by=x_api_key[:8] if x_api_key else None,
)
updated[key] = value
return {"ok": True, "updated": updated}
@router.post("/config/test")
def test_config(payload: TestPayload) -> dict[str, Any]:
text = (payload.test_text or "").strip()
if not text:
return {"ok": False, "error": "test_text 不能为空"}
from packages.application.ditto_emotion_service import get_ditto_emotion_service
service = get_ditto_emotion_service()
segments = service.analyze(text)
return {
"ok": True,
"enabled": service.enabled,
"segments": [s.to_dict() for s in segments],
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 后台管理 API client(#2246)
*
* 独立 axios 实例:不经过主 apiClient 的 Bearer token / 401 刷新逻辑,
* 后台鉴权使用 X-API-Key(存 localStorage,不硬编码)。
*/
import axios from "axios"
export const ADMIN_API_KEY_STORAGE = "ditto_admin_api_key"
export type ConfigType = "bool" | "int" | "float" | "string" | "json"
export interface ConfigItem {
key: string
value: unknown
default: unknown
type: ConfigType
description: string
model_options?: string[]
}
export interface TestResult {
ok: boolean
enabled?: boolean
segments?: Array<{ text: string; emo: number; intensity: number }>
error?: string
}
export function getAdminApiKey(): string {
return localStorage.getItem(ADMIN_API_KEY_STORAGE) || ""
}
export function setAdminApiKey(key: string): void {
localStorage.setItem(ADMIN_API_KEY_STORAGE, key)
}
export function clearAdminApiKey(): void {
localStorage.removeItem(ADMIN_API_KEY_STORAGE)
}
function createAdminClient() {
const client = axios.create({
baseURL: "/api/v1",
timeout: 60000,
headers: { "Content-Type": "application/json" },
})
client.interceptors.request.use((config) => {
const key = getAdminApiKey()
if (key && config.headers) {
config.headers["X-API-Key"] = key
}
return config
})
return client
}
const adminClient = createAdminClient()
export async function fetchConfig(): Promise<ConfigItem[]> {
const { data } = await adminClient.get("/admin/ditto-emotion/config")
return data.configs as ConfigItem[]
}
export async function updateConfig(
configs: Record<string, unknown>,
): Promise<{ ok: boolean; updated?: Record<string, unknown>; error?: string }> {
const { data } = await adminClient.put("/admin/ditto-emotion/config", { configs })
return data
}
export async function testConfig(testText: string): Promise<TestResult> {
const { data } = await adminClient.post("/admin/ditto-emotion/config/test", {
test_text: testText,
})
return data as TestResult
}
@@ -0,0 +1,311 @@
import React, { useEffect, useMemo, useState } from "react"
import {
Alert,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Slider,
Space,
Spin,
Switch,
message,
} from "antd"
import {
clearAdminApiKey,
fetchConfig,
getAdminApiKey,
setAdminApiKey,
testConfig,
updateConfig,
type ConfigItem,
} from "@/api/admin/dittoEmotion"
import "./Admin.css"
const EMO_LABELS: Record<number, string> = {
3: "开心",
4: "中性",
5: "伤心",
6: "惊讶",
}
const DittoEmotionConfig: React.FC = () => {
const [hasKey, setHasKey] = useState<boolean>(!!getAdminApiKey())
const [keyInput, setKeyInput] = useState<string>("")
const [loading, setLoading] = useState<boolean>(false)
const [saving, setSaving] = useState<boolean>(false)
const [testing, setTesting] = useState<boolean>(false)
const [items, setItems] = useState<ConfigItem[]>([])
const [modelOptions, setModelOptions] = useState<string[]>([])
const [testResult, setTestResult] = useState<string>("")
const [form] = Form.useForm()
const [testInput, setTestInput] = useState<string>("")
const load = React.useCallback(async () => {
setLoading(true)
try {
const configs = await fetchConfig()
setItems(configs)
const values: Record<string, unknown> = {}
configs.forEach((c) => {
values[c.key] = c.value
if (c.model_options) setModelOptions(c.model_options)
})
form.setFieldsValue(values)
} catch {
// 401/403 等 → 提示 key 可能无效
message.error("加载配置失败,请检查 X-API-Key 是否正确")
} finally {
setLoading(false)
}
}, [form])
useEffect(() => {
if (hasKey) {
void load()
}
}, [hasKey, load])
const defaults = useMemo(() => {
const m: Record<string, unknown> = {}
items.forEach((c) => {
m[c.key] = c.default
})
return m
}, [items])
const submitKey = () => {
if (!keyInput.trim()) {
message.warning("请输入 X-API-Key")
return
}
setAdminApiKey(keyInput.trim())
setHasKey(true)
}
const changeKey = () => {
clearAdminApiKey()
setHasKey(false)
setKeyInput("")
}
const resetDefaults = () => {
form.setFieldsValue(defaults)
message.info("已填入默认值,点击「保存配置」后生效")
}
const validateBeforeSave = async (): Promise<Record<string, unknown> | null> => {
try {
const values = await form.validateFields()
const prompt = (values.ditto_emotion_prompt || "") as string
if (prompt.trim() && !prompt.includes("{文案}")) {
message.error("提示词必须包含 {文案} 占位符")
return null
}
return values as Record<string, unknown>
} catch {
return null
}
}
const onSave = async () => {
const values = await validateBeforeSave()
if (!values) return
setSaving(true)
try {
const res = await updateConfig(values)
if (res.ok) {
message.success("配置已保存并立即生效")
await load()
} else {
message.error(res.error || "保存失败")
}
} catch {
message.error("保存失败,请检查网络或 X-API-Key")
} finally {
setSaving(false)
}
}
const onTest = async () => {
const testText = (testInput || "").trim()
if (!testText) {
message.warning("请先在下方输入测试文案")
return
}
setTesting(true)
setTestResult("")
try {
const res = await testConfig(testText)
if (!res.ok) {
message.error(res.error || "测试失败")
} else if (!res.enabled) {
message.info("当前表情开关为关闭状态,无情绪结果,可先开启后再测")
} else {
const lines = (res.segments || []).map(
(s) => `【${EMO_LABELS[s.emo] ?? s.emo} ${s.intensity}】${s.text}`,
)
setTestResult(lines.join("\n") || "未解析到情绪结果")
}
} catch {
message.error("测试失败,请检查网络或 X-API-Key")
} finally {
setTesting(false)
}
}
if (!hasKey) {
return (
<div className="admin-coming-soon-page">
<Modal
title="请输入后台 X-API-Key"
open
closable={false}
footer={[
<Button type="primary" key="ok" onClick={submitKey}>
确认
</Button>,
]}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="Key 仅保存在本机浏览器 localStorage,用于后台接口鉴权(X-API-Key)。"
/>
<Input.Password
autoFocus
placeholder="X-API-Key"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
onPressEnter={submitKey}
/>
</Modal>
</div>
)
}
return (
<div className="admin-coming-soon-page">
<div style={{ maxWidth: 860, width: "100%" }}>
<Card
title="Ditto 数字人表情设置"
extra={
<Button size="small" onClick={changeKey}>
更换 X-API-Key
</Button>
}
className="xx-card"
>
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
message="修改保存后立即生效,无需重启或发版。"
action={
<Button size="small" onClick={resetDefaults}>
重置默认
</Button>
}
/>
<Spin spinning={loading}>
<Form form={form} layout="vertical">
<Form.Item
name="ditto_emotion_enabled"
label="启用 LLM 情绪分析"
valuePropName="checked"
extra="关闭后立即回退到原有关键词匹配模式,不影响正常出片。"
>
<Switch />
</Form.Item>
<Form.Item
name="ditto_emotion_model"
label="情绪分析模型"
rules={[{ required: true, message: "请选择模型" }]}
>
<Select
options={(modelOptions.length ? modelOptions : []).map((m) => ({
label: m,
value: m,
}))}
/>
</Form.Item>
<Form.Item name="ditto_emotion_temperature" label="温度(0-1,越低越稳定)">
<Space style={{ width: "100%" }} align="center">
<Slider min={0} max={1} step={0.1} style={{ width: 320 }} />
<InputNumber min={0} max={1} step={0.1} />
</Space>
</Form.Item>
<Form.Item
name="ditto_emotion_prompt"
label="情绪分析提示词(必须包含 {文案} 占位符)"
rules={[
{
validator: (_, value) =>
!value || !String(value).trim() || String(value).includes("{文案}")
? Promise.resolve()
: Promise.reject(new Error("必须包含 {文案} 占位符")),
},
]}
>
<Input.TextArea
rows={15}
placeholder="留空则使用系统默认提示词"
style={{ fontFamily: "monospace" }}
/>
</Form.Item>
<Form.Item
name="ditto_blend_frames"
label="表情过渡帧数(6-30,越大越柔和)"
rules={[{ required: true, message: "请输入过渡帧数" }]}
>
<InputNumber min={6} max={30} step={1} precision={0} />
</Form.Item>
</Form>
<Space style={{ marginTop: 8 }}>
<Button type="primary" loading={saving} onClick={onSave}>
保存配置
</Button>
</Space>
</Spin>
</Card>
<Card title="配置测试" className="xx-card" style={{ marginTop: 16 }}>
<Input.TextArea
rows={3}
placeholder="输入测试文案,例如:这款面膜超级好用!今天补水效果太棒了。"
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
/>
<Space style={{ marginTop: 12 }}>
<Button loading={testing} onClick={onTest}>
用当前配置测试
</Button>
</Space>
{testResult && (
<Input.TextArea
readOnly
rows={6}
value={testResult}
style={{ marginTop: 12, fontFamily: "monospace", whiteSpace: "pre-wrap" }}
/>
)}
</Card>
</div>
</div>
)
}
export default DittoEmotionConfig
export const Component = DittoEmotionConfig
+5 -1
View File
@@ -121,7 +121,11 @@ const appChildren: RouteObject[] = [
children: [
{
index: true,
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
element: <Navigate to="/app/admin/ditto-emotion" replace />,
},
{
path: "ditto-emotion",
lazy: lazyRoute(() => import("@/pages/admin/DittoEmotionConfig")),
},
{
path: "users",
@@ -1019,3 +1019,20 @@ class ViralVideoPromptTemplateModel(Base):
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
class SystemSettingModel(Base):
"""系统配置表 ORM 模型(#2246:后台可配置项,表已手工存在于 staging)."""
__tablename__ = "system_settings"
id = Column(String(36), primary_key=True)
setting_key = Column(String(100), nullable=False, unique=True)
setting_value = Column(Text, nullable=True)
setting_type = Column(String(20), nullable=False)
description = Column(String(255), nullable=False, default="", server_default="")
is_public = Column(Boolean, nullable=False, default=False, server_default="false")
updated_by = Column(String(36), nullable=True)
category = Column(String(50), nullable=False, default="general", server_default="general")
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,65 @@
"""system_settings 表 SQLAlchemy Repository — #2246."""
from __future__ import annotations
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import SystemSettingModel
from packages.domain.system_setting import SystemSetting
class SQLAlchemySystemSettingRepository:
def __init__(self, session: Session):
self.session = session
def get_by_key(self, setting_key: str) -> SystemSetting | None:
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
if model is None:
return None
return self._to_domain(model)
def list_all(self, category: str | None = None) -> list[SystemSetting]:
query = self.session.query(SystemSettingModel)
if category is not None:
query = query.filter(SystemSettingModel.category == category)
return [self._to_domain(m) for m in query.all()]
def upsert(self, setting: SystemSetting) -> SystemSetting:
model = (
self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting.setting_key).first()
)
if model is None:
model = SystemSettingModel(id=setting.id)
self.session.add(model)
model.setting_key = setting.setting_key
model.setting_value = setting.setting_value
model.setting_type = setting.setting_type
model.description = setting.description
model.is_public = setting.is_public
model.category = setting.category
model.updated_by = setting.updated_by
self.session.commit()
return setting
def delete_by_key(self, setting_key: str) -> bool:
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
@staticmethod
def _to_domain(model: SystemSettingModel) -> SystemSetting:
return SystemSetting(
id=model.id,
setting_key=model.setting_key,
setting_value=model.setting_value,
setting_type=model.setting_type,
description=model.description or "",
is_public=bool(model.is_public),
category=model.category or "general",
updated_by=model.updated_by,
created_at=model.created_at,
updated_at=model.updated_at,
)
+20 -4
View File
@@ -212,13 +212,22 @@ class DittoEmotionService:
self.settings = settings or get_api_settings()
self._client = None
def _cfg(self, key: str) -> Any:
"""优先读后台 system_config,未配置则回退到 settings(env 默认)."""
try:
from packages.application.system_config_service import get_config
return get_config(key, getattr(self.settings, key, None))
except Exception:
return getattr(self.settings, key, None)
@property
def enabled(self) -> bool:
return bool(getattr(self.settings, "ditto_emotion_enabled", False))
return bool(self._cfg("ditto_emotion_enabled"))
def _get_prompt_template(self) -> str:
"""优先用配置(环境变量),否则读文件."""
cfg_prompt = getattr(self.settings, "ditto_emotion_prompt", "") or ""
cfg_prompt = self._cfg("ditto_emotion_prompt") or ""
if cfg_prompt.strip():
return cfg_prompt.strip()
return _load_default_prompt()
@@ -238,8 +247,8 @@ class DittoEmotionService:
template = self._get_prompt_template()
prompt = template.replace("{文案}", text)
messages = [{"role": "user", "content": prompt}]
model = getattr(self.settings, "ditto_emotion_model", "") or None
temperature = getattr(self.settings, "ditto_emotion_temperature", 0.1)
model = self._cfg("ditto_emotion_model") or None
temperature = self._cfg("ditto_emotion_temperature")
timeout = getattr(self.settings, "ditto_emotion_timeout", 10)
max_tokens = getattr(self.settings, "ditto_emotion_max_tokens", 1024)
try:
@@ -314,3 +323,10 @@ def get_ditto_emotion_service() -> DittoEmotionService:
if _singleton is None:
_singleton = DittoEmotionService()
return _singleton
def reset_ditto_emotion_service() -> None:
"""#2246:后台配置变更后重置单例并清空 LLM 结果 LRU 缓存."""
global _singleton
_singleton = None
_cached_analyze.cache_clear()
+12 -1
View File
@@ -67,7 +67,12 @@ class DittoClient:
self.default_video_url = default_video_url or s.ditto_default_video_url or ""
self.max_retries = int(max_retries if max_retries is not None else s.ditto_max_retries)
self.timeout = int(timeout if timeout is not None else s.ditto_request_timeout)
self.blend_frames = int(s.ditto_blend_frames)
try:
from packages.application.system_config_service import get_config
self.blend_frames = int(get_config("ditto_blend_frames", s.ditto_blend_frames))
except Exception:
self.blend_frames = int(s.ditto_blend_frames)
@property
def is_configured(self) -> bool:
@@ -289,3 +294,9 @@ def get_ditto_client() -> DittoClient:
if _ditto_client_singleton is None:
_ditto_client_singleton = DittoClient()
return _ditto_client_singleton
def reset_ditto_client() -> None:
"""#2246:后台配置变更后重置 DittoClient 单例."""
global _ditto_client_singleton
_ditto_client_singleton = None
@@ -0,0 +1,187 @@
"""系统配置应用服务 — #2246.
- get_config(key, default) / set_config(...) / list_configs(category)
- 首次访问时从 DB 加载并进程内缓存;set_config 后失效缓存
- set_config 成功后重置 Ditto 情绪服务与 Ditto 客户端单例(含 LRU 缓存),
保证后台改动立即生效;worker 不直接 HTTP 读配置,统一通过本模块。
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from packages.adapters.sqlalchemy_impl.session import SessionLocal
from packages.adapters.sqlalchemy_impl.system_setting_repository import (
SQLAlchemySystemSettingRepository,
)
from packages.domain.system_setting import (
SystemSetting,
infer_setting_type,
serialize_setting_value,
)
logger = logging.getLogger(__name__)
class SystemConfigService:
"""进程内缓存的系统配置服务."""
def __init__(self, session_factory=None):
self._session_factory = session_factory
self._cache: dict[str, Any] | None = None
self._lock = threading.Lock()
# ── 会话 ─────────────────────────────────────────────────────
def _get_session_factory(self):
factory = self._session_factory or SessionLocal
if factory is None:
raise RuntimeError("数据库会话工厂未初始化")
return factory
# ── 缓存 ─────────────────────────────────────────────────────
def _load_cache(self) -> dict[str, Any]:
with self._lock:
if self._cache is not None:
return self._cache
cache: dict[str, Any] = {}
factory = self._get_session_factory()
session = factory()
try:
repo = SQLAlchemySystemSettingRepository(session)
for setting in repo.list_all():
try:
cache[setting.setting_key] = setting.get_typed_value()
except Exception as exc: # 损坏配置不阻塞启动
logger.warning(
"[system_config] 跳过损坏配置 %s: %s",
setting.setting_key,
exc,
)
finally:
session.close()
self._cache = cache
return cache
def reload(self) -> dict[str, Any]:
"""强制重新从 DB 加载配置,返回新缓存."""
with self._lock:
self._cache = None
return self._load_cache()
def invalidate(self) -> None:
with self._lock:
self._cache = None
# ── 读 ───────────────────────────────────────────────────────
def get_config(self, key: str, default: Any = None) -> Any:
cache = self._load_cache()
return cache.get(key, default)
def list_configs(self, category: str | None = None) -> list[SystemSetting]:
factory = self._get_session_factory()
session = factory()
try:
return SQLAlchemySystemSettingRepository(session).list_all(category)
finally:
session.close()
# ── 写 ───────────────────────────────────────────────────────
def set_config(
self,
key: str,
value: Any,
setting_type: str | None = None,
updated_by: str | None = None,
*,
description: str | None = None,
category: str = "general",
is_public: bool = False,
) -> Any:
st = setting_type or infer_setting_type(value)
serialized = serialize_setting_value(value, st)
factory = self._get_session_factory()
session = factory()
try:
repo = SQLAlchemySystemSettingRepository(session)
existing = repo.get_by_key(key)
if existing is not None:
existing.setting_value = serialized
existing.setting_type = st
if updated_by is not None:
existing.updated_by = updated_by
if description is not None:
existing.description = description
setting = existing
else:
setting = SystemSetting(
setting_key=key,
setting_value=serialized,
setting_type=st,
description=description or "",
is_public=is_public,
category=category,
updated_by=updated_by,
)
repo.upsert(setting)
finally:
session.close()
self.invalidate()
self._reset_ditto_singletons(key)
return value
def delete_config(self, key: str) -> bool:
factory = self._get_session_factory()
session = factory()
try:
deleted = SQLAlchemySystemSettingRepository(session).delete_by_key(key)
finally:
session.close()
if deleted:
self.invalidate()
self._reset_ditto_singletons(key)
return deleted
# ── Ditto 联动 ───────────────────────────────────────────────
@staticmethod
def _reset_ditto_singletons(key: str) -> None:
try:
from packages.application import ditto_emotion_service as emo_mod
from packages.application import ditto_service as ditto_mod
emo_mod.reset_ditto_emotion_service()
ditto_mod.reset_ditto_client()
logger.info("[system_config] %s 更新,已重置 Ditto 单例", key)
except Exception as exc: # 联动失败不影响配置落库
logger.warning("[system_config] 重置 Ditto 单例失败: %s", exc)
_service_singleton: SystemConfigService | None = None
def get_system_config_service() -> SystemConfigService:
global _service_singleton
if _service_singleton is None:
_service_singleton = SystemConfigService()
return _service_singleton
def get_config(key: str, default: Any = None) -> Any:
"""便捷读取:优先 DB 配置,未配置时返回 default(调用方传 env 值兜底)."""
try:
return get_system_config_service().get_config(key, default)
except Exception as exc:
logger.warning("[system_config] 读取 %s 失败,使用默认值: %s", key, exc)
return default
def set_config(
key: str,
value: Any,
setting_type: str | None = None,
updated_by: str | None = None,
) -> Any:
return get_system_config_service().set_config(key, value, setting_type=setting_type, updated_by=updated_by)
+134
View File
@@ -0,0 +1,134 @@
"""系统配置领域实体 — #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,
)
+256
View File
@@ -0,0 +1,256 @@
"""#2246 system_settings / system_config_service 单元测试。
覆盖:序列化反序列化、默认值兜底、DB 覆盖、缓存失效、白名单拒绝、
Ditto 单例 reset 联动、PUT 校验。
"""
from __future__ import annotations
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.models import Base
from packages.application.system_config_service import SystemConfigService
from packages.domain.system_setting import (
SETTING_TYPE_BOOL,
SETTING_TYPE_FLOAT,
SETTING_TYPE_INT,
SETTING_TYPE_JSON,
SETTING_TYPE_STRING,
SystemSettingError,
deserialize_setting_value,
infer_setting_type,
serialize_setting_value,
)
# ── domain 序列化 ──────────────────────────────────────────────
class TestSerialization:
@pytest.mark.parametrize(
"value,st,expected",
[
(True, SETTING_TYPE_BOOL, "true"),
(False, SETTING_TYPE_BOOL, "false"),
(12, SETTING_TYPE_INT, "12"),
(-3, SETTING_TYPE_INT, "-3"),
(0.1, SETTING_TYPE_FLOAT, "0.1"),
("hello", SETTING_TYPE_STRING, "hello"),
],
)
def test_serialize(self, value, st, expected):
assert serialize_setting_value(value, st) == expected
def test_json_roundtrip(self):
raw = serialize_setting_value({"a": [1, 2]}, SETTING_TYPE_JSON)
assert deserialize_setting_value(raw, SETTING_TYPE_JSON) == {"a": [1, 2]}
def test_bool_roundtrip_variants(self):
for truthy in ("true", "1", "yes", "on", "TRUE"):
assert deserialize_setting_value(truthy, SETTING_TYPE_BOOL) is True
for falsy in ("false", "0", "", "no"):
assert deserialize_setting_value(falsy, SETTING_TYPE_BOOL) is False
def test_int_float_roundtrip(self):
assert deserialize_setting_value("42", SETTING_TYPE_INT) == 42
assert deserialize_setting_value("1.5", SETTING_TYPE_FLOAT) == 1.5
def test_type_errors(self):
with pytest.raises(SystemSettingError):
serialize_setting_value("x", SETTING_TYPE_INT)
with pytest.raises(SystemSettingError):
serialize_setting_value(1, SETTING_TYPE_BOOL)
with pytest.raises(SystemSettingError):
deserialize_setting_value("abc", SETTING_TYPE_INT)
with pytest.raises(SystemSettingError):
serialize_setting_value(1, "unknown")
def test_infer_type_bool_before_int(self):
assert infer_setting_type(True) == SETTING_TYPE_BOOL
assert infer_setting_type(5) == SETTING_TYPE_INT
assert infer_setting_type(0.5) == SETTING_TYPE_FLOAT
assert infer_setting_type("s") == SETTING_TYPE_STRING
assert infer_setting_type([1]) == SETTING_TYPE_JSON
# ── DB fixture ─────────────────────────────────────────────────
@pytest.fixture()
def config_service():
engine = create_engine("sqlite://", echo=False)
@event.listens_for(engine, "connect")
def _noop(dbapi_conn, connection_record):
pass
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
yield SystemConfigService(session_factory=factory)
class TestConfigService:
def test_default_when_missing(self, config_service):
assert config_service.get_config("not_exist", "fallback") == "fallback"
def test_db_overrides_default(self, config_service):
config_service.set_config("k", 20, setting_type=SETTING_TYPE_INT)
# 再次读取应命中 DB 值,而非传入的默认
assert config_service.get_config("k", 12) == 20
def test_set_and_get_all_types(self, config_service):
config_service.set_config("b", True)
config_service.set_config("i", 7)
config_service.set_config("f", 0.3)
config_service.set_config("s", "文本")
config_service.set_config("j", {"x": 1})
assert config_service.get_config("b", False) is True
assert config_service.get_config("i", 0) == 7
assert abs(config_service.get_config("f", 0.0) - 0.3) < 1e-9
assert config_service.get_config("s", "") == "文本"
assert config_service.get_config("j", {}) == {"x": 1}
def test_update_existing(self, config_service):
config_service.set_config("k", 1, setting_type=SETTING_TYPE_INT)
config_service.set_config("k", 2, setting_type=SETTING_TYPE_INT)
assert config_service.get_config("k", 0) == 2
assert len(config_service.list_configs()) == 1
def test_cache_invalidation(self, config_service):
config_service.set_config("k", 1, setting_type=SETTING_TYPE_INT)
config_service.get_config("k", 0) # 填充缓存
# 手工改库模拟外部写入,reload 后应可见
from packages.adapters.sqlalchemy_impl.models import SystemSettingModel
session = config_service._get_session_factory()()
session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == "k").update({"setting_value": "99"})
session.commit()
session.close()
# 缓存未失效前仍是旧值
assert config_service.get_config("k", 0) == 1
config_service.reload()
assert config_service.get_config("k", 0) == 99
def test_list_by_category(self, config_service):
config_service.set_config("a", 1, category="ditto")
config_service.set_config("b", 2, category="other")
keys = {c.setting_key for c in config_service.list_configs("ditto")}
assert keys == {"a"}
def test_delete(self, config_service):
config_service.set_config("k", 1, setting_type=SETTING_TYPE_INT)
assert config_service.delete_config("k") is True
assert config_service.get_config("k", "d") == "d"
assert config_service.delete_config("k") is False
def test_reset_singletons_called(self, config_service):
called = {"emo": 0, "client": 0}
import packages.application.ditto_emotion_service as real_emo
import packages.application.ditto_service as real_client
orig_emo_reset = real_emo.reset_ditto_emotion_service
orig_client_reset = real_client.reset_ditto_client
def fake_emo_reset():
called["emo"] += 1
def fake_client_reset():
called["client"] += 1
real_emo.reset_ditto_emotion_service = fake_emo_reset
real_client.reset_ditto_client = fake_client_reset
try:
config_service.set_config("ditto_emotion_model", "m", setting_type=SETTING_TYPE_STRING)
assert called["emo"] == 1
assert called["client"] == 1
finally:
# 必须还原,否则污染同文件后续 reset 用例
real_emo.reset_ditto_emotion_service = orig_emo_reset
real_client.reset_ditto_client = orig_client_reset
# ── Ditto 单例 reset ───────────────────────────────────────────
class TestDittoSingletonReset:
def test_emotion_reset_clears_lru(self):
from unittest.mock import MagicMock
from packages.application import ditto_emotion_service as m
# 用一个可哈希的假 service 填 LRU,_call_llm 返回固定值,不触发真实逻辑
fake = MagicMock()
fake._call_llm.return_value = []
m._cached_analyze.cache_clear()
m._cached_analyze(fake, "k", "t")
assert m._cached_analyze.cache_info().currsize == 1
saved_singleton = m._singleton
m._singleton = fake
try:
m.reset_ditto_emotion_service()
assert m._singleton is None
assert m._cached_analyze.cache_info().currsize == 0
finally:
m._singleton = saved_singleton
def test_client_reset(self):
from packages.application import ditto_service as m
saved = m._ditto_client_singleton
m._ditto_client_singleton = object()
try:
m.reset_ditto_client()
assert m._ditto_client_singleton is None
finally:
m._ditto_client_singleton = saved
# ── 路由层白名单与校验 ─────────────────────────────────────────
class TestAdminRouteValidation:
def _route(self):
from apps.api.app.api.routes.admin import ditto_emotion as route
return route
def test_whitelist_rejects_unknown_key(self):
route = self._route()
from packages.application.system_config_service import SystemConfigService
svc = SystemConfigService(session_factory=lambda: pytest.fail("should not open session"))
payload = route.ConfigUpdatePayload(configs={"evil_key": 1})
# 直接调用 update_config,传入一个任意 key 标识
result = route.update_config(payload, x_api_key="k")
assert result["ok"] is False
assert "不允许修改" in result["error"]
def test_validate_blend_frames_range(self):
route = self._route()
with pytest.raises(ValueError):
route._validate_value("ditto_blend_frames", 3)
with pytest.raises(ValueError):
route._validate_value("ditto_blend_frames", 31)
assert route._validate_value("ditto_blend_frames", 12) == 12
def test_validate_temperature_range(self):
route = self._route()
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_temperature", 1.5)
assert route._validate_value("ditto_emotion_temperature", "0.2") == pytest.approx(0.2)
def test_validate_prompt_placeholder(self):
route = self._route()
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_prompt", "没有占位符的提示词")
assert route._validate_value("ditto_emotion_prompt", "分析:{文案}") == "分析:{文案}"
# 允许留空(使用默认)
assert route._validate_value("ditto_emotion_prompt", "") == ""
def test_validate_model_options(self):
route = self._route()
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_model", "gpt-4")
assert route._validate_value("ditto_emotion_model", "deepseek-v3") == "deepseek-v3"
def test_validate_bool(self):
route = self._route()
with pytest.raises(ValueError):
route._validate_value("ditto_emotion_enabled", "yes")
assert route._validate_value("ditto_emotion_enabled", True) is True