Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d78c3184 | |||
| 0c1dbfc5a7 | |||
| 312210c3d5 | |||
| 70a26b053a | |||
| bec75a075b | |||
| 222c41bd55 | |||
| 36c30e5b6f | |||
| eceb454111 | |||
| c8c9c8b35e | |||
| e458ffeb57 | |||
| 02866a64dd | |||
| 4248ea2a59 | |||
| 4e5d365277 | |||
| 22b9019325 | |||
| 652d2cd270 | |||
| 019bbe6897 | |||
| f25fa9d978 | |||
| 2080ecc4f9 | |||
| bc45a10fff | |||
| a949426100 | |||
| e22a62802d | |||
| 70d4a21055 | |||
| d6e4a09628 | |||
| adcb59b446 | |||
| 213e89f93a | |||
| 79696e18f0 | |||
| 8ff9ab0cb4 | |||
| f10b5393fa | |||
| ba0e3fc67f | |||
| 6eb528ed44 | |||
| 82ba481e4f | |||
| 5864ab0325 | |||
| 39fcc42533 | |||
| 8d9583b089 | |||
| 783914764f | |||
| c32e6292f7 | |||
| 247af9b755 | |||
| cfb5c7ab89 | |||
| d6d100baeb | |||
| e9c119aea4 | |||
| 58ac709e53 |
+1
-2
@@ -1,2 +1 @@
|
||||
CI trigger file - safe to delete
|
||||
retrigger at 2026-09-15 20:31:24 UTC
|
||||
trigger staging deploy for bec75a07 (viral-video error isolation) at 2026-10-11 19:39:29
|
||||
|
||||
@@ -28,10 +28,17 @@ depends_on = None
|
||||
|
||||
|
||||
def _tpl(prompt_type: str, version: int) -> dict:
|
||||
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
|
||||
# 先按指定版本找
|
||||
for t in DEFAULT_TEMPLATES:
|
||||
if t["prompt_type"] == prompt_type and t["version"] == version:
|
||||
return t
|
||||
raise RuntimeError("default template missing: %s v%s" % (prompt_type, version))
|
||||
# 找不到则取最新版本
|
||||
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
|
||||
if candidates:
|
||||
latest = max(candidates, key=lambda x: x["version"])
|
||||
return latest
|
||||
raise RuntimeError("default template missing: %s" % prompt_type)
|
||||
|
||||
|
||||
def _upsert(bind, t: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
|
||||
|
||||
Revision ID: 108_storyboard_v4_multivoice
|
||||
Revises: 107
|
||||
Create Date: 2026-10-10
|
||||
|
||||
变更:
|
||||
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
|
||||
- 增加<speaker>标签,每镜必须标注说话人
|
||||
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
|
||||
- voiceover_script用[speaker:xxx]标记格式
|
||||
2. 旧版storyboard模板is_active设为false
|
||||
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "108_storyboard_v4_multivoice"
|
||||
down_revision = "107"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# ── storyboard v4 system_prompt ──────────────────────────────────────
|
||||
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
|
||||
|
||||
## 核心设计原则(必须严格遵守)
|
||||
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
|
||||
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
|
||||
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
|
||||
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
|
||||
|
||||
## 输出格式(XML,严格按结构输出,不要输出额外解释)
|
||||
<script>
|
||||
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
|
||||
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
|
||||
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
|
||||
<theme>一句话主题</theme>
|
||||
<negative>【反套路化要求】
|
||||
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
|
||||
</script>
|
||||
|
||||
## 写作要求
|
||||
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
|
||||
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
|
||||
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
|
||||
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
|
||||
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
|
||||
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
8. 分镜数量与时长匹配总时长,节奏紧凑。
|
||||
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
|
||||
- 5秒视频:voiceover_script 总字数 12~15 字
|
||||
- 10秒视频:voiceover_script 总字数 25~30 字
|
||||
- 15秒视频:voiceover_script 总字数 35~45 字
|
||||
- 20秒视频:voiceover_script 总字数 50~60 字
|
||||
- 30秒视频:voiceover_script 总字数 75~90 字
|
||||
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
|
||||
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
|
||||
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
|
||||
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
|
||||
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
|
||||
|
||||
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
|
||||
<industry>{industry}</industry>
|
||||
<image_analysis>
|
||||
{image_summary}
|
||||
</image_analysis>
|
||||
<user_parameters>
|
||||
<theme_hint>{theme_hint}</theme_hint>
|
||||
<duration>{duration}秒</duration>
|
||||
<aspect_ratio>{aspect_ratio}</aspect_ratio>
|
||||
<tone>{tone}</tone>
|
||||
<target_audience>{target_audience}</target_audience>
|
||||
<persona>{persona_hint}</persona>
|
||||
<viral_structure>{viral_structure_hint}</viral_structure>
|
||||
<language>{language_hint}</language>
|
||||
<extra_requirements>{extra_requirements}</extra_requirements>
|
||||
</user_parameters>
|
||||
{video_style_section}
|
||||
请严格按 XML 结构输出分镜脚本。"""
|
||||
|
||||
V4_STORYBOARD_EXAMPLE = """<script>
|
||||
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
|
||||
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>店主</speaker>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>3-6秒</time_range>
|
||||
<speaker>顾客</speaker>
|
||||
<voiceover>是啊,怎么回事?</voiceover>
|
||||
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂重拾状态</theme>
|
||||
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
|
||||
</script>"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 查询 storyboard 当前最大 version
|
||||
result = conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
max_version = result.scalar() or 0
|
||||
new_version = max_version + 1
|
||||
|
||||
# 2. 旧版 storyboard 模板 is_active 设为 false
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
|
||||
# 3. 防御性插入新版 storyboard 模板
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
|
||||
{"pt": "storyboard", "ver": new_version},
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "编导分镜v4-多角色对话版",
|
||||
"pt": "storyboard",
|
||||
"ver": new_version,
|
||||
"sys": V4_STORYBOARD_SYSTEM,
|
||||
"usr": V4_STORYBOARD_USER,
|
||||
"ex": V4_STORYBOARD_EXAMPLE,
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
|
||||
ia_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
|
||||
if ia_active == 0:
|
||||
# 插入保底 image_analysis 模板(简化版)
|
||||
ia_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "图片分析保底版",
|
||||
"pt": "image_analysis",
|
||||
"ver": ia_max + 1,
|
||||
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
|
||||
"usr": "请分析这张图片。图片地址:{image_url}",
|
||||
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. 检查 review 是否有 active 模板
|
||||
review_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
|
||||
if review_active == 0:
|
||||
rv_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "文案审核保底版",
|
||||
"pt": "review",
|
||||
"ver": rv_max + 1,
|
||||
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
|
||||
"usr": "请审核:{fusion_text}",
|
||||
"ex": "<review><passed>true</passed></review>",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复旧版 storyboard 为 active
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
# 删除新版
|
||||
conn.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -141,9 +161,9 @@ def update_config(
|
||||
key,
|
||||
value,
|
||||
setting_type=_WHITELIST[key],
|
||||
updated_by=x_api_key[:8] if x_api_key else None,
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -701,7 +701,7 @@ def retry_viral_video_job(
|
||||
)
|
||||
# 差额为 0 则不调整
|
||||
|
||||
# 重置状态
|
||||
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
|
||||
@@ -710,11 +710,20 @@ def retry_viral_video_job(
|
||||
job.current_stage = ""
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
|
||||
job.intent_result = None
|
||||
job.copy_result = None
|
||||
job.storyboard = None
|
||||
job.generated_copy_text = ""
|
||||
job.result_video_url = ""
|
||||
job.pre_trusted_images = None
|
||||
job.credits_cost = 0.0
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队
|
||||
# 重新入队:从图片分析阶段1重新跑(旧 run_viral_video_pipeline 只跑到 wait_user_confirm 就停,
|
||||
# 会导致前端永远转圈或文案确认页卡死后 confirm-copy 返回 409)
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
|
||||
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
|
||||
logger.info(
|
||||
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
|
||||
job.id,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 后台管理 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
|
||||
}
|
||||
@@ -65,6 +65,22 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return isImageAnalysisStage(stage) || isCopyStage(stage)
|
||||
}
|
||||
|
||||
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
|
||||
export function getJobStage(
|
||||
job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined,
|
||||
): ViralVideoStage | undefined {
|
||||
if (!job) return undefined
|
||||
return job.current_stage || job.progress_stage
|
||||
}
|
||||
|
||||
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
|
||||
export function getJobPhaseMessage(
|
||||
job: { phase_message?: string; progress_message?: string } | null | undefined,
|
||||
): string {
|
||||
if (!job) return ""
|
||||
return job.phase_message || job.progress_message || ""
|
||||
}
|
||||
|
||||
/** 单张图片 VLM 识别结果(v8 叙述优先,仅保留最少结构化字段) */
|
||||
export interface ImageProductAnalysis {
|
||||
/** store / product / person / scene */
|
||||
@@ -89,6 +105,8 @@ export interface ImageAnalysisResult {
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 说话人角色,如 "店主"、"顾客" */
|
||||
speaker?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
@@ -189,8 +207,14 @@ export interface ViralVideoJob {
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
/** 后端真实字段名(snake_case):当前细粒度阶段 */
|
||||
current_stage?: ViralVideoStage
|
||||
/** 后端真实字段名:当前阶段中文提示文案 */
|
||||
phase_message?: string
|
||||
/** @deprecated 兼容旧前端:使用 current_stage */
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
/** @deprecated 兼容旧前端:使用 phase_message */
|
||||
progress_message?: string
|
||||
output_url?: string
|
||||
result_video_url?: string
|
||||
|
||||
@@ -8,11 +8,6 @@ export const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/products": "成片库",
|
||||
"/app/templates": "模板库",
|
||||
"/app/history": "任务历史",
|
||||
"/app/admin": "控制台",
|
||||
"/app/admin/users": "用户管理",
|
||||
"/app/admin/analytics": "数据分析",
|
||||
"/app/admin/monitor": "系统监控",
|
||||
"/app/admin/logs": "系统日志",
|
||||
"/app/subscription": "订阅管理",
|
||||
"/app/subscription/upgrade": "升级订阅",
|
||||
"/app/subscription/billing": "账单管理",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
@@ -207,12 +206,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/app/admin",
|
||||
icon: React.createElement(ControlOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "会员订阅",
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* Admin 页面样式(Phase 3 精简)
|
||||
*
|
||||
* 原始 477 行 → 精简至仅保留实际使用的 class。
|
||||
* 已迁移至 global.css / ui.css 的样式不再重复定义:
|
||||
* .xx-page-head → global.css
|
||||
* .xx-primary-btn → global.css
|
||||
* .xx-tag / .xx-card → ui.css / global.css
|
||||
*
|
||||
* 以下 class 仅被 AdminComingSoon.tsx 使用。
|
||||
*/
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-result-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.xx-result-center .ant-result {
|
||||
padding: 48px;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button, Card, Result } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./Admin.css"
|
||||
|
||||
const AdminComingSoon: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="admin-coming-soon-page">
|
||||
<div className="xx-result-center">
|
||||
<Card className="xx-card">
|
||||
<Result
|
||||
status="info"
|
||||
title="Admin 后台暂未开放"
|
||||
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
className="xx-primary-btn"
|
||||
>
|
||||
返回首页
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminComingSoon
|
||||
|
||||
export const Component = AdminComingSoon
|
||||
@@ -1,311 +0,0 @@
|
||||
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
|
||||
@@ -1020,6 +1020,11 @@
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-copy-head {
|
||||
display: flex;
|
||||
@@ -1050,7 +1055,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 360px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
padding: 28px 16px;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
@@ -1087,7 +1093,8 @@
|
||||
.vv-storyboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 360px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -1440,7 +1447,8 @@
|
||||
/* ── Preview ──────────────── */
|
||||
.vv-preview {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 360px;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 10px;
|
||||
@@ -2069,3 +2077,198 @@
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ── 分镜脚本流式预览(WebSocket delta 实时输出) ── */
|
||||
.vv-streaming-box {
|
||||
outline: 2px solid #ede9fe;
|
||||
background: linear-gradient(180deg, #fafafe 0%, #ffffff 100%);
|
||||
}
|
||||
.vv-streaming-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 10px 0;
|
||||
border-bottom: 1px dashed #ede9fe;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-streaming-title {
|
||||
font-size: 13px;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.vv-streaming-count {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
background: #f5f0ff;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.vv-streaming-doc {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 8px 2px;
|
||||
/* 复用 vv-sb-doc 的滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d8c4ff transparent;
|
||||
}
|
||||
.vv-streaming-doc::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.vv-streaming-doc::-webkit-scrollbar-thumb {
|
||||
background: #d8c4ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.vv-streaming-pre {
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #1f2937;
|
||||
tab-size: 2;
|
||||
}
|
||||
.vv-streaming-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
background: #7c3aed;
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
animation: vv-caret-blink 1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes vv-caret-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
.vv-streaming-actions {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.vv-streaming-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── 三列等高布局(2026-10-10 fix) ──
|
||||
* vv-page 作为纵向 flex 容器,tabs 固定 + grid 占满剩余高度;
|
||||
* vv-grid 改用 grid-auto-rows:1fr 强制三列等高;
|
||||
* 每列的卡片使用 flex column + min-height:0,正文区域 flex:1 并内部滚动。
|
||||
*/
|
||||
.vv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 减去顶部导航 56px 和自身 padding 16*2=32px,给 Tabs + 三列区留一屏 */
|
||||
height: calc(100vh - 56px);
|
||||
min-height: 640px; /* 小窗口保底 */
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-grid {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
align-items: stretch; /* 覆盖原来的 start,强制行内格子撑满行高 */
|
||||
grid-auto-rows: 1fr; /* 每行所有格子等高 */
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 列内的卡片(.vv-section)默认允许收缩,正文区域接管滚动 */
|
||||
.vv-col > .vv-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
/* STEP1/STEP2 唯一卡片:撑满列高 */
|
||||
.vv-col > .vv-section:only-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* STEP3 右侧两卡片:配置固定不缩,预览区吸剩余空间 */
|
||||
.vv-col > .vv-section-preview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.vv-section-preview .vv-section-body.vv-preview-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-section-preview .vv-preview {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 列内 .vv-credits / .vv-error / 主按钮 不参与收缩,永远固定在底部可见 */
|
||||
.vv-col > .vv-credits,
|
||||
.vv-col > .vv-error,
|
||||
.vv-col > .vv-btn-primary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-col > .vv-credits {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 可滚动 section 正文 */
|
||||
.vv-section-body.vv-section-body-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-scroll-inner {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 美化滚动条(紫白主题) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d8c4ff transparent;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-thumb {
|
||||
background: #d8c4ff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-thumb:hover {
|
||||
background: #c4a8ff;
|
||||
}
|
||||
.vv-scroll-inner::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 主按钮始终顶在 section-body 底部(在 vv-scroll-inner 之外) */
|
||||
.vv-section-body.vv-section-body-scroll > .vv-btn-primary {
|
||||
margin-top: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* STEP1 文件列表改为根据可用空间自适应,不再用 max-height:260px 硬编码 */
|
||||
.vv-file-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getViralVideoJob } from "@/api/viral-video"
|
||||
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
|
||||
import {
|
||||
isAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
type ViralVideoStatus,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
|
||||
|
||||
@@ -44,14 +49,21 @@ export function useViralVideoPolling(
|
||||
}
|
||||
if (stoppedRef.current) return
|
||||
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
|
||||
const inRender = job.progress_stage === "rendering"
|
||||
const stage = getJobStage(job)
|
||||
const inRender = stage === "rendering"
|
||||
// 分析阶段走默认间隔即可
|
||||
const isAnalyzing = isAnalysisStage(job.progress_stage)
|
||||
const isAnalyzing = isAnalysisStage(stage)
|
||||
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
|
||||
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
|
||||
} catch (_err) {
|
||||
failCountRef.current += 1
|
||||
if (stoppedRef.current) return
|
||||
// P2-5: 连续失败 20 次(约 2 分钟)后熔断,避免后端挂掉时前端无限轮询
|
||||
if (failCountRef.current >= 20) {
|
||||
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
|
||||
timerRef.current = setTimeout(() => pollOnce(id), delay)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
export interface ViralVideoDeltaEvent {
|
||||
type: "viral_video:script_delta"
|
||||
job_id: string
|
||||
stage: string
|
||||
progress: number
|
||||
message?: string
|
||||
data: {
|
||||
delta: string
|
||||
full_text: string
|
||||
text_length: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ViralVideoWSError {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ViralVideoTerminalEvent {
|
||||
type: "viral_video:completed" | "viral_video:failed" | "viral_video:copy_generated"
|
||||
job_id: string
|
||||
}
|
||||
|
||||
export interface UseViralVideoWSOptions {
|
||||
/** delta 事件回调(script_delta 推送时触发) */
|
||||
onDelta?: (ev: ViralVideoDeltaEvent) => void
|
||||
/** 连接关闭/失败回调 */
|
||||
onError?: (err: ViralVideoWSError) => void
|
||||
/** 连接建立回调 */
|
||||
onOpen?: () => void
|
||||
/** P2-6: 收到终态事件(copy_generated/completed/failed)时回调,上层应立即主动拉取一次最新 job,减少终态延迟 */
|
||||
onTerminal?: (ev: ViralVideoTerminalEvent) => void
|
||||
}
|
||||
|
||||
const WS_SCHEME =
|
||||
typeof window !== "undefined" && window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
function buildWsUrl(jobId: string): string {
|
||||
const token = localStorage.getItem("access_token") || ""
|
||||
const host = window.location.host
|
||||
return `${WS_SCHEME}//${host}/api/v1/viral-video/ws/${jobId}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 爆款视频 WebSocket 订阅 hook。
|
||||
* 仅在 jobId 非空时建立连接;组件卸载/ jobId 变更时自动关闭。
|
||||
* 失败静默:后端已说明流式异常会自动 fallback 到 HTTP 轮询的 copy_generated 终态,
|
||||
* 前端不需要中断主流程,连接失败时只记录不影响轮询继续推进 UI。
|
||||
*/
|
||||
export function useViralVideoWS(
|
||||
jobId: string | null | undefined,
|
||||
{ onDelta, onError, onOpen, onTerminal }: UseViralVideoWSOptions = {},
|
||||
) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const manualCloseRef = useRef(false)
|
||||
const onDeltaRef = useRef(onDelta)
|
||||
const onErrorRef = useRef(onError)
|
||||
const onOpenRef = useRef(onOpen)
|
||||
const onTerminalRef = useRef<UseViralVideoWSOptions["onTerminal"]>()
|
||||
onDeltaRef.current = onDelta
|
||||
onErrorRef.current = onError
|
||||
onOpenRef.current = onOpen
|
||||
onTerminalRef.current = onTerminal
|
||||
|
||||
const close = useCallback(() => {
|
||||
manualCloseRef.current = true
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
manualCloseRef.current = false
|
||||
let closed = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retries = 0
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
try {
|
||||
const ws = new WebSocket(buildWsUrl(jobId))
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (closed) return
|
||||
retries = 0
|
||||
onOpenRef.current?.()
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data)
|
||||
if (payload?.type === "viral_video:script_delta" && payload.data) {
|
||||
onDeltaRef.current?.(payload as ViralVideoDeltaEvent)
|
||||
} else if (
|
||||
payload?.type === "viral_video:completed" ||
|
||||
payload?.type === "viral_video:failed" ||
|
||||
payload?.type === "viral_video:copy_generated"
|
||||
) {
|
||||
// P2-6: 收到终态事件时主动通知上层立即拉取,降低终态延迟(不用等下一个轮询周期)
|
||||
onTerminalRef.current?.(payload as ViralVideoTerminalEvent)
|
||||
}
|
||||
// 心跳/其他事件忽略
|
||||
} catch {
|
||||
// 心跳/非 JSON 消息忽略
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
onErrorRef.current?.({ message: "websocket error" })
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
if (closed || manualCloseRef.current) return
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries += 1
|
||||
const delay = 500 * 2 ** (retries - 1)
|
||||
retryTimer = setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
onErrorRef.current?.({ message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟 100ms 再连,给后端 job 初始化留一点时间
|
||||
startTimer = setTimeout(connect, 100)
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
if (startTimer) clearTimeout(startTimer)
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
close()
|
||||
}
|
||||
}, [jobId, close])
|
||||
|
||||
return { close }
|
||||
}
|
||||
@@ -116,35 +116,6 @@ const appChildren: RouteObject[] = [
|
||||
path: "profile",
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/app/admin/ditto-emotion" replace />,
|
||||
},
|
||||
{
|
||||
path: "ditto-emotion",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/DittoEmotionConfig")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "monitor",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const appRoutes: RouteObject = {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import AdminComingSoon from "@/pages/admin/AdminComingSoon"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("AdminComingSoon Page", () => {
|
||||
it("should render without crashing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminComingSoon />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByText("Admin 后台暂未开放")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should render back button", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AdminComingSoon />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByText("返回首页")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -355,10 +355,44 @@ def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _refresh_oss_signed_url(raw: str) -> str:
|
||||
"""把我们自己 OSS 的 URL 重新签新鲜签名(默认2小时),避免前端上传时签的1h URL
|
||||
在阶段3被Wan/Seedance下载时过期(用户在文案页停留超过1小时导致Failed to download)。
|
||||
- 我们 OSS 域名的URL:提取storage_key,走get_download_url重签
|
||||
- 外链(公网资源、Ark/Seedream t2i产物等):原样返回
|
||||
- storage_key(无scheme):走get_download_url签公网URL
|
||||
"""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return raw
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return s
|
||||
try:
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
svc = get_storage_service()
|
||||
bucket = getattr(svc, "bucket_name", "") or ""
|
||||
if s.startswith("http://") or s.startswith("https://"):
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(s)
|
||||
host = (parsed.hostname or "").lower()
|
||||
is_our_oss = bool(bucket) and host.endswith(".aliyuncs.com") and host.startswith(f"{bucket}.")
|
||||
if is_our_oss:
|
||||
return svc.get_download_url(s, expires_seconds=7200)
|
||||
return s # 外链原样返回
|
||||
return svc.get_download_url(s.lstrip("/"), expires_seconds=7200)
|
||||
except Exception as _e:
|
||||
logger.warning("[爆款视频] URL重签失败回退原值: %s err=%s", s[:80], _e)
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_image_url(raw: str, idx: int) -> str:
|
||||
"""将 job.images 中的 storage_key/相对路径/空值统一归一化为可公网访问 URL。
|
||||
- http(s):// → 直接用
|
||||
- 其他 → storage_key,通过 SharedStorageService.get_url() 转公网 URL
|
||||
- OSS URL(含前端上传的签名URL):统一通过 _refresh_oss_signed_url 重签2h,
|
||||
避免用户在文案页停留超过1小时导致阶段3 Wan/Seedance 下载时签名过期
|
||||
- 外链(Ark/Seedream t2i产物、公网资源):原样返回
|
||||
- storage_key:签新鲜公网URL
|
||||
- 空值/非字符串 → 抛 ValueError
|
||||
"""
|
||||
if not raw or not isinstance(raw, str):
|
||||
@@ -366,17 +400,14 @@ def _normalize_image_url(raw: str, idx: int) -> str:
|
||||
url = raw.strip()
|
||||
if not url:
|
||||
raise ValueError(f"图片 #{idx} URL 为空白字符串")
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return url
|
||||
storage_key = url.lstrip("/")
|
||||
try:
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
url = get_storage_service().get_url(storage_key)
|
||||
except Exception as _e:
|
||||
raise ValueError(f"图片 #{idx} storage_key={storage_key!r} 转公网URL失败: {_e}") from _e
|
||||
logger.info("[爆款视频] 图片 #%d storage_key → 公网URL: %s", idx, url[:120])
|
||||
return url
|
||||
signed = _refresh_oss_signed_url(url)
|
||||
if not signed:
|
||||
raise ValueError(f"图片 #{idx} URL={url[:100]!r} 归一化失败")
|
||||
if signed != url and (url.startswith("http://") or url.startswith("https://")):
|
||||
logger.info("[爆款视频] 图片 #%d OSS URL 重签2h: %s", idx, signed[:120])
|
||||
elif not url.startswith("http"):
|
||||
logger.info("[爆款视频] 图片 #%d storage_key → 签名URL(2h): %s", idx, signed[:120])
|
||||
return signed
|
||||
|
||||
|
||||
# ── v8 图片分析结构归一化(老数据兼容只在这里做一次)────────────────────
|
||||
@@ -461,7 +492,9 @@ def _step_video_analysis(job: ViralVideoJob) -> dict | None:
|
||||
try:
|
||||
from viral_video.video_analyzer import analyze_video_style
|
||||
|
||||
style_guide = analyze_video_style(job.reference_video_url)
|
||||
# 参考视频URL重签(同样防止上传后停留过久签名过期)
|
||||
video_url = _refresh_oss_signed_url(job.reference_video_url)
|
||||
style_guide = analyze_video_style(video_url)
|
||||
return style_guide if isinstance(style_guide, dict) else None
|
||||
except ImportError as e:
|
||||
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e)
|
||||
@@ -525,6 +558,67 @@ def _viral_structure_hint(structure: str) -> str:
|
||||
return "【爆款结构:未指定】自由组织,保证开头有钩子、中段有卖点、结尾有行动引导"
|
||||
|
||||
|
||||
def _marketing_purpose_hint(mp: str) -> str:
|
||||
"""根据营销目的提供创作指导 hint。
|
||||
|
||||
覆盖前端 20 个 PURPOSES,分 4 类指导:
|
||||
- 短剧类:多角色对话,格式"角色:台词",禁止旁白,角色间镜头切换
|
||||
- 口播类:第一人称对镜头说话,真人出镜感
|
||||
- 门店类:店主或探店博主出镜讲解+场景展示
|
||||
- 品牌类:主演出镜说话或角色对白,禁止上帝视角旁白
|
||||
"""
|
||||
mp = (mp or "").strip()
|
||||
if not mp:
|
||||
return ""
|
||||
|
||||
# 短剧类(多角色对话)
|
||||
if any(k in mp for k in ["对话短剧", "反转短剧", "悬念短剧", "情绪短片"]):
|
||||
return "【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,禁止第三人称旁白解说。"
|
||||
|
||||
# 口播类(第一人称对镜头说话)
|
||||
if any(
|
||||
k in mp
|
||||
for k in ["口播带货", "促销转化", "功能演示", "痛点解决", "获客引流", "账号涨粉", "活动通知", "场景种草"]
|
||||
):
|
||||
return "【口播模式】第一人称对镜头说话,像真人出镜,有语气停顿和情绪,禁止第三人称旁白。"
|
||||
|
||||
# 门店类(店主/探店博主出镜)
|
||||
if any(k in mp for k in ["门店发现", "到店实录", "招牌体验", "同城团购"]):
|
||||
return "【门店模式】店主或探店博主出镜讲解+场景展示,第一人称或角色对白,禁止上帝视角旁白。"
|
||||
|
||||
# 品牌类(主演出镜或角色对白)
|
||||
if any(k in mp for k in ["品牌主张", "品牌故事", "生活方式", "创意概念"]):
|
||||
return "【品牌模式】主演出镜说话或角色对白,禁止上帝视角旁白,要有品牌调性和情感共鸣。"
|
||||
|
||||
# 未匹配的营销目的:通用 hint
|
||||
return "【通用模式】避免上帝视角旁白,优先第一人称或角色对话。"
|
||||
|
||||
|
||||
def _target_customer_hint(tc: str) -> str:
|
||||
"""根据目标客户提供台词风格指导。"""
|
||||
tc = (tc or "").strip()
|
||||
if not tc:
|
||||
return ""
|
||||
|
||||
# 年轻人
|
||||
if any(k in tc for k in ["18-25", "年轻", "学生", "Z世代", "95后", "00后"]):
|
||||
return "【目标年轻客群】台词要活泼、有梗、节奏快,可用网络流行语。"
|
||||
|
||||
# 中年人
|
||||
if any(k in tc for k in ["30-45", "中年", "家庭", "宝妈", "职场"]):
|
||||
return "【目标中年客群】台词要实用、有共鸣,强调性价比和品质。"
|
||||
|
||||
# 老年人
|
||||
if any(k in tc for k in ["50+", "老年", "退休", "银发"]):
|
||||
return "【目标老年客群】台词要清晰、慢节奏,强调健康和实惠。"
|
||||
|
||||
# 高端
|
||||
if any(k in tc for k in ["高端", "商务", "精英", "白领"]):
|
||||
return "【目标高端客群】台词要专业、有格调,强调品质和身份。"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _language_hint(language: str) -> str:
|
||||
"""根据 language 代码返回语言提示。"""
|
||||
lang = (language or "zh-CN").strip().lower()
|
||||
@@ -764,8 +858,9 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
ref_idx = xp.attr_int(ref_raw, -1) if ref_raw not in (None, "") else -1
|
||||
if not isinstance(ref_idx, int) or ref_idx < 0:
|
||||
ref_idx = None
|
||||
speaker = xp.text_of(body, "speaker") or "主播"
|
||||
if voice:
|
||||
voice_parts.append(voice)
|
||||
voice_parts.append(f"[speaker:{speaker}]{voice}")
|
||||
shot = {
|
||||
"time_range": a.get("time_range") or xp.text_of(body, "time_range") or f"{i * 3}-{(i + 1) * 3}秒",
|
||||
"shot_type_angle_movement": visual or "中景平视,固定镜头",
|
||||
@@ -974,7 +1069,6 @@ def _step_script_generation(
|
||||
last_emit = 0.0
|
||||
MIN_INTERVAL = 0.25 # 至少 250ms 一次,约 4 次/秒
|
||||
MIN_CHARS = 40 # 累积 ~40 字符才推送
|
||||
TAG_CLOSE_CHARS = ("</", "\n", ">", "\n\n")
|
||||
|
||||
def _flush(force: bool = False):
|
||||
nonlocal last_emit, pending_delta_buf
|
||||
@@ -1260,6 +1354,45 @@ def _resolve_tts_voice_id(job: ViralVideoJob) -> str:
|
||||
return raw_voice_id or default_voice
|
||||
|
||||
|
||||
def _parse_speaker_segments(text: str) -> list[tuple[str, str]]:
|
||||
"""解析 voiceover_script 中的 [speaker:xxx] 标记,返回 [(speaker, text), ...] 列表。"""
|
||||
import re
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# 匹配 [speaker:xxx]text 格式
|
||||
pattern = r"\[speaker:([^\]]+)\]([^\[]*)"
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
if not matches:
|
||||
# 没有 speaker 标记,返回单个默认角色
|
||||
return [("主播", text)]
|
||||
|
||||
return [(speaker.strip(), seg_text.strip()) for speaker, seg_text in matches if seg_text.strip()]
|
||||
|
||||
|
||||
def _get_speaker_voice_id(speaker: str, main_voice_id: str, is_main_voice_male: bool = True) -> str:
|
||||
"""根据角色名分配合适的音色 ID。
|
||||
|
||||
主角(店主/主播/老板等)使用用户选择的音色,配角自动分配异性音色。
|
||||
"""
|
||||
# 主角列表
|
||||
main_speakers = ["店主", "主播", "老板", "我", "主讲人", "店长"]
|
||||
|
||||
# 配角列表
|
||||
sub_speakers = ["顾客", "客人", "朋友", "闺蜜", "路人", "店员"]
|
||||
|
||||
if speaker in main_speakers or not any(s in speaker for s in sub_speakers):
|
||||
return main_voice_id
|
||||
|
||||
# 配角分配异性音色
|
||||
if is_main_voice_male:
|
||||
return "longxiaochun" # 女声
|
||||
else:
|
||||
return "longsanshu" # 男声
|
||||
|
||||
|
||||
def _step_tts(job: ViralVideoJob, voiceover_script: str):
|
||||
"""步骤 5: CosyVoice 整段配音 → 返回本地 MP3 Path;失败返回 None。
|
||||
|
||||
@@ -1729,11 +1862,13 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
|
||||
# reference_audios: TTS 音频驱动口型
|
||||
ref_audios = [tts_audio_url] if tts_audio_url else []
|
||||
# reference_images: 产品图(除首帧外的其他图作为多参考;首帧通过 image_url 传)
|
||||
images = list(job.images or [])
|
||||
# 阶段3进入时再次重签URL(用户可能在文案页停留超1h,阶段1签的URL已过期)
|
||||
images = [_refresh_oss_signed_url(u) for u in (job.images or []) if u]
|
||||
first_image = images[0] if images else None
|
||||
rest_images = images[1:30] if len(images) > 1 else []
|
||||
# reference_videos: 参考视频(可选)
|
||||
ref_videos = [job.reference_video_url] if getattr(job, "reference_video_url", "") else []
|
||||
# reference_videos: 参考视频(可选,也重签)
|
||||
_ref_vid = _refresh_oss_signed_url(getattr(job, "reference_video_url", "") or "")
|
||||
ref_videos = [_ref_vid] if _ref_vid else []
|
||||
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix=f"viral_{job.id}_"))
|
||||
logger.info(
|
||||
@@ -1923,7 +2058,7 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
|
||||
job.mark_running()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_hb_stop, _ = _start_heartbeat_thread(job_id)
|
||||
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
|
||||
|
||||
image_analysis = _step_image_analysis(job)
|
||||
@@ -1973,8 +2108,16 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
except Retry:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 流水线异常: %s", e, exc_info=True)
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
|
||||
logger.error("[爆款视频][pipeline] 旧一键流水线失败 job_id=%s: %s", job_id, e, exc_info=True)
|
||||
_err_stage = ""
|
||||
try:
|
||||
_r = SQLAlchemyViralVideoJobRepository(session)
|
||||
_j = _r.get(job_id)
|
||||
if _j is not None and getattr(_j, "current_stage", None):
|
||||
_err_stage = _j.current_stage
|
||||
except Exception:
|
||||
pass
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), _err_stage)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if _hb_stop is not None:
|
||||
@@ -1997,8 +2140,16 @@ def resume_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
except Retry:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 恢复流水线异常: %s", e, exc_info=True)
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
|
||||
logger.error("[爆款视频][resume-pipeline] 恢复流水线失败 job_id=%s: %s", job_id, e, exc_info=True)
|
||||
_err_stage = ""
|
||||
try:
|
||||
_r = SQLAlchemyViralVideoJobRepository(session)
|
||||
_j = _r.get(job_id)
|
||||
if _j is not None and getattr(_j, "current_stage", None):
|
||||
_err_stage = _j.current_stage
|
||||
except Exception:
|
||||
pass
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), _err_stage)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if session:
|
||||
@@ -2034,13 +2185,14 @@ def run_video_style_analysis(self: Task, job_id: str) -> dict:
|
||||
|
||||
def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage: str = "") -> None:
|
||||
"""标记任务失败并通知。若传入的 session 已失效(因前面异常导致 rollback 状态),
|
||||
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。"""
|
||||
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。
|
||||
BUG 修复:失败时持久化 stage 到 current_stage,前端才能正确归因错误。"""
|
||||
try:
|
||||
# 尝试用传入的 session 标记
|
||||
marked = False
|
||||
if job is not None and not job.is_terminal and session is not None:
|
||||
try:
|
||||
job.mark_failed(err_msg)
|
||||
job.mark_failed(err_msg, stage=stage)
|
||||
_save_job(repo, job, session)
|
||||
marked = True
|
||||
except Exception as se:
|
||||
@@ -2056,7 +2208,7 @@ def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage
|
||||
r = SQLAlchemyViralVideoJobRepository(ssn)
|
||||
j = r.get(job_id)
|
||||
if j is not None and not j.is_terminal:
|
||||
j.mark_failed(err_msg)
|
||||
j.mark_failed(err_msg, stage=stage)
|
||||
r.update(j)
|
||||
ssn.commit()
|
||||
finally:
|
||||
@@ -2095,7 +2247,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
|
||||
|
||||
job.mark_running()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_hb_stop, _ = _start_heartbeat_thread(job_id)
|
||||
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
|
||||
|
||||
image_analysis = _step_image_analysis(job)
|
||||
@@ -2186,7 +2338,7 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
|
||||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||||
job.touch_heartbeat()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_hb_stop, _ = _start_heartbeat_thread(job_id)
|
||||
|
||||
# v8:意图理解并入分镜生成,一次 LLM 调用;流式推送 script_delta 给前端
|
||||
image_analysis = normalize_image_analysis(job.image_analysis)
|
||||
@@ -2567,7 +2719,7 @@ def run_viral_video_render(self: Task, job_id: str) -> dict:
|
||||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||||
job.touch_heartbeat()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_hb_stop, _ = _start_heartbeat_thread(job_id)
|
||||
return _run_render_pipeline(job_id, session, repo, job)
|
||||
except Retry:
|
||||
raise
|
||||
|
||||
@@ -40,7 +40,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -244,6 +244,13 @@ class ViralVideoJob:
|
||||
self.heartbeat_at = _now
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = _now
|
||||
# 清空上一次渲染留下的错误/产物(参考 resume_from_image_analyzed 对齐)
|
||||
self.error_msg = ""
|
||||
self.result_video_url = ""
|
||||
self.current_stage = ""
|
||||
self.phase_message = ""
|
||||
self.completed_at = None
|
||||
self.credits_cost = 0.0
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
@@ -260,9 +267,12 @@ class ViralVideoJob:
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self, error_msg: str) -> None:
|
||||
def mark_failed(self, error_msg: str, stage: str = "") -> None:
|
||||
self.status = ViralVideoStatus.FAILED
|
||||
self.error_msg = error_msg
|
||||
# 失败时持久化失败阶段,避免前端错误归因
|
||||
if stage:
|
||||
self.current_stage = stage
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -408,7 +408,6 @@ class DoubaoClient:
|
||||
payload.update(kwargs)
|
||||
|
||||
_t0 = time.time()
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
_req_timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
@@ -454,7 +453,6 @@ class DoubaoClient:
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
|
||||
@@ -67,10 +67,12 @@ def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -
|
||||
return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}"
|
||||
if status_code == 0:
|
||||
return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。"
|
||||
# 任务内失败
|
||||
# 任务内失败(异步轮询结果):此时 HTTP 200 只是查询成功,不代表任务成功,提示文案不要带 HTTP 200 以免误导
|
||||
if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")):
|
||||
return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。"
|
||||
detail = msg_in_body or body[:200]
|
||||
if task_msg and status_code == 200:
|
||||
return "task_failed", f"Wan 3.0 视频生成失败:{detail}"
|
||||
return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,22 @@ source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
|
||||
echo "=== Installing mypy ==="
|
||||
python3 -m pip install -q mypy
|
||||
# pip�容错: 默认�(阿里云)缺文件时fallback到清�/官方�(2026-10-10 librt-0.6.0 metadata 404)
|
||||
MYPY_SPEC="mypy<1.19"
|
||||
INSTALL_OK=0
|
||||
python3 -m pip install -q "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: default pip index failed, retry tsinghua mirror..."
|
||||
python3 -m pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: tsinghua mirror failed, retry pypi.org..."
|
||||
python3 -m pip install -q -i https://pypi.org/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "ERROR: pip install mypy failed on all indexes"
|
||||
exit 1
|
||||
fi
|
||||
mypy --version
|
||||
echo ""
|
||||
echo "=== Running mypy type check (hard gate mode) ==="
|
||||
|
||||
@@ -108,8 +108,10 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -400,7 +400,7 @@ class TestMarkFailedAndNotifySessionFallback:
|
||||
job.mark_failed.assert_called()
|
||||
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
|
||||
fake_repo.get.assert_called_with("job-1")
|
||||
fake_job_in_db.mark_failed.assert_called_with("boom")
|
||||
fake_job_in_db.mark_failed.assert_called_with("boom", stage="render")
|
||||
fake_repo.update.assert_called_with(fake_job_in_db)
|
||||
fake_ssn.commit.assert_called()
|
||||
fake_ssn.close.assert_called()
|
||||
@@ -419,7 +419,7 @@ class TestMarkFailedAndNotifySessionFallback:
|
||||
with patch.object(vv, "_emit_progress") as mock_emit:
|
||||
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
|
||||
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
|
||||
job.mark_failed.assert_called_with("err")
|
||||
job.mark_failed.assert_called_with("err", stage="copy")
|
||||
mock_save.assert_called()
|
||||
mock_emit.assert_called_once()
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class TestRetryViralVideo:
|
||||
|
||||
assert job.status == ViralVideoStatus.PENDING
|
||||
assert job.retry_count == 2
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=["job-retry"])
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_analyze", args=["job-retry"])
|
||||
assert resp.id == "job-retry"
|
||||
|
||||
def test_retry_without_body_keeps_original_params(self):
|
||||
|
||||
Reference in New Issue
Block a user