Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e6da8ea31 | |||
| 7d5d7e9aee |
@@ -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")
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user