Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a57878f76 | |||
| f0dee5bbd3 | |||
| 50719db7c8 | |||
| eb50442296 | |||
| 73375c6639 | |||
| 5bde975ea6 |
@@ -23,7 +23,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FEATURE_FLAG_REDIS_PREFIX,
|
||||
FeatureFlagConfig,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
check_queue_limits,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* CloneVoiceModal — 音色克隆弹窗
|
||||
*
|
||||
* 三步骤状态:input → uploading → success
|
||||
* 支持上传音频文件或直接录制(mock,无真实录音)
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { uploadAsset } from "@/api/assets";
|
||||
import "./clone-voice-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalStep = "input" | "uploading" | "success";
|
||||
|
||||
export interface CloneVoiceModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||||
|
||||
let cloneCounter = 1;
|
||||
|
||||
const getNextDefaultName = (): string => {
|
||||
const name = `我的声音 ${cloneCounter}`;
|
||||
cloneCounter += 1;
|
||||
return name;
|
||||
};
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setStep("input");
|
||||
setVoiceName("");
|
||||
setSelectedFile(null);
|
||||
setIsRecording(false);
|
||||
setDragActive(false);
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
/** 上传区域点击 */
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
/** 文件选择 */
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
// 清除之前的录制状态
|
||||
setIsRecording(false);
|
||||
}
|
||||
// 清空 input 以允许重复选择同一文件
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext === "mp3" || ext === "wav") {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 录制按钮(mock) */
|
||||
const handleRecord = () => {
|
||||
setIsRecording((prev) => !prev);
|
||||
if (!isRecording) {
|
||||
// 开始录制 — 清除已选文件
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** 开始克隆 */
|
||||
const handleStartClone = async () => {
|
||||
const name = voiceName.trim() || getNextDefaultName();
|
||||
setStep("uploading");
|
||||
|
||||
try {
|
||||
// 先上传音频文件获取真实 URL
|
||||
let audioUrl: string;
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("kind", "voice");
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
audioUrl = uploadResult.url;
|
||||
} else {
|
||||
// 录制功能暂未实现,提示用户上传
|
||||
setStep("input");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交克隆请求
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: audioUrl,
|
||||
});
|
||||
|
||||
setStep("success");
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
setStep("input");
|
||||
}
|
||||
};
|
||||
|
||||
/** 弹窗打开时初始化默认名称 */
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
setVoiceName(getNextDefaultName());
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = selectedFile || isRecording;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="🎤 克隆新音色"
|
||||
width={520}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
>
|
||||
{/* ── 输入步骤 ──────────────────────────────────── */}
|
||||
{step === "input" && (
|
||||
<div className="cvm-body">
|
||||
{/* 音色名称 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">音色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="cvm-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">上传音频</label>
|
||||
<div
|
||||
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="cvm-upload-icon">🎵</div>
|
||||
<p className="cvm-upload-title">
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
|
||||
</p>
|
||||
<p className="cvm-upload-hint">支持 MP3、WAV 格式</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mp3,.wav,audio/mpeg,audio/wav"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="cvm-divider">
|
||||
<div className="cvm-divider-line" />
|
||||
<span className="cvm-divider-text">或</span>
|
||||
<div className="cvm-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="cvm-field">
|
||||
<label className="cvm-label">直接录制</label>
|
||||
<div className="cvm-record-area">
|
||||
<p className="cvm-record-hint">
|
||||
{isRecording
|
||||
? "录制中…再次点击停止"
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
>
|
||||
🎙️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="cvm-tip">
|
||||
<span className="cvm-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="cvm-footer">
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 上传中步骤 ────────────────────────────────── */}
|
||||
{step === "uploading" && (
|
||||
<div className="cvm-uploading">
|
||||
<div className="cvm-uploading-spinner" />
|
||||
<p className="cvm-uploading-text">正在克隆你的音色…</p>
|
||||
<p className="cvm-uploading-sub">AI 正在分析你的声音特征,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 成功步骤 ──────────────────────────────────── */}
|
||||
{step === "success" && (
|
||||
<div className="cvm-success">
|
||||
<div className="cvm-success-icon">✅</div>
|
||||
<h3 className="cvm-success-title">克隆已提交</h3>
|
||||
<p className="cvm-success-desc">
|
||||
音色正在生成中,完成后将出现在列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloneVoiceModal;
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* CloneVoiceModal — V21 Design System
|
||||
*
|
||||
* 音色克隆弹窗样式
|
||||
* 三步骤状态:input → uploading → success
|
||||
*/
|
||||
|
||||
/* ── 弹窗内容区 ─────────────────────────────────────────── */
|
||||
|
||||
.cvm-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── 表单区 ─────────────────────────────────────────────── */
|
||||
|
||||
.cvm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cvm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #475467);
|
||||
}
|
||||
|
||||
.cvm-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cvm-input:focus {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.cvm-input::placeholder {
|
||||
color: var(--muted, #98a2b3);
|
||||
}
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-upload-zone {
|
||||
border: 2px dashed var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.cvm-upload-zone:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
||||
}
|
||||
|
||||
.cvm-upload-zone.cvm-upload-zone--active {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
}
|
||||
|
||||
.cvm-upload-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cvm-upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.cvm-upload-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 或分隔线 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.cvm-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line, #e4e7ec);
|
||||
}
|
||||
|
||||
.cvm-divider-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 录制区域 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-record-area {
|
||||
border: 1px solid var(--line, #e4e7ec);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cvm-record-hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.cvm-record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--error-color, #ef4444),
|
||||
var(--error-dark, #dc2626)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cvm-record-btn:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
|
||||
}
|
||||
|
||||
.cvm-record-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.cvm-record-btn--recording {
|
||||
animation: cvm-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cvm-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 28px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示条 ─────────────────────────────────────────────── */
|
||||
|
||||
.cvm-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--warning-soft, #fef3c7);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--warning-color, #92400e);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cvm-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 底部按钮 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cvm-footer .xx-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 上传中状态 ─────────────────────────────────────────── */
|
||||
|
||||
.cvm-uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cvm-uploading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--line, #e4e7ec);
|
||||
border-top-color: var(--primary, #6366f1);
|
||||
border-radius: 50%;
|
||||
animation: cvm-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cvm-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cvm-uploading-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cvm-uploading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
||||
|
||||
.cvm-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cvm-success-icon {
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cvm-success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #101828);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cvm-success-desc {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #98a2b3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cvm-overlay {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.cvm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.cvm-upload-zone {
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.cvm-record-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.cvm-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cvm-record-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.cvm-tip {
|
||||
font-size: 12px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
@@ -532,3 +532,23 @@
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
|
||||
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
|
||||
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
|
||||
|
||||
.xx-card .ant-card-head {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-card .ant-card-head-title {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-card .ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ const Accounts: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定 mutation(mock) */
|
||||
/** 绑定 mutation */
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: bindAccount,
|
||||
onSuccess: () => {
|
||||
@@ -165,7 +165,7 @@ const Accounts: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/** 绑定新账号(mock:直接创建) */
|
||||
/** 绑定新账号 */
|
||||
const handleBind = (platformId: PlatformId) => {
|
||||
const platform = PLATFORMS.find((p) => p.id === platformId);
|
||||
if (!platform) return;
|
||||
|
||||
@@ -29,6 +29,7 @@ interface KpiItem {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// TODO: kpiData 当前使用硬编码 mock 数据,待后端提供 Dashboard 统计 API 后替换
|
||||
const kpiData: KpiItem[] = [
|
||||
{
|
||||
key: "projects",
|
||||
@@ -81,6 +82,7 @@ interface QuickEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// TODO: quickEntries 描述中含硬编码计数(如 486个素材),待后端 API 后动态化
|
||||
const quickEntries: QuickEntry[] = [
|
||||
{
|
||||
id: "titles",
|
||||
@@ -135,6 +137,7 @@ const statusLabel: Record<TaskStatus, string> = {
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
// TODO: recentTasks 当前使用硬编码 mock 数据,待后端提供最近任务 API 后替换
|
||||
const recentTasks: RecentTask[] = [
|
||||
{
|
||||
id: "t-1",
|
||||
|
||||
@@ -55,13 +55,9 @@ interface TitleData {
|
||||
/* ============================================================
|
||||
* Mock 数据
|
||||
* ============================================================ */
|
||||
// TODO: 分类数据当前为前端硬编码 mock,待后端提供标题分类 API 后替换
|
||||
const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
{ id: "cat-all", name: "全部标题", count: 15 },
|
||||
{ id: "cat-1", name: "美食探店", count: 4 },
|
||||
{ id: "cat-2", name: "科技数码", count: 3 },
|
||||
{ id: "cat-3", name: "生活日常", count: 4 },
|
||||
{ id: "cat-4", name: "美妆穿搭", count: 2 },
|
||||
{ id: "cat-5", name: "教育学习", count: 2 },
|
||||
{ id: "cat-all", name: "全部标题", count: 0 },
|
||||
];
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import {
|
||||
getVoiceClones,
|
||||
deleteVoiceClone,
|
||||
@@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneVoiceModal
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Video deduplication module - compute fingerprints and detect duplicates."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
@@ -327,7 +325,6 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
raise ValueError(f"Generated video {generated_video_id} not found")
|
||||
|
||||
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
|
||||
storage_key = video.file_url.split("/")[-1]
|
||||
storage_service.download_file(
|
||||
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ def run_ffmpeg(
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
|
||||
@@ -11,7 +11,6 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import oss2
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import ffmpeg
|
||||
|
||||
@@ -17,15 +17,15 @@ import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,6 +17,7 @@ class WorkerSettings(BaseSettings):
|
||||
database_pool_recycle: int = 3600
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
render_engine: str = "legacy"
|
||||
|
||||
@@ -10,12 +10,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from celery import Task
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
|
||||
@@ -9,7 +8,6 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.domain import (
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
ClassificationStatus,
|
||||
)
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
@@ -21,7 +21,6 @@ import logging
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
@@ -174,7 +172,6 @@ def _build_plan_and_clips_from_task(
|
||||
path_duration[p] = probe_duration(p)
|
||||
|
||||
clips: list[_VirtualClip] = []
|
||||
n = len(downloaded_paths)
|
||||
|
||||
if mode == "pip":
|
||||
# 1 main + N-1 overlay
|
||||
@@ -912,7 +909,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
render_result = render_service.render()
|
||||
render_output_path = render_result.output_path
|
||||
render_duration = render_result.duration
|
||||
render_file_size = render_result.file_size
|
||||
render_elapsed = time.monotonic() - render_start
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from celery import Celery
|
||||
from celery.app.task import Task
|
||||
from celery.utils.log import get_task_logger
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.core.asset_types import infer_mime_type_from_storage_key
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Voice extraction tasks - extract voice tracks and background music from videos."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from celery import Task
|
||||
from sqlalchemy.orm import Session
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ celery==5.4.0
|
||||
|
||||
# 对象存储
|
||||
oss2==2.18.4
|
||||
cryptography==46.0.5
|
||||
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
|
||||
pyOpenSSL==26.2.0
|
||||
|
||||
# HTTP 客户端
|
||||
httpx==0.27.2
|
||||
|
||||
+1
-6
@@ -17,18 +17,13 @@ os.environ.setdefault("USE_IN_MEMORY_DB", "True")
|
||||
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
|
||||
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _mock_celery_task():
|
||||
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
|
||||
from celery import Celery, Task
|
||||
|
||||
# 保存原始方法
|
||||
_orig_delay = Task.delay
|
||||
_orig_apply_async = Task.apply_async
|
||||
_orig_send_task = Celery.send_task
|
||||
|
||||
def _mock_delay(self, *args, **kwargs):
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = "mock-task-id"
|
||||
|
||||
@@ -113,7 +113,6 @@ class PerfAssert:
|
||||
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
||||
|
||||
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
||||
num_samples = samples or self.sample_count
|
||||
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
||||
|
||||
# 预热(第一次请求可能有冷启动开销)
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"""查重 API 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
from app.schemas.duplication import (
|
||||
DuplicateSegmentResponse,
|
||||
DuplicationDetailResponse,
|
||||
DuplicationRecordResponse,
|
||||
DuplicationUploadResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from packages.application import (
|
||||
DeleteDuplicationRecordUseCase,
|
||||
GetDuplicationDetailUseCase,
|
||||
ListDuplicationRecordsUseCase,
|
||||
RetryDuplicationUseCase,
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 查重功能只接受视频文件
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset(
|
||||
{
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
|
||||
if not content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||||
)
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||||
return DuplicationRecordResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||||
return DuplicationDetailResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
segments=[
|
||||
DuplicateSegmentResponse(
|
||||
id=seg.id,
|
||||
source_start=seg.source_start,
|
||||
source_end=seg.source_end,
|
||||
matched_video_id=seg.matched_video_id,
|
||||
matched_video_name=seg.matched_video_name,
|
||||
matched_start=seg.matched_start,
|
||||
matched_end=seg.matched_end,
|
||||
similarity=seg.similarity,
|
||||
)
|
||||
for seg in record.segments
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DuplicationUploadResponse)
|
||||
async def upload_for_duplication(
|
||||
file: UploadFile = File(..., description="要查重的视频文件"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""上传视频进行查重。"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件名不能为空",
|
||||
)
|
||||
|
||||
# P0-1: 验证 MIME 类型(只接受视频文件)
|
||||
validated_content_type = _validate_video_mime_type(file.content_type)
|
||||
|
||||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
# 先检查 Content-Length header(如果可用)
|
||||
if file.size is not None and file.size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# 读取文件内容并上传到 OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"duplication/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# 再次检查实际文件大小
|
||||
if file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("读取查重文件失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件读取失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
storage_service.upload_file(
|
||||
content,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="文件上传失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
use_case = UploadForDuplicationUseCase(duplication_repository)
|
||||
record = use_case.execute(
|
||||
UploadForDuplicationCommand(
|
||||
user_id=authenticated_user.user.id,
|
||||
filename=file.filename,
|
||||
file_size=file_size,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Duplication upload: record=%s file=%s user=%s",
|
||||
record.id,
|
||||
file.filename,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=record.id,
|
||||
status=record.status,
|
||||
message=f'文件 "{file.filename}" 已上传,正在查重中...',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=list[DuplicationRecordResponse])
|
||||
def list_duplication_records(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> list[DuplicationRecordResponse]:
|
||||
"""获取当前用户的查重记录列表。"""
|
||||
use_case = ListDuplicationRecordsUseCase(duplication_repository)
|
||||
records = use_case.execute(authenticated_user.user.id)
|
||||
return [_to_record_response(r) for r in records]
|
||||
|
||||
|
||||
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
|
||||
def get_duplication_detail(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationDetailResponse:
|
||||
"""获取查重记录详情(含重复片段)。"""
|
||||
use_case = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = use_case.execute(record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
if record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> Response:
|
||||
"""删除查重记录。"""
|
||||
# 检查记录是否存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||
use_case.execute(record_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||
def retry_duplication(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""重新提交查重。"""
|
||||
# 检查记录存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = RetryDuplicationUseCase(duplication_repository)
|
||||
updated = use_case.execute(record_id)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=updated.id,
|
||||
status=updated.status,
|
||||
message="已重新提交查重",
|
||||
)
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -33,7 +32,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
from app.api.routes.duplication import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_db_session, get_duplication_repository
|
||||
from app.dependencies import get_duplication_repository
|
||||
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
|
||||
@@ -32,11 +32,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
||||
|
||||
from app.api.routes.duplication import _validate_video_mime_type, router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
@@ -9,16 +9,12 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderResult,
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import (
|
||||
OUTPUT_HEIGHT,
|
||||
OUTPUT_WIDTH,
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
|
||||
@@ -515,7 +515,6 @@ class TestRetryGenerationTask:
|
||||
task_id = resp.json()["items"][0]["id"]
|
||||
|
||||
# 直接修改 repository 中的任务状态为 failed
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
# 由于是 stub,我们需要通过另一种方式设置状态
|
||||
# 让我们直接通过 retry 测试来验证
|
||||
|
||||
@@ -3,7 +3,7 @@ from packages.adapters.in_memory import (
|
||||
InMemoryIngestJobRepository,
|
||||
)
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from packages.domain import Asset, IngestJob, IngestJobStatus
|
||||
from packages.domain import Asset, IngestJobStatus
|
||||
|
||||
|
||||
def simulate_ingest_asset(
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
"""项目管理功能集成测试"""
|
||||
|
||||
import pytest
|
||||
|
||||
# 项目管理功能尚未实现,相关模块不存在,跳过整个文件
|
||||
pytest.skip(
|
||||
"项目管理功能尚未实现(project_management_repositories / "
|
||||
"project_management_use_cases / TaskPriority / TaskStatus 均不存在)",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
from packages.adapters.in_memory.project_management_repositories import (
|
||||
InMemoryMilestoneRepository,
|
||||
InMemoryTaskIssueRepository,
|
||||
InMemoryTaskRepository,
|
||||
)
|
||||
from packages.application.project_management_use_cases import (
|
||||
CreateMilestoneUseCase,
|
||||
CreateTaskIssueUseCase,
|
||||
CreateTaskUseCase,
|
||||
ListProjectTasksUseCase,
|
||||
ListTaskIssuesUseCase,
|
||||
ResolveTaskIssueUseCase,
|
||||
UpdateTaskProgressUseCase,
|
||||
UpdateTaskStatusUseCase,
|
||||
)
|
||||
from packages.domain import TaskPriority, TaskStatus
|
||||
|
||||
|
||||
def test_create_task():
|
||||
"""测试创建任务"""
|
||||
repo = InMemoryTaskRepository()
|
||||
use_case = CreateTaskUseCase(repo)
|
||||
|
||||
task = use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="开发登录功能",
|
||||
description="实现用户登录功能",
|
||||
priority=TaskPriority.HIGH,
|
||||
)
|
||||
|
||||
assert task.id is not None
|
||||
assert task.name == "开发登录功能"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
assert task.priority == TaskPriority.HIGH
|
||||
assert task.progress == 0.0
|
||||
|
||||
|
||||
def test_list_tasks():
|
||||
"""测试获取任务列表"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
|
||||
# 创建两个任务
|
||||
create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="任务1",
|
||||
)
|
||||
create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="任务2",
|
||||
)
|
||||
|
||||
# 查询任务列表
|
||||
list_use_case = ListProjectTasksUseCase(repo)
|
||||
tasks = list_use_case.execute("proj_1")
|
||||
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0].name == "任务1"
|
||||
assert tasks[1].name == "任务2"
|
||||
|
||||
|
||||
def test_update_task_status():
|
||||
"""测试更新任务状态"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
update_use_case = UpdateTaskStatusUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
)
|
||||
|
||||
# 更新状态为进行中
|
||||
updated_task = update_use_case.execute(task.id, TaskStatus.IN_PROGRESS)
|
||||
|
||||
assert updated_task.status == TaskStatus.IN_PROGRESS
|
||||
assert updated_task.actual_start_date is not None
|
||||
|
||||
|
||||
def test_update_task_progress():
|
||||
"""测试更新任务进度"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
progress_use_case = UpdateTaskProgressUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
)
|
||||
|
||||
# 更新进度到 50%
|
||||
updated_task = progress_use_case.execute(task.id, 50.0)
|
||||
|
||||
assert updated_task.progress == 50.0
|
||||
assert updated_task.status == TaskStatus.IN_PROGRESS
|
||||
|
||||
# 更新进度到 100%
|
||||
completed_task = progress_use_case.execute(task.id, 100.0)
|
||||
|
||||
assert completed_task.progress == 100.0
|
||||
assert completed_task.status == TaskStatus.COMPLETED
|
||||
assert completed_task.actual_end_date is not None
|
||||
|
||||
|
||||
def test_create_milestone():
|
||||
"""测试创建里程碑"""
|
||||
repo = InMemoryMilestoneRepository()
|
||||
use_case = CreateMilestoneUseCase(repo)
|
||||
|
||||
milestone = use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="V1.0 发布",
|
||||
description="第一个正式版本",
|
||||
)
|
||||
|
||||
assert milestone.id is not None
|
||||
assert milestone.name == "V1.0 发布"
|
||||
assert milestone.completed is False
|
||||
|
||||
|
||||
def test_create_and_resolve_issue():
|
||||
"""测试创建和解决任务问题"""
|
||||
repo = InMemoryTaskIssueRepository()
|
||||
create_use_case = CreateTaskIssueUseCase(repo)
|
||||
resolve_use_case = ResolveTaskIssueUseCase(repo)
|
||||
list_use_case = ListTaskIssuesUseCase(repo)
|
||||
|
||||
# 创建问题
|
||||
issue = create_use_case.execute(
|
||||
task_id="task_1",
|
||||
project_id="proj_1",
|
||||
title="接口报错",
|
||||
description="调用登录接口返回 500",
|
||||
)
|
||||
|
||||
assert issue.id is not None
|
||||
assert issue.title == "接口报错"
|
||||
assert issue.resolved is False
|
||||
|
||||
# 解决问题
|
||||
resolved_issue = resolve_use_case.execute(issue.id)
|
||||
|
||||
assert resolved_issue.resolved is True
|
||||
assert resolved_issue.resolved_at is not None
|
||||
|
||||
# 查询任务问题列表
|
||||
issues = list_use_case.execute("task_1")
|
||||
assert len(issues) == 1
|
||||
assert issues[0].resolved is True
|
||||
|
||||
|
||||
def test_task_hierarchy():
|
||||
"""测试任务层级关系"""
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
|
||||
# 创建父任务
|
||||
parent_task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="开发用户模块",
|
||||
)
|
||||
|
||||
# 创建子任务
|
||||
child_task_1 = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="登录功能",
|
||||
parent_task_id=parent_task.id,
|
||||
)
|
||||
|
||||
child_task_2 = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="注册功能",
|
||||
parent_task_id=parent_task.id,
|
||||
)
|
||||
|
||||
# 查询子任务
|
||||
children = repo.list_by_parent(parent_task.id)
|
||||
|
||||
assert len(children) == 2
|
||||
assert children[0].parent_task_id == parent_task.id
|
||||
assert children[1].parent_task_id == parent_task.id
|
||||
|
||||
|
||||
def test_get_task_detail():
|
||||
"""测试获取任务详情"""
|
||||
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
|
||||
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
get_use_case = GetTaskDetailUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="测试任务",
|
||||
description="这是一个测试任务",
|
||||
)
|
||||
|
||||
# 获取详情
|
||||
retrieved_task = get_use_case.execute(task.id)
|
||||
|
||||
assert retrieved_task.id == task.id
|
||||
assert retrieved_task.name == "测试任务"
|
||||
assert retrieved_task.description == "这是一个测试任务"
|
||||
|
||||
# 测试不存在的任务
|
||||
try:
|
||||
get_use_case.execute("nonexistent_id")
|
||||
assert False, "应该抛出异常"
|
||||
except ValueError as e:
|
||||
assert "not found" in str(e)
|
||||
|
||||
|
||||
def test_update_task():
|
||||
"""测试任务基本信息更新"""
|
||||
from packages.application.update_task_use_case import UpdateTaskUseCase
|
||||
|
||||
repo = InMemoryTaskRepository()
|
||||
create_use_case = CreateTaskUseCase(repo)
|
||||
update_use_case = UpdateTaskUseCase(repo)
|
||||
|
||||
# 创建任务
|
||||
task = create_use_case.execute(
|
||||
project_id="proj_1",
|
||||
name="原始任务",
|
||||
description="原始描述",
|
||||
priority="low",
|
||||
)
|
||||
|
||||
# 更新任务
|
||||
updated_task = update_use_case.execute(
|
||||
task_id=task.id,
|
||||
name="更新后的任务",
|
||||
description="更新后的描述",
|
||||
priority="high",
|
||||
)
|
||||
|
||||
assert updated_task.name == "更新后的任务"
|
||||
assert updated_task.description == "更新后的描述"
|
||||
assert updated_task.priority == "high"
|
||||
|
||||
# 部分更新
|
||||
partial_updated = update_use_case.execute(
|
||||
task_id=task.id,
|
||||
name="又更新了",
|
||||
)
|
||||
|
||||
assert partial_updated.name == "又更新了"
|
||||
assert partial_updated.description == "更新后的描述" # 保持不变
|
||||
assert partial_updated.priority == "high" # 保持不变
|
||||
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,12 +29,10 @@ from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
|
||||
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
|
||||
|
||||
@@ -629,7 +629,6 @@ class TestTaskCenterCrossEndpoint:
|
||||
# 2. 重试失败任务
|
||||
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
new_task_id = retry_resp.json()["source_id"]
|
||||
|
||||
# 3. 再次列出,应有2个任务(旧的failed + 新的pending)
|
||||
list_resp2 = tc.get("/tasks")
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
|
||||
@@ -181,7 +181,7 @@ def compute_audio_diff(
|
||||
timeout=120,
|
||||
)
|
||||
stderr = result.stderr or ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
except subprocess.CalledProcessError:
|
||||
# 如果音频格式不兼容,返回失败
|
||||
return AudioDiffResult(
|
||||
audio_a=str(audio_a),
|
||||
|
||||
@@ -30,7 +30,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -403,9 +403,6 @@ def generate_html_report(summary: dict[str, Any], output_path: Path):
|
||||
scenarios = summary["scenarios"]
|
||||
|
||||
# 按通过/失败分组
|
||||
passed_list = [s for s in scenarios if s["passed"]]
|
||||
failed_list = [s for s in scenarios if not s["passed"]]
|
||||
|
||||
# 构建场景卡片
|
||||
scenario_cards = ""
|
||||
for s in scenarios:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
# 设置必要环境变量(必须在导入 app 模块之前)
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
@@ -9,7 +8,6 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from app.api.routes.asset_diagnosis import _build_diagnosis
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_find_by_tag_ids(asset_repo, tag_repo):
|
||||
a2.add_tag(tag1.id)
|
||||
asset_repo.update(a2)
|
||||
|
||||
a3 = _create_asset(asset_repo, name="c.mp4")
|
||||
_create_asset(asset_repo, name="c.mp4")
|
||||
# 无标签
|
||||
|
||||
# 按 tag1 筛选 → a1, a2
|
||||
|
||||
@@ -8,8 +8,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAudioUrlSigner:
|
||||
"""测试音频URL签名函数的行为。"""
|
||||
|
||||
@@ -10,10 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from app.services.auto_clip_service import AutoClipService, ClipAssignDetail
|
||||
from app.services.auto_clip_service import AutoClipService
|
||||
|
||||
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -12,12 +12,9 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_settings_class():
|
||||
"""
|
||||
|
||||
@@ -15,9 +15,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
@@ -41,7 +40,7 @@ class TestNormalizePlanConfig:
|
||||
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
|
||||
def test_empty_dict_returns_full_defaults(self):
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
result = normalize_plan_config({})
|
||||
assert result["cover"]["type"] == "ai_frame"
|
||||
@@ -363,7 +362,7 @@ def _create_ai_test_app():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -37,13 +37,10 @@ if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app
|
||||
if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock):
|
||||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||||
|
||||
# Mock celery.Task base class — 仅在 celery 不可用时注入 mock,避免污染真实包
|
||||
try:
|
||||
import celery as _real_celery # noqa: F401
|
||||
except ImportError:
|
||||
_mock_if_absent("celery", MagicMock())
|
||||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||||
sys.modules["celery"].Task = object
|
||||
# Mock celery.Task base class
|
||||
_mock_if_absent("celery", MagicMock())
|
||||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||||
sys.modules["celery"].Task = object
|
||||
|
||||
# Mock packages.shared.storage
|
||||
_mock_if_absent("packages.shared")
|
||||
|
||||
@@ -22,7 +22,7 @@ from packages.application.duplication import (
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
|
||||
def _make_record(status="pending", **kwargs):
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -176,7 +175,6 @@ class TestRenderEditPlanFailureUpdatesGenTask:
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = StubClipRepo([])
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
# 让 clip_repo 抛异常以触发 except 路径
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -16,8 +16,8 @@ import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
@@ -27,7 +27,7 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repositories
|
||||
@@ -273,7 +273,7 @@ class TestEditTemplateServiceCRUD:
|
||||
|
||||
def test_list_templates_active_only(self):
|
||||
svc = _make_service()
|
||||
t1 = svc.create_template(name="活跃")
|
||||
svc.create_template(name="活跃")
|
||||
t2 = svc.create_template(name="停用")
|
||||
svc.deactivate_template(t2.id)
|
||||
result = svc.list_templates(active_only=True)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
邮件服务测试
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
@@ -337,7 +335,6 @@ class TestRedisStoreDegradation:
|
||||
|
||||
def test_get_returns_default_when_redis_unavailable(self):
|
||||
"""Redis 连接失败时返回默认关闭配置,不抛异常。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
@@ -358,7 +355,6 @@ class TestRedisStoreDegradation:
|
||||
|
||||
def test_list_all_returns_empty_on_redis_error(self):
|
||||
"""Redis 错误时 list_all 返回空字典。"""
|
||||
import importlib
|
||||
|
||||
from packages.adapters.redis import feature_flag_store as ff_module
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -11,18 +11,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
@@ -226,8 +222,6 @@ def test_legacy_engine_two_clips_concat_duration():
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
@@ -429,7 +429,7 @@ class TestListJobsUseCase:
|
||||
|
||||
def test_list_by_project_with_status_filter(self, repo):
|
||||
create_uc = CreateJobUseCase(repo)
|
||||
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2.mark_running()
|
||||
repo.update(j2)
|
||||
@@ -460,7 +460,7 @@ class TestListJobsUseCase:
|
||||
class TestGetJobStatisticsUseCase:
|
||||
def test_statistics(self, repo):
|
||||
create_uc = CreateJobUseCase(repo)
|
||||
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
|
||||
j2.mark_running()
|
||||
repo.update(j2)
|
||||
|
||||
@@ -15,8 +15,6 @@ import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── oss_bucket connect_timeout 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||
|
||||
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
||||
|
||||
@@ -11,8 +11,6 @@ from __future__ import annotations
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -34,11 +32,10 @@ class TestOSSBucketEndpointScheme:
|
||||
"OSS_BUCKET_NAME": "test-bucket",
|
||||
},
|
||||
),
|
||||
patch("video_processing.oss_helpers.oss2.Auth") as mock_auth,
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
# 清除缓存,确保重新创建
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
|
||||
@@ -67,9 +64,8 @@ class TestOSSBucketEndpointScheme:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
oss_bucket()
|
||||
|
||||
call_args = mock_bucket_cls.call_args
|
||||
endpoint_arg = call_args[0][1]
|
||||
@@ -95,9 +91,8 @@ class TestOSSBucketEndpointScheme:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
oss_bucket()
|
||||
|
||||
call_args = mock_bucket_cls.call_args
|
||||
endpoint_arg = call_args[0][1]
|
||||
@@ -117,7 +112,6 @@ class TestOSSBucketEndpointScheme:
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
import video_processing.oss_helpers as oss_mod
|
||||
|
||||
bucket = oss_bucket()
|
||||
assert bucket is None
|
||||
@@ -177,7 +171,7 @@ class TestGetSignedDownloadUrl:
|
||||
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||
):
|
||||
result = get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
|
||||
get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
|
||||
|
||||
mock_bucket.sign_url.assert_called_once()
|
||||
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
|
||||
|
||||
@@ -300,7 +300,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base, EditPlanClipModel, TemplateClipConfigModel
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
|
||||
@@ -16,13 +16,12 @@ import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.render_adapter import RenderAdapter, RenderAdapterResult
|
||||
from video_processing.render_adapter import RenderAdapter
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -318,7 +316,7 @@ class TestRenderPlan:
|
||||
def progress_cb(progress: float, stage: str) -> None:
|
||||
progress_values.append((progress, stage))
|
||||
|
||||
result = adapter.render_plan(
|
||||
adapter.render_plan(
|
||||
"plan_001",
|
||||
work_dir=tmp_path / "work",
|
||||
progress_cb=progress_cb,
|
||||
|
||||
@@ -3,8 +3,7 @@ Redis Session 存储测试
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||||
from packages.domain.generation_task import GenerationTaskStatus
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Template Use Cases 单元测试 — 剪辑计划模板 CRUD + 业务规则校验
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -287,7 +287,6 @@ class TestSaveToLibraryUseCase:
|
||||
repo = MagicMock()
|
||||
repo.count_by_user.return_value = 0 # 配额未满
|
||||
|
||||
expected_item = _make_voice_library_item()
|
||||
repo.create.side_effect = lambda item: item
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(repo)
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
TTSStreamingError,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
RenderLayer,
|
||||
RenderResult,
|
||||
ResolvedClip,
|
||||
UnifiedRenderService,
|
||||
@@ -683,7 +682,7 @@ class TestAssSubtitles:
|
||||
def test_generate_ass_with_subtitle_only(self, tmp_path):
|
||||
"""只有字幕时生成 ASS 文件。"""
|
||||
ass_path = tmp_path / "test.ass"
|
||||
result = generate_ass_subtitles(
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=1280,
|
||||
video_height=720,
|
||||
|
||||
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# 确保 app 模块可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
@@ -13,7 +13,6 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
ClassificationJobModel,
|
||||
EditPlanClipModel,
|
||||
EditPlanModel,
|
||||
|
||||
@@ -14,15 +14,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.services.video_compose_service import (
|
||||
ComposeCommand,
|
||||
ComposeValidation,
|
||||
VideoComposeService,
|
||||
_build_concat_filter,
|
||||
_build_xfade_filter,
|
||||
_chain_filters,
|
||||
)
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
4. 边界条件与异常场景
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, call
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user