Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d78c3184 | |||
| 0c1dbfc5a7 | |||
| 222c41bd55 | |||
| eceb454111 |
@@ -1,9 +1,10 @@
|
||||
"""Ditto 数字人表情后台配置 — #2246.
|
||||
|
||||
路由前缀 /api/v1/admin/ditto-emotion,全部使用 _verify_internal_api_key 鉴权
|
||||
(X-API-Key header)。仅开放 5 项白名单配置:
|
||||
(X-API-Key header)。仅开放 6 项白名单配置:
|
||||
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
|
||||
- ditto_emotion_prompt / ditto_blend_frames
|
||||
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
|
||||
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,6 +44,7 @@ _WHITELIST: dict[str, str] = {
|
||||
"ditto_emotion_temperature": SETTING_TYPE_FLOAT,
|
||||
"ditto_emotion_prompt": SETTING_TYPE_STRING,
|
||||
"ditto_blend_frames": SETTING_TYPE_INT,
|
||||
"doubao_api_key": SETTING_TYPE_STRING,
|
||||
}
|
||||
|
||||
_DESCRIPTIONS: dict[str, str] = {
|
||||
@@ -51,8 +53,13 @@ _DESCRIPTIONS: dict[str, str] = {
|
||||
"ditto_emotion_temperature": "模型温度,0-1,越低越稳定保守。",
|
||||
"ditto_emotion_prompt": "情绪分析提示词,核心调优入口,必须包含 {文案} 占位符。",
|
||||
"ditto_blend_frames": "表情切换过渡帧数(6-30),越大越柔和。",
|
||||
"doubao_api_key": "豆包/火山方舟 API Key(用于情绪分析LLM调用)",
|
||||
}
|
||||
|
||||
# 密钥类配置:GET 脱敏、PUT 响应不回显明文
|
||||
_SECRET_KEYS = {"doubao_api_key"}
|
||||
_SECRET_PLACEHOLDER = "***已配置***"
|
||||
|
||||
|
||||
def _settings():
|
||||
return get_api_settings()
|
||||
@@ -63,13 +70,24 @@ def _default_value(key: str) -> Any:
|
||||
|
||||
|
||||
def _build_config_item(key: str) -> dict[str, Any]:
|
||||
service = get_system_config_service()
|
||||
if key in _SECRET_KEYS:
|
||||
# 密钥不回明文、不暴露 env 默认值:DB 或 env 任一侧已配置即视为已配置
|
||||
db_value = service.get_config(key, None)
|
||||
configured = bool((db_value or "").strip()) or bool((getattr(_settings(), key, "") or "").strip())
|
||||
return {
|
||||
"key": key,
|
||||
"type": _WHITELIST[key],
|
||||
"description": _DESCRIPTIONS.get(key, ""),
|
||||
"default": "",
|
||||
"value": _SECRET_PLACEHOLDER if configured else "",
|
||||
}
|
||||
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)
|
||||
@@ -110,6 +128,8 @@ def _validate_value(key: str, value: Any) -> Any:
|
||||
raise ValueError("提示词必须包含 {文案} 占位符")
|
||||
if key == "ditto_emotion_model" and value not in MODEL_OPTIONS:
|
||||
raise ValueError(f"模型必须是以下之一:{', '.join(MODEL_OPTIONS)}")
|
||||
if key == "doubao_api_key" and not value.strip():
|
||||
raise ValueError(f"{key} 不能为空字符串")
|
||||
return value
|
||||
|
||||
|
||||
@@ -143,7 +163,7 @@ def update_config(
|
||||
setting_type=_WHITELIST[key],
|
||||
updated_by=str(x_api_key)[:8] if x_api_key and x_api_key is not True else None,
|
||||
)
|
||||
updated[key] = value
|
||||
updated[key] = _SECRET_PLACEHOLDER if key in _SECRET_KEYS else value
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ class DittoEmotionService:
|
||||
|
||||
self.settings = settings or get_api_settings()
|
||||
self._client = None
|
||||
self._client_api_key: str | None = None
|
||||
|
||||
def _cfg(self, key: str) -> Any:
|
||||
"""优先读后台 system_config,未配置则回退到 settings(env 默认)."""
|
||||
@@ -236,10 +237,16 @@ class DittoEmotionService:
|
||||
return hashlib.md5(text.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
def _get_llm_client(self):
|
||||
if self._client is None:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
"""创建/复用 DoubaoClient;API Key 优先读后台配置(DB),fallback 环境变量。
|
||||
|
||||
self._client = get_doubao_client()
|
||||
后台可能随时改 doubao_api_key,每次调用前对比 key,变化则重建 client。
|
||||
"""
|
||||
api_key = (self._cfg("doubao_api_key") or "").strip()
|
||||
if self._client is None or self._client_api_key != api_key:
|
||||
from packages.shared.ai_client import DoubaoClient
|
||||
|
||||
self._client = DoubaoClient(api_key=api_key)
|
||||
self._client_api_key = api_key
|
||||
return self._client
|
||||
|
||||
def _call_llm(self, text: str) -> list[EmotionSegment]:
|
||||
@@ -309,7 +316,7 @@ def _cached_analyze(service: DittoEmotionService, cache_key: str, text: str) ->
|
||||
# 如果 LLM 返回空(比如分句数量不匹配),尝试直接对预分句结果分析
|
||||
if not segments:
|
||||
pre_splits = split_sentences(text)
|
||||
if len(pre_splits) > 1:
|
||||
if len(pre_splits) >= 1:
|
||||
# 用预分句结果兜底:全中性低强度
|
||||
segments = [EmotionSegment(text=s, emo=EMO_NEUTRAL, intensity=0.1) for s in pre_splits]
|
||||
return segments
|
||||
|
||||
@@ -223,3 +223,60 @@ class TestPromptLoading:
|
||||
custom = "分析情绪: {文案}"
|
||||
svc = _make_service(enabled=True, prompt=custom)
|
||||
assert svc._get_prompt_template() == custom
|
||||
|
||||
|
||||
# ── _get_llm_client:DB 配置 api_key 优先 + 变化重建 ─────────────
|
||||
class TestGetLLMClient:
|
||||
def test_uses_config_api_key(self):
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value="sk-from-db") as mock_cfg:
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
svc._get_llm_client()
|
||||
mock_cfg.assert_called_with("doubao_api_key")
|
||||
assert MockClient.call_args.kwargs["api_key"] == "sk-from-db"
|
||||
|
||||
def test_rebuilds_client_when_key_changes(self):
|
||||
svc = _make_service(enabled=True)
|
||||
keys = iter(["sk-key-v1", "sk-key-v2"])
|
||||
with patch.object(svc, "_cfg", side_effect=lambda k: next(keys)):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
MockClient.side_effect = lambda **kw: MagicMock(api_key=kw["api_key"])
|
||||
c1 = svc._get_llm_client()
|
||||
c2 = svc._get_llm_client()
|
||||
assert c1 is not c2
|
||||
assert MockClient.call_count == 2
|
||||
|
||||
def test_reuses_client_when_key_unchanged(self):
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value="sk-same"):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
MockClient.side_effect = lambda **kw: MagicMock(api_key=kw["api_key"])
|
||||
c1 = svc._get_llm_client()
|
||||
c2 = svc._get_llm_client()
|
||||
assert c1 is c2
|
||||
assert MockClient.call_count == 1
|
||||
|
||||
def test_empty_key_falls_back_to_doubao_client_default(self):
|
||||
"""DB/env 都没配时传空串,由 DoubaoClient 内部 fallback env / 标记不可用."""
|
||||
svc = _make_service(enabled=True)
|
||||
with patch.object(svc, "_cfg", return_value=""):
|
||||
with patch("packages.shared.ai_client.DoubaoClient") as MockClient:
|
||||
svc._get_llm_client()
|
||||
assert MockClient.call_args.kwargs["api_key"] == ""
|
||||
|
||||
|
||||
# ── 单句兜底:LLM 返回空时单句也走中性兜底(>= 1)────────────────
|
||||
class TestSingleSentenceFallback:
|
||||
def test_single_sentence_gets_neutral_fallback(self):
|
||||
import packages.application.ditto_emotion_service as m
|
||||
|
||||
svc = _make_service(enabled=True)
|
||||
m._cached_analyze.cache_clear()
|
||||
try:
|
||||
with patch.object(svc, "_call_llm", return_value=[]):
|
||||
segs = svc.analyze("独一无二单句兜底测试文案abc123")
|
||||
assert len(segs) == 1
|
||||
assert segs[0].emo == EMO_NEUTRAL
|
||||
assert segs[0].intensity == 0.1
|
||||
finally:
|
||||
m._cached_analyze.cache_clear()
|
||||
|
||||
@@ -6,6 +6,8 @@ Ditto 单例 reset 联动、PUT 校验。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -285,3 +287,91 @@ class TestAdminRouteValidation:
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("ditto_emotion_enabled", "yes")
|
||||
assert route._validate_value("ditto_emotion_enabled", True) is True
|
||||
|
||||
def test_validate_api_key(self):
|
||||
route = self._route()
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("doubao_api_key", " ")
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("doubao_api_key", 123)
|
||||
assert route._validate_value("doubao_api_key", "sk-abc123") == "sk-abc123"
|
||||
|
||||
def test_whitelist_contains_api_key(self):
|
||||
route = self._route()
|
||||
assert "doubao_api_key" in route._WHITELIST
|
||||
assert "doubao_api_key" in route._DESCRIPTIONS
|
||||
|
||||
|
||||
class TestAdminRouteSecretMasking:
|
||||
"""doubao_api_key GET 脱敏 + PUT 响应脱敏."""
|
||||
|
||||
def _route(self):
|
||||
from apps.api.app.api.routes.admin import ditto_emotion as route
|
||||
|
||||
return route
|
||||
|
||||
def _mock_service(self, values):
|
||||
svc = MagicMock()
|
||||
svc.get_config = lambda key, default=None: values.get(key, default)
|
||||
return svc
|
||||
|
||||
def test_get_config_masks_db_configured_key(self, monkeypatch):
|
||||
route = self._route()
|
||||
monkeypatch.setattr(
|
||||
route,
|
||||
"get_system_config_service",
|
||||
lambda: self._mock_service({"doubao_api_key": "sk-real-secret-key"}),
|
||||
)
|
||||
monkeypatch.setattr(route, "_settings", lambda: MagicMock(doubao_api_key=""))
|
||||
item = route._build_config_item("doubao_api_key")
|
||||
assert item["value"] == route._SECRET_PLACEHOLDER
|
||||
assert item["default"] == ""
|
||||
assert "sk-real-secret-key" not in str(item)
|
||||
|
||||
def test_get_config_masks_env_configured_key(self, monkeypatch):
|
||||
route = self._route()
|
||||
monkeypatch.setattr(
|
||||
route,
|
||||
"get_system_config_service",
|
||||
lambda: self._mock_service({}),
|
||||
)
|
||||
monkeypatch.setattr(route, "_settings", lambda: MagicMock(doubao_api_key="sk-env-key"))
|
||||
item = route._build_config_item("doubao_api_key")
|
||||
assert item["value"] == route._SECRET_PLACEHOLDER
|
||||
assert "sk-env-key" not in str(item)
|
||||
|
||||
def test_get_config_empty_when_unconfigured(self, monkeypatch):
|
||||
route = self._route()
|
||||
monkeypatch.setattr(
|
||||
route,
|
||||
"get_system_config_service",
|
||||
lambda: self._mock_service({}),
|
||||
)
|
||||
monkeypatch.setattr(route, "_settings", lambda: MagicMock(doubao_api_key=""))
|
||||
item = route._build_config_item("doubao_api_key")
|
||||
assert item["value"] == ""
|
||||
|
||||
def test_get_config_never_leaks_plaintext(self, monkeypatch):
|
||||
route = self._route()
|
||||
secret = "sk-top-secret-12345"
|
||||
monkeypatch.setattr(
|
||||
route,
|
||||
"get_system_config_service",
|
||||
lambda: self._mock_service({"doubao_api_key": secret}),
|
||||
)
|
||||
monkeypatch.setattr(route, "_settings", lambda: MagicMock(doubao_api_key=secret))
|
||||
result = route.get_config()
|
||||
assert secret not in str(result)
|
||||
|
||||
def test_put_response_masks_secret(self, monkeypatch):
|
||||
route = self._route()
|
||||
svc = self._mock_service({})
|
||||
svc.set_config = MagicMock(return_value="sk-new-key")
|
||||
monkeypatch.setattr(route, "get_system_config_service", lambda: svc)
|
||||
payload = route.ConfigUpdatePayload(configs={"doubao_api_key": "sk-new-key"})
|
||||
result = route.update_config(payload, x_api_key="test-key")
|
||||
assert result["ok"] is True
|
||||
assert result["updated"]["doubao_api_key"] == route._SECRET_PLACEHOLDER
|
||||
assert "sk-new-key" not in str(result)
|
||||
# 落库值必须是明文(服务要用),只有响应脱敏
|
||||
assert svc.set_config.call_args[0][1] == "sk-new-key"
|
||||
|
||||
Reference in New Issue
Block a user