Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s

This commit is contained in:
CI Test
2026-07-07 19:54:54 +08:00
9 changed files with 781 additions and 54 deletions
+14 -1
View File
@@ -43,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
CHUNK_EXPIRY_HOURS = 24
# Allowed file types (consistent with existing upload.py)
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
ALLOWED_MIME_TYPES = {
# Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
# Video
"video/mp4",
"video/quicktime",
"video/mpeg",
"video/x-msvideo",
"video/webm",
"video/x-matroska",
"video/3gpp",
# Audio
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp3",
"audio/flac",
"audio/aac",
"audio/x-m4a",
"audio/webm",
}
# Chunk storage root directory
+8 -17
View File
@@ -10,7 +10,6 @@ import {
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
@@ -37,7 +36,7 @@ import "./assets.css";
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type AssetKind = "video" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
interface LibraryItem {
@@ -67,7 +66,6 @@ interface AssetItem {
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "voice";
return "image";
};
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
count: item.asset_count ?? 0,
});
@@ -149,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined />;
case "voice":
return <SoundOutlined />;
case "image":
return <PictureOutlined />;
}
@@ -160,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video":
return "视频";
case "voice":
return "配音";
case "image":
return "图片";
}
@@ -172,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
@@ -246,7 +238,7 @@ const AssetCard: React.FC<{
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
{asset.kind === "video" && (
<span
className="xx-asset-play"
onClick={(e) => {
@@ -450,8 +442,9 @@ const AssetLibrary: React.FC = () => {
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch {
message.error(`"${file.name}" 上传失败`);
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
} finally {
setUploading(false);
setUploadProgress(0);
@@ -645,7 +638,7 @@ const AssetLibrary: React.FC = () => {
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
accept="video/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
@@ -655,7 +648,7 @@ const AssetLibrary: React.FC = () => {
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
支持视频、音频、图片,单文件不超过 2GB
支持视频、图片,单文件不超过 2GB
</p>
</div>
</Upload.Dragger>
@@ -678,7 +671,6 @@ const AssetLibrary: React.FC = () => {
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
@@ -809,7 +801,6 @@ const AssetLibrary: React.FC = () => {
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
+116 -22
View File
@@ -6,11 +6,14 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Typography, message } from "antd";
import { Typography, message, Select } from "antd";
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
@@ -160,6 +163,7 @@ const GeneratePage: React.FC = () => {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -477,6 +481,7 @@ const GeneratePage: React.FC = () => {
setGenerating(true);
setProgress(0);
setGenerated(false);
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
@@ -537,6 +542,7 @@ const GeneratePage: React.FC = () => {
)?.error_message ||
"视频生成失败,请联系管理员或重试";
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
return;
}
@@ -593,7 +599,9 @@ const GeneratePage: React.FC = () => {
"完整错误:",
axiosErr,
);
message.error(backendMsg || "生成失败,请检查网络后重试或联系管理员");
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -917,14 +925,30 @@ const GeneratePage: React.FC = () => {
<h3>📝 选择标题</h3>
<div className="xx-form-field">
<label>从标题库选择</label>
<select value={title} onChange={(e) => setTitle(e.target.value)}>
<option value="">请选择标题…</option>
{userTitles.map((t) => (
<option key={t.id} value={t.content}>
{t.content}
</option>
))}
</select>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "")
.toLowerCase()
.includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
标题库为空,请前往「标题管理」添加
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label>或手动输入</label>
@@ -1407,18 +1431,82 @@ const GeneratePage: React.FC = () => {
</div>
</div>
{/* 生成进度 */}
{generating && (
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
<div className="xx-progress-bar">
{generating && (
<>
<div className="xx-progress-bar">
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
<LoadingOutlined style={{ marginRight: 6 }} />
正在生成视频,请稍候… {Math.round(progress)}%
</Text>
</>
)}
{generated && !generating && (
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
正在生成视频,请稍候… {Math.round(progress)}%
</Text>
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(82, 196, 26, 0.08)",
border: "1px solid rgba(82, 196, 26, 0.3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
<div>
<Text
strong
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
>
视频生成完成!
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
可在右侧预览或前往成片库查看
</Text>
</div>
</div>
)}
{generateError && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(255, 77, 79, 0.08)",
border: "1px solid rgba(255, 77, 79, 0.3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<CloseCircleOutlined
style={{
color: "#ff4d4f",
fontSize: 18,
marginTop: 2,
flexShrink: 0,
}}
/>
<div>
<Text
strong
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
>
生成失败
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{generateError}
</Text>
</div>
</div>
)}
</div>
)}
</div>
@@ -1529,10 +1617,16 @@ const GeneratePage: React.FC = () => {
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || generated}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
+27 -7
View File
@@ -739,17 +739,37 @@ const ProductLibrary: React.FC = () => {
if (isError) {
console.error("[ProductLibrary] 加载失败:", error);
const errorMsg = error?.message || "加载失败";
// 区分 404 和其他错误
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
if (is404) {
return (
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined /> 成片库
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p>暂无成片数据</p>
<p
style={{
fontSize: 12,
color: "var(--text-tertiary)",
marginTop: 4,
}}
>
完成视频生成后,成片将自动保存到这里
</p>
</div>
</div>
);
}
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon">{is404 ? "🔍" : "❌"}</div>
<p>
{is404
? "成片库功能正在建设中,敬请期待"
: errorMsg || "加载失败,请稍后重试"}
</p>
<div className="xx-products-empty-icon">❌</div>
<p>{errorMsg || "加载失败,请稍后重试"}</p>
<Button
buttonType="primary"
buttonSize="sm"
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description,
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.description.slice(0, 80)}...
{(template.description ?? "").slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint">点击预览</div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
已使用 {template.usageCount} 次
@@ -483,7 +483,9 @@ const TemplateLibrary: React.FC = () => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
@@ -233,6 +233,24 @@
pointer-events: none;
}
/* 缩略图底部名称 */
.xx-template-thumb-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 14px 10px;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 1;
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
@@ -35,6 +35,8 @@ import {
CheckOutlined,
TagsOutlined,
MutedOutlined,
RobotOutlined,
LoadingOutlined,
} from "@ant-design/icons";
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
import { message, Popover, Popconfirm, Tooltip } from "antd";
@@ -56,6 +58,8 @@ import {
tagAsset,
untagAsset,
} from "@/api/tags";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices";
import "./voice-materials.css";
/* ============================================================
@@ -1086,6 +1090,14 @@ const VoiceMaterialLibrary: React.FC = () => {
[assets],
);
// ── 获取预设音色列表(AI 配音用) ─────────────────────────
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
staleTime: 60_000,
});
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [];
// ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
@@ -1218,6 +1230,19 @@ const VoiceMaterialLibrary: React.FC = () => {
);
const [batchCustomTag, setBatchCustomTag] = useState("");
// ── AI 配音(TTS 合成)状态 ────────────────────────────────
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<
"idle" | "synthesizing" | "done" | "error"
>("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── 播放控制 ──────────────────────────────────────────────
const stopPlayback = useCallback(() => {
if (audioRef.current) {
@@ -1505,6 +1530,77 @@ const VoiceMaterialLibrary: React.FC = () => {
[tags, createTagMutation, handleBatchTag],
);
// ── TTS 合成处理 ─────────────────────────────────────────
/** 开始 AI 配音合成 */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
// 轮询任务状态
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, {
name: ttsText.slice(0, 20) || "AI配音",
});
message.success("已保存到配音素材库");
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setTtsOpen(false);
} catch {
message.error("保存失败");
}
}, [ttsJobId, ttsText, queryClient]);
// TTS 定时器清理
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 渲染 ─────────────────────────────────────────────── */
const isUploading = uploadMutation.isPending;
@@ -1512,6 +1608,13 @@ const VoiceMaterialLibrary: React.FC = () => {
const pageActions = (
<div className="vmat-page-actions">
<Button
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
AI配音
</Button>
<Button
buttonType="primary"
buttonSize="sm"
@@ -1818,6 +1921,182 @@ const VoiceMaterialLibrary: React.FC = () => {
/>
)}
</Modal>
{/* AI 配音(TTS 合成)弹窗 */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
if (ttsTimerRef.current) {
clearInterval(ttsTimerRef.current);
ttsTimerRef.current = null;
}
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
destroyOnClose
>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* 文本输入 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
输入文本
</label>
<textarea
rows={4}
placeholder="请输入需要转换为语音的文本内容…"
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
maxLength={2000}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary, #999)",
marginTop: 4,
textAlign: "right",
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
选择音色
</label>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
height: 36,
padding: "0 10px",
border: "1px solid var(--border-color, #d9d9d9)",
borderRadius: 6,
fontSize: 13,
background: "var(--bg-primary, #fff)",
}}
>
<option value="">默认音色</option>
{presetVoices.map((v) => (
<option key={v.voice_id} value={v.voice_id}>
{v.name}
</option>
))}
</select>
</div>
{/* 语速调节 */}
<div>
<label
style={{
fontSize: 13,
fontWeight: 500,
marginBottom: 6,
display: "block",
}}
>
语速:{ttsSpeed.toFixed(1)}x
</label>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 合成按钮 */}
<Button
buttonType="primary"
buttonSize="md"
icon={
ttsStatus === "synthesizing" ? (
<LoadingOutlined />
) : (
<RobotOutlined />
)
}
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
>
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
</Button>
{/* 错误提示 */}
{ttsStatus === "error" && ttsError && (
<div
style={{
padding: "8px 12px",
background: "#fff2f0",
borderRadius: 6,
color: "#ff4d4f",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-surface, #f5f5f5)",
borderRadius: 8,
}}
>
<audio
controls
src={ttsAudioUrl}
style={{ width: "100%", marginBottom: 12 }}
/>
<Button
buttonType="primary"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={handleTtsSave}
>
保存到素材库
</Button>
</div>
)}
</div>
</Modal>
</div>
);
};
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
models = (
@@ -86,7 +86,7 @@ class SQLAlchemyGeneratedVideoRepository:
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
.all()
)
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
@@ -0,0 +1,310 @@
"""
测试视频上传失败排查修复:
1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致
2. generated_video_repository list 方法无 N+1 查询
3. 成片库 API 端点可正常返回数据
"""
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ───
class TestChunkedUploadMIMEConsistency:
"""chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。"""
def _get_upload_mime_types(self) -> set:
from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES
return set(ALLOWED_MIME_TYPES)
def _get_chunked_mime_types(self) -> set:
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
return set(ALLOWED_MIME_TYPES)
def test_chunked_upload_has_all_upload_mime_types(self):
"""chunked_upload 白名单必须覆盖 upload.py 的全部类型。"""
upload_types = self._get_upload_mime_types()
chunked_types = self._get_chunked_mime_types()
missing = upload_types - chunked_types
assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}"
def test_chunked_upload_supports_video_mpeg(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "video/mpeg" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_video_matroska(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "video/x-matroska" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_video_3gpp(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "video/3gpp" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_audio_flac(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "audio/flac" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_audio_aac(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "audio/aac" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_audio_m4a(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "audio/x-m4a" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_audio_webm(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "audio/webm" in ALLOWED_MIME_TYPES
def test_chunked_upload_supports_extra_image_types(self):
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
assert "image/bmp" in ALLOWED_MIME_TYPES
assert "image/tiff" in ALLOWED_MIME_TYPES
assert "image/svg+xml" in ALLOWED_MIME_TYPES
def test_both_have_same_core_video_types(self):
"""两条路径的核心视频类型必须一致。"""
upload_types = self._get_upload_mime_types()
chunked_types = self._get_chunked_mime_types()
core_video = {"video/mp4", "video/quicktime", "video/webm"}
for vt in core_video:
assert vt in upload_types, f"upload.py 缺少 {vt}"
assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}"
# ─── 2. GeneratedVideo Repository N+1 修复验证 ───
class TestGeneratedVideoRepositoryNoNPlus1:
"""list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。"""
def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"):
model = MagicMock()
model.id = video_id
model.project_id = project_id
model.generation_task_id = task_id
model.name = f"video-{video_id}.mp4"
model.file_url = f"https://oss.example.com/{video_id}.mp4"
model.file_size = 1024
model.duration = 5.0
model.thumbnail_url = None
model.width = 1280
model.height = 720
model.fps = 25.0
model.status = "completed"
model.review_status = "pending_review"
model.generation_params = json.dumps({"mode": "one_take"})
model.video_fingerprint = None
model.is_duplicate = False
model.duplicate_of = None
model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
return model
def test_list_by_project_uses_to_domain(self):
"""list_by_project 不应调用 self.get(N+1),应使用 _to_domain。"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
session = MagicMock()
models = [self._make_model(f"v{i}") for i in range(5)]
query_mock = MagicMock()
query_mock.filter.return_value.all.return_value = models
session.query.return_value = query_mock
repo = SQLAlchemyGeneratedVideoRepository(session)
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
result = repo.list_by_project("proj-1")
mock_get.assert_not_called()
assert len(result) == 5
assert all(v.id.startswith("v") for v in result)
def test_list_by_generation_task_uses_to_domain(self):
"""list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。"""
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
session = MagicMock()
models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)]
query_mock = MagicMock()
query_mock.filter.return_value.all.return_value = models
session.query.return_value = query_mock
repo = SQLAlchemyGeneratedVideoRepository(session)
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
result = repo.list_by_generation_task("task-42")
mock_get.assert_not_called()
assert len(result) == 3
def test_list_by_project_returns_empty_when_no_videos(self):
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
session = MagicMock()
query_mock = MagicMock()
query_mock.filter.return_value.all.return_value = []
session.query.return_value = query_mock
repo = SQLAlchemyGeneratedVideoRepository(session)
result = repo.list_by_project("empty-project")
assert result == []
# ─── 3. 成片库 API 端点可用性确认 ───
class TestGeneratedVideosAPIAvailability:
"""确认成片库 API 路由注册正确,端点可正常返回数据。"""
def test_generated_videos_routes_registered(self):
"""成片库路由已注册到 router。"""
from apps.api.app.api.router import api_router
# 检查 router 包含 generated-videos 路径
routes = [r for r in api_router.routes if hasattr(r, "path")]
gv_routes = [r for r in routes if "generated-videos" in r.path]
assert len(gv_routes) > 0, "generated-videos 路由未注册"
def test_generated_videos_list_endpoint_exists(self):
"""GET /generated-videos 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "" in paths, "GET /generated-videos 列表端点不存在"
def test_generated_videos_detail_endpoint_exists(self):
"""GET /generated-videos/{video_id} 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在"
def test_generated_videos_review_endpoint_exists(self):
"""PATCH /generated-videos/{video_id}/review 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}/review" in paths, "PATCH review 端点不存在"
def test_generated_videos_download_url_endpoint_exists(self):
"""GET /generated-videos/{video_id}/download-url 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}/download-url" in paths, "download-url 端点不存在"
def test_generated_video_response_schema_complete(self):
"""GeneratedVideoResponse 包含所有必要字段。"""
from apps.api.app.schemas.generated_video import GeneratedVideoResponse
fields = GeneratedVideoResponse.model_fields
required_fields = [
"id",
"project_id",
"name",
"file_url",
"status",
"review_status",
"download_url",
]
for field in required_fields:
assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}"
def test_list_generated_videos_response_schema(self):
"""ListGeneratedVideosResponse 包含 items 列表。"""
from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse
fields = ListGeneratedVideosResponse.model_fields
assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段"
def test_generation_task_results_endpoint_exists(self):
"""GET /generation/tasks/{task_id}/results 端点存在。"""
from apps.api.app.api.routes.generation_tasks import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在"
# ─── 4. GeneratedVideo Use Cases 可用性 ───
class TestGeneratedVideoUseCases:
"""确认成片库 Use Case 层可正常工作。"""
def _make_video(self, video_id: str, project_id: str = "proj-1"):
from packages.domain import GeneratedVideo
return GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id="task-1",
name=f"video-{video_id}.mp4",
file_url=f"https://oss.example.com/{video_id}.mp4",
file_size=1024,
duration=5.0,
width=1280,
height=720,
fps=25.0,
status="completed",
)
def test_list_generated_videos_use_case(self):
"""ListGeneratedVideosUseCase 可正常列出视频。"""
from packages.application import ListGeneratedVideosUseCase
repo = MagicMock()
videos = [self._make_video(f"v{i}") for i in range(3)]
repo.list_by_project.return_value = videos
use_case = ListGeneratedVideosUseCase(repo)
result = use_case.execute("proj-1")
repo.list_by_project.assert_called_once_with("proj-1")
assert len(result) == 3
def test_get_generated_video_use_case(self):
"""GetGeneratedVideoUseCase 可正常获取单个视频。"""
from packages.application import GetGeneratedVideoUseCase
repo = MagicMock()
video = self._make_video("v1")
repo.get.return_value = video
use_case = GetGeneratedVideoUseCase(repo)
result = use_case.execute("v1")
repo.get.assert_called_once_with("v1")
assert result.id == "v1"
def test_list_by_task_use_case(self):
"""ListGeneratedVideosByTaskUseCase 可按任务列出视频。"""
from packages.application import ListGeneratedVideosByTaskUseCase
repo = MagicMock()
videos = [self._make_video(f"v{i}") for i in range(2)]
repo.list_by_generation_task.return_value = videos
use_case = ListGeneratedVideosByTaskUseCase(repo)
result = use_case.execute("task-1")
repo.list_by_generation_task.assert_called_once_with("task-1")
assert len(result) == 2