Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot 6e66c49e09 style: auto-format with black + isort + ruff + prettier [skip ci-format-check] 2026-10-09 11:40:27 +00:00
47 changed files with 1379 additions and 2316 deletions
+1 -1
View File
@@ -437,7 +437,7 @@ jobs:
if: "always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'"
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 20
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
+1 -8
View File
@@ -28,17 +28,10 @@ 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
# 找不到则取最新版本
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)
raise RuntimeError("default template missing: %s v%s" % (prompt_type, version))
def _upsert(bind, t: dict) -> None:
@@ -1,23 +1,27 @@
"""add language column to viral_video_jobs
Revision ID: 107
Revises: 106_system_settings
Revises: 106
Create Date: 2026-10-09
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "107"
down_revision = "106_system_settings"
down_revision = "106"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs " "ADD COLUMN IF NOT EXISTS language VARCHAR(20) NOT NULL DEFAULT 'zh-CN'")
with op.batch_alter_table("viral_video_jobs") as batch_op:
batch_op.add_column(sa.Column("language", sa.String(20), nullable=False, server_default="zh-CN"))
def downgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS language")
with op.batch_alter_table("viral_video_jobs") as batch_op:
batch_op.drop_column("language")
@@ -1,235 +0,0 @@
# -*- 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
View File
@@ -89,7 +89,6 @@ def get_balance(
is_member=_is_member(current_user),
member_type=_member_type(current_user),
member_expires_at=getattr(current_user.user, "member_expires_at", None),
credits_enabled=_credits_enabled(),
)
+3 -12
View File
@@ -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,20 +710,11 @@ 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_analyze", args=[job.id])
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info(
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
job.id,
-1
View File
@@ -19,7 +19,6 @@ class PointsBalanceResponse(BaseModel):
is_member: bool = Field(default=False, description="是否付费会员")
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
credits_enabled: bool = Field(default=False, description="积分系统是否启用(false=免费放行不扣分)")
# ============ 流水 ============
+139 -132
View File
@@ -11,34 +11,12 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
// 单一通用路由(Playwright 按注册逆序匹配,故不拆分多个 glob 以免相互遮蔽)
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
// /points/balance:Staging 后端积分总开关关闭(credits_enabled=false,免费放行)。
// 旧 bundle 部署的接口暂未返回该字段、且预校验只看余额,这里同时补开关与余额,
// 以验证“免费期不拦截生成”;新后端+新前端部署后读 credits_enabled=false 直接放行,
// 余额被忽略,此补丁随之成为 no-op。
if (sourceUrl.pathname.endsWith("/points/balance")) {
const body = await response.json().catch(() => ({}))
await route.fulfill({
response,
json: { ...body, credits_enabled: false, balance: 999999 },
})
return
}
await route.fulfill({ response })
} catch {
// 测试收尾时页面/上下文可能已关闭,忽略仍在途的请求,避免误判为失败
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -67,13 +45,7 @@ async function loginWithRetry(
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{
token: string
user: Record<string, unknown>
libraryId: string
assetId: string
suffix: string
}> {
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
@@ -81,19 +53,7 @@ async function setupFreshUser(
})
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
const me = await request.get(`${apiBase}/auth/me`, { headers: auth })
expect(me.ok(), `fetch profile: ${await me.text()}`).toBeTruthy()
const user = (await me.json()) as Record<string, unknown>
// 预置一条文案:标题下拉候选来自文案库,新用户为空会导致无法选中标题
await request.post(`${apiBase}/scripts`, {
headers: auth,
data: {
title: `测试标题 ${suffix}`,
content: `测试文案内容 ${suffix}`,
tags: ["e2e"],
},
})
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
@@ -133,7 +93,7 @@ async function setupFreshUser(
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, user, libraryId, assetId, suffix }
return { token, libraryId, assetId, suffix }
}
/**
@@ -152,7 +112,7 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "random")
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
// 确保默认模板存在(智能剪辑页依赖模板)
@@ -166,24 +126,13 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
expect(templates.length).toBeGreaterThan(0)
// 注入登录态 + 路由 API
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
@@ -229,28 +178,16 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 标题为 antd AutoComplete(combobox),真实 input 带 placeholder
// antd AutoComplete combobox:真实可输入元素是 .ant-select-selection-search-input,
// 灰色提示语是单独的 placeholder span(input 自身无 placeholder 属性)
// 标题候选来自文案库(setupFreshUser 已预置一条文案)。
// combobox 的自由输入会在失焦时被 rc-select 重置,必须从下拉选中才提交,
// 因此聚焦输入框 → 点击候选选项
const seededTitle = `测试标题 ${suffix}`
const titleBox = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox.click()
await page.locator(".ant-select-item-option", { hasText: seededTitle }).first().click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -267,11 +204,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskBody = await taskResp.json()
const taskId = taskBody.items?.[0]?.id ?? taskBody.id ?? taskBody.task_id
expect(taskId, "created task should return an id").toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
})
@@ -281,28 +216,113 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "narrative")
const { token, suffix } = await setupFreshUser(request, "narrative")
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts)
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
})
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
@@ -314,38 +334,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText(`测试标题 ${suffix}`).first().click()
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("龙小夏").first().click()
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
// 真实阿里云 CosyVoice 合成耗时偶有波动,放宽到 90s
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 90000 })
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/).first()).toBeVisible()
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
// (#2048: PreviewCountModal 已移除)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 选中文案后标题框已预填该文案标题,下拉按当前输入过滤,直接选中该选项确认
const titleBox2 = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox2.click()
await page
.locator(".ant-select-item-option", { hasText: `测试标题 ${suffix}` })
.first()
.click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -361,11 +371,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
const taskBody2 = await taskResp2.json()
const taskId2 = taskBody2.items?.[0]?.id ?? taskBody2.id ?? taskBody2.task_id
expect(taskId2, "created task should return an id").toBeTruthy()
console.log("[narrative] Generation task created:", taskId2)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
})
})
+7 -15
View File
@@ -11,20 +11,11 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
} catch {
// 收尾时页面可能已关闭,忽略在途请求避免误判
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -54,7 +45,8 @@ type LibraryResponse = { id: string }
test.describe("Core media upload flow", () => {
test.describe.configure({ timeout: 180_000 })
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
// 串行执行时登录可能触发 429,两次退避约 130s,沿用 describe 的 180s 超时
test.setTimeout(120_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-mov-${suffix}@example.com`
+76
View File
@@ -0,0 +1,76 @@
/**
* 后台管理 API client(#2246)
*
* 独立 axios 实例:不经过主 apiClient 的 Bearer token / 401 刷新逻辑,
* 后台鉴权使用 X-API-Key(存 localStorage,不硬编码)。
*/
import axios from "axios"
export const ADMIN_API_KEY_STORAGE = "ditto_admin_api_key"
export type ConfigType = "bool" | "int" | "float" | "string" | "json"
export interface ConfigItem {
key: string
value: unknown
default: unknown
type: ConfigType
description: string
model_options?: string[]
}
export interface TestResult {
ok: boolean
enabled?: boolean
segments?: Array<{ text: string; emo: number; intensity: number }>
error?: string
}
export function getAdminApiKey(): string {
return localStorage.getItem(ADMIN_API_KEY_STORAGE) || ""
}
export function setAdminApiKey(key: string): void {
localStorage.setItem(ADMIN_API_KEY_STORAGE, key)
}
export function clearAdminApiKey(): void {
localStorage.removeItem(ADMIN_API_KEY_STORAGE)
}
function createAdminClient() {
const client = axios.create({
baseURL: "/api/v1",
timeout: 60000,
headers: { "Content-Type": "application/json" },
})
client.interceptors.request.use((config) => {
const key = getAdminApiKey()
if (key && config.headers) {
config.headers["X-API-Key"] = key
}
return config
})
return client
}
const adminClient = createAdminClient()
export async function fetchConfig(): Promise<ConfigItem[]> {
const { data } = await adminClient.get("/admin/ditto-emotion/config")
return data.configs as ConfigItem[]
}
export async function updateConfig(
configs: Record<string, unknown>,
): Promise<{ ok: boolean; updated?: Record<string, unknown>; error?: string }> {
const { data } = await adminClient.put("/admin/ditto-emotion/config", { configs })
return data
}
export async function testConfig(testText: string): Promise<TestResult> {
const { data } = await adminClient.post("/admin/ditto-emotion/config/test", {
test_text: testText,
})
return data as TestResult
}
-41
View File
@@ -1,41 +0,0 @@
/**
* 运行时开关安全读取
*
* 背景:本项目使用 Vite 构建,浏览器运行时不存在 Node 的全局 `process`。
* 直接写 `process.env.XXX` 会在模块加载阶段抛出 `ReferenceError: process is not defined`,
* 导致整个页面白屏崩溃。
*
* 统一通过本模块读取这类仅在构建/调试期注入的布尔开关:
* - 优先读取 Vite 的 `import.meta.env.VITE_XXX`
* - 兼容历史上未加 VITE_ 前缀、经由 Node 环境(单测 / 旧构建脚本)注入的 `process.env.XXX`
* - 任何情况下访问失败都安全返回 false(默认走真实后端 API,不启用 mock)
*/
/** 从可能不存在的 Node 全局 process 上安全读取环境变量 */
function readNodeEnv(name: string): string | undefined {
try {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
return proc?.env?.[name]
} catch {
return undefined
}
}
/**
* 读取运行时布尔开关。
*
* @param name 开关名(不含 VITE_ 前缀的历史名称,如 POINTS_API_MOCK)
* @returns 开关是否显式置为 "true";未设置或读取失败时为 false
*/
export function readRuntimeFlag(name: string): boolean {
// Vite 注入的环境变量(需 VITE_ 前缀才会暴露到浏览器)
const viteKey = `VITE_${name}`
const viteEnv = (import.meta as unknown as { env?: Record<string, string | undefined> }).env
const viteVal = viteEnv?.[viteKey] ?? viteEnv?.[name]
// 兼容 Node 环境下无前缀的历史变量名
const nodeVal = readNodeEnv(name)
const raw = viteVal ?? nodeVal
return raw === "true"
}
+8 -9
View File
@@ -8,7 +8,6 @@
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
PointsBalance,
PointsRulesResponse,
@@ -202,7 +201,7 @@ const MOCK_MEMBERSHIP: MembershipResponse = {
/** 获取积分余额 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_BALANCE }
}
@@ -212,7 +211,7 @@ export async function getPointsBalance(): Promise<PointsBalance> {
/** 获取积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
}
@@ -222,7 +221,7 @@ export async function getPointsRules(): Promise<PointsRulesResponse> {
/** 获取充值包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
}
@@ -237,7 +236,7 @@ export async function getPointsTransactions(
page = 1,
pageSize = 20,
): Promise<PointsTransactionsResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const start = (page - 1) * pageSize
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
@@ -262,7 +261,7 @@ export async function getPointsTransactions(
export async function createPointsOrder(
data: CreateRechargeOrderRequest,
): Promise<CreateRechargeOrderResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
if (!pkg) throw new Error("充值包不存在")
@@ -286,7 +285,7 @@ export async function createPointsOrder(
* 积分预检查(消耗前调用)
*/
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
if (!rule) {
@@ -327,7 +326,7 @@ export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheck
/** 获取每日免费额度使用情况 */
export async function getDailyUsage(): Promise<DailyUsage> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_DAILY_USAGE }
}
@@ -337,7 +336,7 @@ export async function getDailyUsage(): Promise<DailyUsage> {
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
export async function getMembership(): Promise<MembershipResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_MEMBERSHIP }
}
-2
View File
@@ -50,8 +50,6 @@ export interface PointsBalance {
member_type: "monthly" | "quarterly" | "yearly" | null
/** 会员到期时间 */
member_expires_at: ISODate | null
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
credits_enabled?: boolean
}
/* ================================================================
+1 -2
View File
@@ -3,7 +3,6 @@
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
ScriptItem,
ScriptListParams,
@@ -17,7 +16,7 @@ import type {
* #1894:文案库接口已上线,默认 false 走真实 API;
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
*/
export const SCRIPTS_API_MOCK = readRuntimeFlag("SCRIPTS_API_MOCK")
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
// ==================== Mock 数据 ====================
@@ -6,7 +6,6 @@
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
SubscriptionInfo,
SubscriptionPlan,
@@ -73,7 +72,7 @@ const MOCK_PLANS: SubscriptionPlan[] = [
const MOCK_BILLING: BillingRecord[] = []
const isMock = () => readRuntimeFlag("POINTS_API_MOCK")
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
/** 获取当前订阅 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
-20
View File
@@ -65,18 +65,6 @@ 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 */
@@ -101,8 +89,6 @@ export interface ImageAnalysisResult {
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 说话人角色,如 "店主"、"顾客" */
speaker?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
@@ -203,14 +189,8 @@ 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,6 +8,11 @@ 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": "账单管理",
+7
View File
@@ -13,6 +13,7 @@ import {
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
@@ -206,6 +207,12 @@ 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: "会员订阅",
+27
View File
@@ -0,0 +1,27 @@
/* 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;
}
@@ -0,0 +1,35 @@
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
@@ -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
+4 -8
View File
@@ -38,8 +38,6 @@ const GeneratePage: React.FC = () => {
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
// 后端积分总开关(由 /points/balance 返回;数据未就绪时保守按 UI 开关处理)
const creditsEnabled = balance?.credits_enabled ?? ENABLE_CREDIT_SYSTEM
useEffect(() => {
initPoints()
}, [initPoints])
@@ -344,8 +342,7 @@ const GeneratePage: React.FC = () => {
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
// 仅当后端积分系统真实启用时才做余额预校验(后端免费放行时前端不得拦截)
if (creditsEnabled) {
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
@@ -391,7 +388,6 @@ const GeneratePage: React.FC = () => {
balance,
dailyUsage,
rules,
creditsEnabled,
])
/* ── 步骤导航 ── */
@@ -506,7 +502,7 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(() => {
if (!creditsEnabled) return { sufficient: true, cost: 0 }
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
@@ -515,8 +511,8 @@ const GeneratePage: React.FC = () => {
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules, creditsEnabled])
const insufficientPoints = creditsEnabled && !pointsEstimate.sufficient
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -1020,11 +1020,6 @@
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;
@@ -1055,8 +1050,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-height: 260px;
height: 360px;
padding: 28px 16px;
gap: 10px;
background: #fff;
@@ -1093,8 +1087,7 @@
.vv-storyboard {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 260px;
height: 360px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e7eb;
@@ -1447,8 +1440,7 @@
/* ── Preview ──────────────── */
.vv-preview {
aspect-ratio: 9 / 16;
max-height: 100%;
max-width: 100%;
max-height: 360px;
background: #fff;
border: 1px dashed #e5e7eb;
border-radius: 10px;
@@ -2077,198 +2069,3 @@
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,6 @@
import { useCallback, useEffect, useRef } from "react"
import { getViralVideoJob } from "@/api/viral-video"
import { isAnalysisStage, getJobStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
@@ -44,21 +44,14 @@ export function useViralVideoPolling(
}
if (stoppedRef.current) return
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const stage = getJobStage(job)
const inRender = stage === "rendering"
const inRender = job.progress_stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(stage)
const isAnalyzing = isAnalysisStage(job.progress_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)
}
@@ -1,151 +0,0 @@
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 }
}
+29
View File
@@ -116,6 +116,35 @@ 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 = {
@@ -0,0 +1,32 @@
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()
})
})
+21 -205
View File
@@ -29,7 +29,7 @@ import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any
from celery import Task, shared_task
from celery.exceptions import Retry
@@ -764,9 +764,8 @@ 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(f"[speaker:{speaker}]{voice}")
voice_parts.append(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 "中景平视,固定镜头",
@@ -792,12 +791,8 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
return base
def _step_script_generation(
job: ViralVideoJob,
image_analysis: dict,
on_delta: Optional[Callable[[str, str], None]] = None,
) -> dict:
"""步骤: 意图理解 + 分镜生成一次完成(v3,模板 + XML 解析,支持流式推送)。"""
def _step_script_generation(job: ViralVideoJob, image_analysis: dict) -> dict:
"""步骤: 意图理解 + 分镜生成一次完成(v3,模板 + XML 解析)。"""
try:
from packages.application.viral_video.prompt_loader import (
get_template,
@@ -933,140 +928,6 @@ def _step_script_generation(
return normalized
# ── 流式内部函数 ──────────────────────────────────────────────────────
def _stream_chat_with_fallback(client, messages, temp, max_tok, tmo):
"""流式调用,失败/超时则降级到同步 chat_completion;yield 每段 delta 文本。
返回 (full_text, used_stream)。
"""
full_parts = []
stream_ok = False
if client and client.is_available and on_delta is not None and hasattr(client, "chat_completion_stream"):
try:
for chunk in client.chat_completion_stream(
messages, temperature=temp, max_tokens=max_tok, timeout=tmo or 120
):
if chunk:
full_parts.append(chunk)
yield ("delta", chunk)
stream_ok = True
except Exception as e:
logger.warning("[爆款视频] 流式调用失败,降级同步: %s", e, exc_info=True)
full_parts = [] # 重置,走同步
if not stream_ok:
raw = client.chat_completion(messages, temperature=temp, max_tokens=max_tok, timeout=tmo)
if raw:
full_parts = [raw]
yield ("delta", raw) # 一次性推送完整文本(同步回退)
else:
full_parts = []
yield ("done", "".join(full_parts))
def _try_gen_stream(client, temp: float, max_tok: int, label: str, tmo: int, user_text: str = None):
"""流式版本 _try_gen:边收 token 边调 on_delta,最终返回 normalized dict 或 None。"""
if not client or not client.is_available:
return None
_u = user_text if user_text is not None else user
messages = [{"role": "system", "content": system}, {"role": "user", "content": _u}]
logger.info("[爆款视频] 分镜生成(流式) model=%s label=%s timeout=%d", client.model, label, tmo)
full_text_buf = []
pending_delta_buf = []
last_emit = 0.0
MIN_INTERVAL = 0.25 # 至少 250ms 一次,约 4 次/秒
MIN_CHARS = 40 # 累积 ~40 字符才推送
def _flush(force: bool = False):
nonlocal last_emit, pending_delta_buf
if not pending_delta_buf:
return
now = time.time()
if not force and (now - last_emit) < MIN_INTERVAL:
return
delta_text = "".join(pending_delta_buf)
pending_delta_buf = []
full_text = "".join(full_text_buf)
last_emit = now
try:
on_delta(delta_text, full_text)
except Exception as e:
logger.warning("[爆款视频] on_delta 回调失败: %s", e)
final_raw = None
try:
for kind, payload in _stream_chat_with_fallback(client, messages, temp, max_tok, tmo):
if kind == "delta":
full_text_buf.append(payload)
pending_delta_buf.append(payload)
# 判断是否触发推送
buf_text = "".join(pending_delta_buf)
should_flush = False
if len(buf_text) >= MIN_CHARS:
should_flush = True
elif any(tok in buf_text for tok in ("\n", "</", "/>", ">\n")):
# 换行或 XML 标签闭合时尽早 flush
if len(buf_text) >= 10:
should_flush = True
if should_flush:
_flush(force=False)
elif kind == "done":
final_raw = payload
except Exception as e:
logger.warning("[爆款视频] 流式生成异常 label=%s err=%s", label, e, exc_info=True)
return None
_flush(force=True) # 剩余全部推送
if not final_raw:
return None
normalized = _script_from_xml(final_raw, job)
if normalized is None:
parsed_json = _safe_json_loads(final_raw)
if isinstance(parsed_json, dict):
normalized = _validate_and_normalize_script(parsed_json, job)
else:
return None
voiceover = normalized.get("voiceover_script") or ""
shots_cnt = len(normalized.get("shots") or [])
is_fallback = shots_cnt < 1 or len(voiceover) < 12
logger.info(
"[爆款视频] 分镜结果(流式) label=%s voiceover_len=%d shots_cnt=%d fallback=%s",
label,
len(voiceover),
shots_cnt,
is_fallback,
)
if is_fallback:
return None
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
_max_chars = _dur * 3
_voiceover_chars = len(voiceover.strip())
_min_chars = max(10, int(_dur * 2.2))
if _voiceover_chars > _max_chars:
logger.warning(
"[爆款视频] 口播超长(流式) label=%s voiceover_chars=%d max=%d", label, _voiceover_chars, _max_chars
)
return None
if _voiceover_chars < _min_chars:
logger.warning(
"[爆款视频] 口播过短(流式) label=%s voiceover_chars=%d min=%d", label, _voiceover_chars, _min_chars
)
return None
_shots = normalized.get("shots") or []
_shot_count = len(_shots)
_expected_range = _get_expected_shot_count(_dur)
if _expected_range and (_shot_count < _expected_range[0] or _shot_count > _expected_range[1]):
logger.warning(
"[爆款视频] 镜头数量不符(流式) label=%s shots=%d expected=%s", label, _shot_count, _expected_range
)
return None
_time_valid = _validate_shot_timeline(_shots, _dur)
if not _time_valid:
logger.warning("[爆款视频] 时间轴不合法(流式) label=%s dur=%ds", label, _dur)
return None
return normalized
client_fast = ai_router.get_llm_client("storyboard", variant="primary")
client_pro = ai_router.get_llm_client("storyboard", variant="lite")
fast_tmo = int(os.environ.get("VIRAL_VIDEO_SCRIPT_FAST_TIMEOUT", "90"))
@@ -1079,16 +940,13 @@ def _step_script_generation(
_char_hint = f"口播总字数严格控制在 {_min_chars}~{_max_chars} 字({_dur}秒视频),超长会导致配音失败"
user = user + "\n\n" + _char_hint
# 选择 _try_gen 实现:有 on_delta 用流式,否则保持原同步逻辑
_do_gen = _try_gen_stream if on_delta is not None else _try_gen
try:
result = _do_gen(client_fast, 0.8, 2500, "fast-first", fast_tmo)
result = _try_gen(client_fast, 0.8, 2500, "fast-first", fast_tmo)
if result is not None:
return result
if time.time() > deadline:
return _finalize_fallback_script(job)
result = _do_gen(client_fast, 0.6, 3200, "fast-retry", fast_tmo)
result = _try_gen(client_fast, 0.6, 3200, "fast-retry", fast_tmo)
if result is not None:
return result
# Bug2: 压缩重试 — 用更严格约束要求 LLM 压缩口播
@@ -1096,12 +954,12 @@ def _step_script_generation(
user_compressed = user + "\n\n【紧急】上一次生成口播超长,请将口播压缩到 {} 字以内,保留核心卖点。".format(
_max_chars
)
compressed_result = _do_gen(client_fast, 0.5, 2000, "compress-retry", fast_tmo, user_text=user_compressed)
compressed_result = _try_gen(client_fast, 0.5, 2000, "compress-retry", fast_tmo, user_text=user_compressed)
if compressed_result is not None:
return compressed_result
if client_pro and client_pro.is_available and client_pro.model != client_fast.model:
if time.time() <= deadline:
result = _do_gen(client_pro, 0.7, 3500, "pro-fallback", pro_tmo)
result = _try_gen(client_pro, 0.7, 3500, "pro-fallback", pro_tmo)
if result is not None:
return result
return _finalize_fallback_script(job)
@@ -1923,7 +1781,7 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
job.mark_running()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
@@ -1973,16 +1831,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
except Retry:
raise
except Exception as 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)
logger.error("[爆款视频] 流水线异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if _hb_stop is not None:
@@ -2005,16 +1855,8 @@ def resume_viral_video_pipeline(self: Task, job_id: str) -> dict:
except Retry:
raise
except Exception as 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)
logger.error("[爆款视频] 恢复流水线异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
@@ -2050,14 +1892,13 @@ 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 重新标记,确保状态一定落库。
BUG 修复:失败时持久化 stage 到 current_stage,前端才能正确归因错误。"""
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。"""
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, stage=stage)
job.mark_failed(err_msg)
_save_job(repo, job, session)
marked = True
except Exception as se:
@@ -2073,7 +1914,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, stage=stage)
j.mark_failed(err_msg)
r.update(j)
ssn.commit()
finally:
@@ -2112,7 +1953,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
job.mark_running()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
@@ -2203,37 +2044,12 @@ 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, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
# v8:意图理解并入分镜生成,一次 LLM 调用;流式推送 script_delta 给前端
# v8:意图理解并入分镜生成,一次 LLM 调用
image_analysis = normalize_image_analysis(job.image_analysis)
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在编排分镜脚本...")
_script_full_text = []
_script_last_emit_ts = [0.0]
_script_stage_start = time.time()
def _on_script_delta(delta: str, full_text: str):
"""流式回调:推 viral_video:script_delta 事件到 Redis pub/sub(WS 桥接前端)。"""
_script_full_text.append(delta) if delta else None
now = time.time()
# 速率保护:on_delta 已经做了基础节流;这里再加一道 200ms 兜底防止消息风暴
if now - _script_last_emit_ts[0] < 0.2:
return
_script_last_emit_ts[0] = now
# 进度估算:基于 full_text 长度线性增长(最长 ~3500 字 = 70% 进度位)
_est_progress = min(70.0, 15.0 + (len(full_text) / 3500.0) * 55.0)
_elapsed = now - _script_stage_start
_emit_progress(
job_id,
ViralVideoStage.SCRIPT_GENERATION,
round(_est_progress, 1),
f"正在编排分镜脚本...({len(full_text)}字,{_elapsed:.0f}s)",
{"delta": delta, "full_text": full_text, "text_length": len(full_text)},
event_type="viral_video:script_delta",
)
copy_result = _step_script_generation(job, image_analysis, on_delta=_on_script_delta)
copy_result = _step_script_generation(job, image_analysis)
_emit_progress(
job_id,
ViralVideoStage.SCRIPT_GENERATION,
@@ -2584,7 +2400,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, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
return _run_render_pipeline(job_id, session, repo, job)
except Retry:
raise
+1 -3
View File
@@ -40,9 +40,7 @@ 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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+1 -3
View File
@@ -39,9 +39,7 @@ 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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+1 -11
View File
@@ -244,13 +244,6 @@ 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:
@@ -267,12 +260,9 @@ class ViralVideoJob:
self.completed_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
def mark_failed(self, error_msg: str, stage: str = "") -> None:
def mark_failed(self, error_msg: 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)
-105
View File
@@ -363,111 +363,6 @@ class DoubaoClient:
logger.error("豆包API调用最终失败: elapsed=%.1fs err=%s", time.time() - _t0, last_error)
return None
def chat_completion_stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int | None = None,
model: str | None = None,
timeout: int | None = None,
**kwargs,
):
"""流式调用 Chat Completion 接口(SSE),逐块 yield delta 文本。
Yields:
str: 增量文本片段(delta.content);全部结束后 StopIteration。
失败时 yield 空并返回(由调用方决定是否降级到同步调用)。
注意:
- 流式不做 finish_reason=length 自动扩容(流式难以拼接重试);
如果调用方需要 length 截断处理,建议自行 fallback 到同步 chat_completion。
- 重试只在连接建立阶段(首包之前)有效;一旦开始 yield,错误直接抛出。
"""
if not self.is_available:
return
import json as _json
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
effective_max_tokens = max_tokens if max_tokens is not None else (self.max_tokens or 1024)
payload: dict[str, Any] = {
"model": model or self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": effective_max_tokens,
"stream": True,
}
if self.extra_params:
payload.update(self.extra_params)
if kwargs:
payload.update(kwargs)
_t0 = time.time()
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)
if timeout:
_req_timeout = httpx.Timeout(connect=10.0, read=max(int(timeout), 30), write=30.0, pool=10.0)
with httpx.stream(
"POST",
url,
headers=headers,
json=payload,
timeout=_req_timeout,
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = _json.loads(data_str)
except (_json.JSONDecodeError, ValueError):
continue
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content_piece = delta.get("content") or ""
if content_piece:
yield content_piece
finish_reason = choices[0].get("finish_reason")
if finish_reason:
self.last_finish_reason = finish_reason
_elapsed = time.time() - _t0
logger.info(
"[doubao] chat_completion_stream 完成 model=%s elapsed=%.1fs attempt=%d",
payload.get("model"),
_elapsed,
attempt + 1,
)
return
except Exception as e:
if attempt < self.max_retries:
wait = 0.5 * (2**attempt)
logger.warning(
"豆包流式API失败,%.1fs后重试 (%d/%d, elapsed=%.1fs): %s",
wait,
attempt + 1,
self.max_retries + 1,
time.time() - _t0,
e,
)
time.sleep(wait)
continue
logger.error("豆包流式API最终失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
return
def vision_completion(
self,
messages: list[dict],
+1 -3
View File
@@ -67,12 +67,10 @@ 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}"
-19
View File
@@ -12,22 +12,3 @@ export CI_LOCAL_PG_PORT="${CI_LOCAL_PG_PORT:-5432}"
# === 默认数据库名 ===
export CI_DEFAULT_DB="${CI_DEFAULT_DB:-xiaoxia_saas}"
# === Python 版本保障:本项目依赖 datetime.UTC,需要 Python >= 3.11 ===
_ensure_python311() {
if python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
return 0
fi
for cand in python3.12 python3.11 /opt/python3.12/bin/python3; do
if command -v "$cand" >/dev/null 2>&1 && "$cand" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
_d="$(dirname "$(command -v "$cand")")"
export PATH="$_d:$PATH"
hash -r
echo "✅ ci_env: 切换到 $cand ($("$cand" -c 'import sys; print(sys.version.split()[0])'))"
return 0
fi
done
echo "❌ ci_env: 未找到 Python >= 3.11(datetime.UTC 需要),请安装 Python 3.11/3.12" >&2
return 1
}
_ensure_python311
+1 -21
View File
@@ -4,28 +4,8 @@
set -e
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=ci_env.sh
source "${SCRIPT_DIR}/ci_env.sh"
echo "=== Installing 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
python3 -m pip install -q mypy
mypy --version
echo ""
echo "=== Running mypy type check (hard gate mode) ==="
+2 -22
View File
@@ -7,29 +7,9 @@ JOB_NAME="${1:-Unit Tests}"
echo "=== CI Unit Tests 开始 ==="
# --- Python 版本选择(必须 >= 3.11,代码使用 datetime.UTC)---
if ! python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
for cand in python3.12 python3.11 /opt/python3.12/bin/python3; do
if command -v "$cand" >/dev/null 2>&1 && "$cand" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
PY3_DIR="$(dirname "$(command -v "$cand")")"
export PATH="$PY3_DIR:$PATH"
hash -r
echo "✅ python3 版本过低,改用 $cand ($("$cand" -c "import sys; print(sys.version.split()[0])"))"
break
fi
done
if ! python3 -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then
echo "❌ 未找到 Python >= 3.11,本项目要求 Python 3.11+(使用 datetime.UTC)" >&2
exit 1
fi
fi
PYVER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
echo "使用 Python 版本: $(python3 --version)"
# --- 依赖缓存检查(按 Python 版本区分缓存文件,避免跨版本复用)---
# --- 依赖缓存检查 ---
# 如果 requirements 文件未变化且依赖已安装,跳过 pip install(持久 runner 优化)
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash_py${PYVER}"
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash"
CURRENT_REQ_HASH=""
if [ -f requirements-base.txt ] && [ -f requirements.txt ] && [ -f requirements-dev.txt ]; then
CURRENT_REQ_HASH=$(cat requirements-base.txt requirements.txt requirements-dev.txt | md5sum | cut -d' ' -f1)
-5
View File
@@ -77,11 +77,6 @@ set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=ci_env.sh
source "${SCRIPT_DIR}/ci_env.sh"
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
+2 -4
View File
@@ -108,10 +108,8 @@ 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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
-156
View File
@@ -196,159 +196,3 @@ class TestGetDoubaoClient:
"""返回 DoubaoClient 实例"""
client = get_doubao_client()
assert isinstance(client, DoubaoClient)
class TestChatCompletionStream:
"""chat_completion_stream 方法测试"""
def _fake_sse(self, chunks: list[str]):
"""构造一个伪装的 httpx stream 响应,按 chunks 逐行返回 SSE。"""
import json as _json
lines = []
for piece in chunks:
evt = {"choices": [{"delta": {"content": piece}, "finish_reason": None}]}
lines.append("data: " + _json.dumps(evt, ensure_ascii=False))
lines.append("data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}))
lines.append("data: [DONE]")
m = MagicMock()
m.status_code = 200
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
m.raise_for_status = MagicMock()
m.iter_lines = MagicMock(return_value=iter(lines))
return m
def test_stream_yields_delta_content(self, client_with_key):
"""流式调用逐段 yield delta.content"""
import json as _json
fake = self._fake_sse(["你好", ",", "世界"])
with patch("packages.shared.ai_client.httpx.stream", return_value=fake) as mock_stream:
out = list(
client_with_key.chat_completion_stream(
messages=[{"role": "user", "content": "hi"}], max_tokens=100, timeout=30
)
)
assert out == ["你好", ",", "世界"]
call_kwargs = mock_stream.call_args[1]
assert call_kwargs["json"]["stream"] is True
assert call_kwargs["json"]["max_tokens"] == 100
def test_stream_unavailable_returns_empty(self, client_without_key):
"""不可用时返回空生成器(不调用 httpx.stream)"""
with patch("packages.shared.ai_client.httpx.stream") as mock_stream:
out = list(client_without_key.chat_completion_stream(messages=[{"role": "user", "content": "hi"}]))
assert out == []
mock_stream.assert_not_called()
def test_stream_handles_done_marker(self, client_with_key):
"""遇到 [DONE] 正确终止,不把它当内容"""
import json as _json
lines = [
"data: " + _json.dumps({"choices": [{"delta": {"content": "A"}}]}),
"data: [DONE]",
"data: " + _json.dumps({"choices": [{"delta": {"content": "NEVER"}}]}), # 应被忽略
]
m = MagicMock()
m.status_code = 200
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
m.raise_for_status = MagicMock()
m.iter_lines = MagicMock(return_value=iter(lines))
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
out = list(
client_with_key.chat_completion_stream(
messages=[{"role": "user", "content": "hi"}], max_tokens=50, timeout=30
)
)
assert out == ["A"]
def test_stream_skips_empty_delta(self, client_with_key):
"""空 delta(role 等 metadata)不应产出内容"""
import json as _json
lines = [
"data: " + _json.dumps({"choices": [{"delta": {"role": "assistant"}}]}),
"data: " + _json.dumps({"choices": [{"delta": {"content": "hi"}}]}),
"data: " + _json.dumps({"choices": [{"delta": {}}]}),
"data: [DONE]",
]
m = MagicMock()
m.status_code = 200
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
m.raise_for_status = MagicMock()
m.iter_lines = MagicMock(return_value=iter(lines))
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
out = list(
client_with_key.chat_completion_stream(
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
)
)
assert out == ["hi"]
def test_stream_invalid_json_lines_skipped(self, client_with_key):
"""SSE 行里脏数据/非 JSON 不应中断流"""
import json as _json
lines = [
"data: " + _json.dumps({"choices": [{"delta": {"content": "ok"}}]}),
"data: not-a-json",
":comment line",
"",
"event: ping",
"data: " + _json.dumps({"choices": [{"delta": {"content": "2"}}]}),
"data: [DONE]",
]
m = MagicMock()
m.status_code = 200
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
m.raise_for_status = MagicMock()
m.iter_lines = MagicMock(return_value=iter(lines))
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
out = list(
client_with_key.chat_completion_stream(
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
)
)
assert out == ["ok", "2"]
def test_stream_network_error_yields_empty(self, client_with_key):
"""网络错误(重试耗尽)yield 空,不抛异常给调用方"""
import httpx as _httpx
# max_retries=2 → 3 次总尝试
client = client_with_key
client.max_retries = 1 # 只重试 1 次,缩短测试
with patch("packages.shared.ai_client.httpx.stream", side_effect=_httpx.ConnectError("boom")):
out = list(
client.chat_completion_stream(messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=5)
)
assert out == []
def test_stream_records_finish_reason(self, client_with_key):
"""流式结束后 last_finish_reason 被正确记录"""
import json as _json
lines = [
"data: " + _json.dumps({"choices": [{"delta": {"content": "x"}, "finish_reason": None}]}),
"data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}),
"data: [DONE]",
]
m = MagicMock()
m.status_code = 200
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
m.raise_for_status = MagicMock()
m.iter_lines = MagicMock(return_value=iter(lines))
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
list(
client_with_key.chat_completion_stream(
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
)
)
assert client_with_key.last_finish_reason == "stop"
+1 -1
View File
@@ -286,7 +286,7 @@ class TestGetDownloadUrl:
result = svc.get_download_url("uploads/video.mp4")
svc.bucket.sign_url.assert_called_once_with("GET", "uploads/video.mp4", 3600, slash_safe=True)
svc.bucket.sign_url.assert_called_once_with("GET", "uploads/video.mp4", 3600)
assert "signed-url" in result
def test_returns_raw_url_when_bucket_none(self):
-31
View File
@@ -250,34 +250,3 @@ class TestMultiplierConsistency:
resp = check_points(body=body, current_user=cu, db=db)
expected = calculate_points_cost(scene, is_member=False, quantity=1, duration_minutes=1)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
# ── GET /points/balance 返回后端真实积分开关 credits_enabled ──────────
class TestBalanceCreditsEnabled:
def _call_balance(self, enabled):
from app.api.routes import points as points_routes
svc = MagicMock()
svc.get_or_create_account.return_value = {
"balance": 0,
"total_earned": 0,
"total_spent": 0,
}
cu = _make_cu()
db = MagicMock()
with (
patch("app.api.routes.points._get_service", return_value=svc),
patch("app.api.routes.points._credits_enabled", return_value=enabled),
):
return points_routes.get_balance(current_user=cu, db=db)
def test_balance_credits_enabled_false_when_free_pass(self):
"""免费期(开关关闭)时 credits_enabled=False,前端应跳过余额预校验。"""
resp = self._call_balance(False)
assert resp.credits_enabled is False
def test_balance_credits_enabled_true_when_enabled(self):
"""收费期(开关开启)时 credits_enabled=True。"""
resp = self._call_balance(True)
assert resp.credits_enabled is True
+1 -1
View File
@@ -361,7 +361,7 @@ class TestGetDownloadUrlFallback:
result = service.get_download_url("videos/test.mp4", expires_seconds=7200)
mock_bucket.sign_url.assert_called_once_with("GET", "videos/test.mp4", 7200, slash_safe=True)
mock_bucket.sign_url.assert_called_once_with("GET", "videos/test.mp4", 7200)
assert result == "https://signed-url.com/file?sig=abc"
def test_sign_url_exception_falls_back_to_public_url(self):
+2 -2
View File
@@ -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", stage="render")
fake_job_in_db.mark_failed.assert_called_with("boom")
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", stage="copy")
job.mark_failed.assert_called_with("err")
mock_save.assert_called()
mock_emit.assert_called_once()
+1 -2
View File
@@ -65,7 +65,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"image_analysis": None,
"storyboard": None,
"generated_copy_text": "",
"language": "zh-CN",
"voice_id": "",
"voice_source": "",
"voice_mode": "global",
@@ -144,7 +143,7 @@ class TestRetryViralVideo:
assert job.status == ViralVideoStatus.PENDING
assert job.retry_count == 2
mock_send.assert_called_once_with("worker.run_viral_video_analyze", args=["job-retry"])
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=["job-retry"])
assert resp.id == "job-retry"
def test_retry_without_body_keeps_original_params(self):
-5
View File
@@ -8,15 +8,11 @@ Bug4: 错误事件按阶段区分(_mark_failed_and_notify 传正确 stage)
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
import pytest
# 仓库根目录(本文件位于 tests/unit/,向上两级)
BASE = str(Path(__file__).resolve().parents[2])
def _auth_user(uid: str = "u1"):
return SimpleNamespace(user=SimpleNamespace(id=uid))
@@ -56,7 +52,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"storyboard": None,
"copy_result": {"voiceover_script": "测试口播", "shots": [{"clip_id": 1}]},
"generated_copy_text": "",
"language": "zh-CN",
"voice_id": "",
"voice_source": "",
"voice_mode": "global",
-85
View File
@@ -483,88 +483,3 @@ class TestWSInitialSnapshot:
received, _, _ = _run_ws_handshake(job=job)
data = received[0]["data"]
assert data == {"status": "running"}
class TestScriptDeltaEvent:
"""流式 script_delta 事件推送(worker on_delta 回调语义)测试。
这里不直接导入 _step_script_generation(依赖 celery/db),而是复刻 on_delta 回调
的核心节流与事件格式逻辑,验证:
1) 事件 type = viral_video:script_delta
2) data 包含 delta / full_text / text_length
3) 节流逻辑(两次事件间隔 ≥200ms)
"""
def _make_on_delta(self, job_id, stage, emit_fn, start_ts):
"""复刻 worker 里 _on_script_delta 回调的关键逻辑。"""
import time
last_emit_ts = [start_ts]
def on_delta(delta, full_text):
now = time.time()
if now - last_emit_ts[0] < 0.2:
return
last_emit_ts[0] = now
_est_progress = min(70.0, 15.0 + (len(full_text) / 3500.0) * 55.0)
emit_fn(
job_id,
stage,
round(_est_progress, 1),
f"generating ({len(full_text)} chars)",
{"delta": delta, "full_text": full_text, "text_length": len(full_text)},
event_type="viral_video:script_delta",
)
return on_delta
def test_script_delta_event_format(self):
"""script_delta 事件格式与字段完整(用 time.sleep 跨过节流窗口)"""
import time
events = []
def fake_emit(job_id, stage, progress, msg, data, event_type):
events.append(
{
"job_id": job_id,
"stage": stage,
"progress": progress,
"message": msg,
"data": data,
"type": event_type,
}
)
on_delta = self._make_on_delta("job123", "script_generation", fake_emit, time.time() - 1)
on_delta("你好", "你好")
time.sleep(0.25)
on_delta("世界", "你好世界")
assert len(events) >= 2
e = events[0]
assert e["type"] == "viral_video:script_delta"
assert e["job_id"] == "job123"
assert e["data"]["delta"] == "你好"
assert e["data"]["full_text"] == "你好"
assert e["data"]["text_length"] == 2
assert events[-1]["data"]["full_text"] == "你好世界"
def test_rate_limit_throttles_fast_calls(self):
"""节流:200ms 内的连续 delta 在第一次发送后被拒绝;跨节流窗口的会放行"""
import time
events = []
def fake_emit(*a, **kw):
events.append(kw.get("event_type", a[5] if len(a) > 5 else "x"))
# start_ts 设为 1s 前,保证第一次 emit 被放行
on_delta = self._make_on_delta("j", "s", fake_emit, time.time() - 1)
on_delta("a", "a") # 放行:距离 start_ts 已 1s
on_delta("b", "ab") # 被节流:距上次 <0.2s
on_delta("c", "abc") # 被节流:同上
time.sleep(0.25)
on_delta("d", "abcd") # 放行:已跨过节流窗口
assert len(events) == 2
assert all(e == "viral_video:script_delta" for e in events)