Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 711d03a409 | |||
| 0554b4b1dd | |||
| 8c24e252e1 | |||
| d95ca5d723 | |||
| 0c15e5be03 | |||
| 3a8faeb31d | |||
| d336382f3a | |||
| d947171713 | |||
| 1f4c907bed | |||
| 121820caa9 | |||
| 52f281a66c | |||
| f1bd2d6f1d | |||
| eac05dee30 | |||
| 4263e7f6ca | |||
| db244fe14c | |||
| 75478e3fed | |||
| b0d6260617 | |||
| 83542cab86 | |||
| 1cd592c957 | |||
| 278208d31a | |||
| 0c636fd913 | |||
| 692d0b3c7e | |||
| d00a37fc31 | |||
| ad5bc81604 | |||
| 662de73a31 | |||
| 0e924c36e7 | |||
| 2b9fcb08b6 | |||
| 419e763ad6 | |||
| 86d9743909 | |||
| a65e6e293e | |||
| 7f21252634 | |||
| 1e483b7bf9 | |||
| 72b30d7959 | |||
| d1b934970a | |||
| d8dd510cba | |||
| f988a028fe | |||
| 57cd3d92dd | |||
| 5d18b76cea | |||
| 6d5b860952 | |||
| 12fb0a8e14 | |||
| 545ff0fab8 | |||
| 89a8c8b6fb | |||
| e4997b9b4a | |||
| 68c89861e9 | |||
| 076601b431 | |||
| e23a60e98b | |||
| e2e91e1d58 | |||
| 682972469c | |||
| f8b3552a4e | |||
| 5e0dcaead8 | |||
| 1eca707db3 | |||
| 5331307cbc | |||
| 3bea00aa56 | |||
| 1addcffeba | |||
| f3819653b8 | |||
| b73d75a3e4 | |||
| 62967e078b | |||
| 2818f44282 | |||
| c9e5129ec8 | |||
| cd3d366f4a | |||
| 1dd640da87 | |||
| 4587d0b014 | |||
| 4749071c16 | |||
| 3353865f5b | |||
| 7061d7e672 | |||
| 6efdfbe194 | |||
| 02e3246f5a | |||
| 5cdafd2559 | |||
| a6afb344ba | |||
| 504e2e71c9 | |||
| fb2884b03c | |||
| 7fab42c3d0 | |||
| 561548c84c | |||
| 77704e7ec6 | |||
| 5ae6c33bf6 | |||
| db07738178 | |||
| 53c09e7d3c | |||
| e602439769 | |||
| a26fda1597 | |||
| 99a8ffa97b | |||
| f03c9d5453 | |||
| 8322e2b6e2 | |||
| d2409e16c1 | |||
| 6eac0b2cf2 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 9b034764ad | |||
| 8748b43070 | |||
| d213a055a1 | |||
| 2371860f82 | |||
| dbd956fc6e | |||
| 1d59ee5336 |
@@ -1460,7 +1460,6 @@ jobs:
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
|
||||
strategy:
|
||||
@@ -2117,3 +2116,4 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
# CI retry trigger
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
retrigger3
|
||||
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -40,13 +40,6 @@ const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = (sec % 60).toFixed(0)
|
||||
return `${m}m${s.padStart(2, "0")}s`
|
||||
}
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -102,7 +95,6 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">{formatDuration(clip.duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* - ClipCard - 片段卡片
|
||||
* - ClipTrack - 片段轨道(播放头+片段列表+添加卡片)
|
||||
* - TimelineHeader - 时间线头部(标题+缩放+操作按钮)
|
||||
* - AddClipPicker - 添加片段选择器
|
||||
* - TrimPreview - 裁剪预览 tooltip
|
||||
* - ContextMenu - 右键菜单
|
||||
*
|
||||
@@ -27,7 +26,6 @@ import { usePlayheadDrag } from "./timeline/hooks/usePlayheadDrag"
|
||||
import { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { ClipTrack } from "./timeline/ClipTrack"
|
||||
import { TimelineHeader } from "./timeline/TimelineHeader"
|
||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||
import { TrimPreview } from "./timeline/TrimPreview"
|
||||
import { ContextMenu } from "./timeline/ContextMenu"
|
||||
|
||||
@@ -100,16 +98,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
@@ -177,7 +166,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipMouseLeave={() => setHoveredClipId(null)}
|
||||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||||
onClipRemove={onClipRemove}
|
||||
onTogglePicker={handleTogglePicker}
|
||||
onTogglePicker={handleConfirmAdd}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -204,20 +193,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onDelete={handleContextDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{showAddPicker && (
|
||||
<AddClipPicker
|
||||
pickerRef={pickerRef}
|
||||
position={pickerPos}
|
||||
availableTypes={availableTypes}
|
||||
addType={addType}
|
||||
addDuration={addDuration}
|
||||
onTypeChange={setAddType}
|
||||
onDurationChange={setAddDuration}
|
||||
onConfirm={handleConfirmAdd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,8 @@ interface AddClipPickerProps {
|
||||
position: { top: number; right: number }
|
||||
availableTypes: ClipType[]
|
||||
addType: ClipType
|
||||
addDuration: number
|
||||
onTypeChange: (type: ClipType) => void
|
||||
onDurationChange: (duration: number) => void
|
||||
onConfirm: () => void
|
||||
minDuration?: number
|
||||
maxDuration?: number
|
||||
}
|
||||
|
||||
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
|
||||
@@ -20,12 +16,8 @@ export const AddClipPicker: React.FC<AddClipPickerProps> = ({
|
||||
position,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
onTypeChange,
|
||||
onDurationChange,
|
||||
onConfirm,
|
||||
minDuration = 1,
|
||||
maxDuration = 120,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -53,24 +45,6 @@ export const AddClipPicker: React.FC<AddClipPickerProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时长输入 */}
|
||||
<div className="ep-add-clip-duration-row">
|
||||
<span className="ep-add-clip-type-label">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-duration-input"
|
||||
min={minDuration}
|
||||
max={maxDuration}
|
||||
value={addDuration}
|
||||
onChange={(e) =>
|
||||
onDurationChange(
|
||||
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="ep-add-clip-duration-unit">秒</span>
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
|
||||
添加
|
||||
|
||||
@@ -105,14 +105,11 @@ export const ClipCard: React.FC<ClipCardProps> = ({
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
|
||||
@@ -26,8 +26,6 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||
}, [currentMode])
|
||||
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
@@ -95,9 +93,9 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||
}, [showAddPicker])
|
||||
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
onAddClip(addType, DEFAULT_ADD_DURATION)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
}, [onAddClip, addType])
|
||||
|
||||
return {
|
||||
showAddPicker,
|
||||
@@ -107,9 +105,8 @@ export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
addDuration: DEFAULT_ADD_DURATION,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export const useTimelineMenus = (
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
} = useAddPicker({ currentMode, onAddClip })
|
||||
@@ -57,7 +56,6 @@ export const useTimelineMenus = (
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
// 悬停状态
|
||||
|
||||
@@ -16,10 +16,6 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import {
|
||||
calculateTotalVideoDuration,
|
||||
estimateTotalVideoDuration,
|
||||
} from "./utils/calculateTotalVideoDuration"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
@@ -128,7 +124,6 @@ const GeneratePage: React.FC = () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
@@ -174,13 +169,6 @@ const GeneratePage: React.FC = () => {
|
||||
[previewAssetsReady, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 视频总时长计算 ── */
|
||||
const totalVideoDuration = useMemo(() => {
|
||||
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
|
||||
if (exact > 0) return exact
|
||||
return estimateTotalVideoDuration(currentTemplate ?? undefined)
|
||||
}, [previewAssets, currentTemplate])
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -291,7 +279,6 @@ const GeneratePage: React.FC = () => {
|
||||
onCoverSettingsChange={setCoverSettings}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
onServerClipsChange={setServerClips}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface GenerateStepContentProps {
|
||||
/* 配音 */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
@@ -104,7 +103,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onCoverSettingsChange,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
onServerClipsChange,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
@@ -151,7 +149,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
|
||||
@@ -47,9 +47,7 @@ const Step1TemplateSelect: React.FC<Step1TemplateSelectProps> = (props) => {
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
<p>{tpl.segments.length}片段</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -5,18 +5,15 @@
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
|
||||
import { Modal } from "antd"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
}
|
||||
|
||||
/** 格式化时长 mm:ss */
|
||||
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
|
||||
const getDuration = (item: AssetItem): number => {
|
||||
return item.duration ?? (item.metadata?.duration as number) ?? 0
|
||||
@@ -34,13 +31,6 @@ const isAiVoice = (item: AssetItem): boolean => {
|
||||
return (!duration || duration <= 0) && (!size || size <= 0)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return "未知"
|
||||
@@ -53,13 +43,10 @@ const formatFileSize = (bytes?: number): string => {
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration = 0,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [durationWarningOpen, setDurationWarningOpen] = useState(false)
|
||||
const [pendingVoiceId, setPendingVoiceId] = useState<string | null>(null)
|
||||
|
||||
// 获取用户上传的配音素材
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
@@ -100,38 +87,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 选中素材(含时长校验) */
|
||||
/** 选中素材(直接选中,不再做时长校验弹窗) */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
// 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验)
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
onSelectedVoiceChange(id)
|
||||
},
|
||||
[onSelectedVoiceChange, totalVideoDuration, materials],
|
||||
[onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/** 确认使用时长不足的配音 */
|
||||
const handleConfirmUseAnyway = useCallback(() => {
|
||||
if (pendingVoiceId) {
|
||||
onSelectedVoiceChange(pendingVoiceId)
|
||||
}
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [pendingVoiceId, onSelectedVoiceChange])
|
||||
|
||||
/** 取消选择 */
|
||||
const handleCancelSelection = useCallback(() => {
|
||||
setDurationWarningOpen(false)
|
||||
setPendingVoiceId(null)
|
||||
}, [])
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
navigate("/app/voices?tab=material&upload=1")
|
||||
@@ -277,7 +240,7 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
{item.name}
|
||||
</div>
|
||||
|
||||
{/* 时长 + 大小 */}
|
||||
{/* 文件大小 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -289,65 +252,13 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 时长不足警告弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<WarningOutlined style={{ color: "#faad14" }} />
|
||||
配音时长不足
|
||||
</span>
|
||||
}
|
||||
open={durationWarningOpen}
|
||||
onOk={handleConfirmUseAnyway}
|
||||
onCancel={handleCancelSelection}
|
||||
okText="仍要使用"
|
||||
cancelText="重新选择"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
{(() => {
|
||||
const pendingMaterial = pendingVoiceId
|
||||
? materials.find((m) => m.id === pendingVoiceId)
|
||||
: null
|
||||
return (
|
||||
<p>
|
||||
该配音时长(
|
||||
<strong>
|
||||
{pendingMaterial ? formatDuration(getDuration(pendingMaterial)) : "--"}
|
||||
</strong>
|
||||
)短于视频总时长(
|
||||
<strong>{formatDuration(totalVideoDuration)}</strong>
|
||||
),播放时配音可能提前结束,建议选择更长的配音素材。
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@ export const useTaskHistory = () => {
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
staleTime: 5_000,
|
||||
// 有进行中任务时每 3 秒自动刷新,全部结束后停止轮询
|
||||
refetchInterval: (query) => {
|
||||
const list = query.state.data ?? []
|
||||
const hasActive = list.some((t) => ["pending", "waiting", "running"].includes(t.status))
|
||||
return hasActive ? 3_000 : false
|
||||
},
|
||||
})
|
||||
|
||||
// 重试 mutation
|
||||
|
||||
@@ -104,6 +104,9 @@ vi.mock("@ant-design/icons", () => ({
|
||||
SoundOutlined: () => <span />,
|
||||
UploadOutlined: () => <span />,
|
||||
UserOutlined: () => <span />,
|
||||
VideoCameraOutlined: () => <span />,
|
||||
InboxOutlined: () => <span />,
|
||||
CloseOutlined: () => <span />,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
|
||||
@@ -198,8 +198,13 @@ class UnifiedRenderService:
|
||||
# 2. 分组为 RenderLayers
|
||||
layers = self._group_clips_into_layers(resolved)
|
||||
|
||||
# 2.5 配音时长对齐:如果有配音素材,调整片段时长以匹配配音时长
|
||||
voice_duration = self._get_voice_audio_duration()
|
||||
if voice_duration > 0:
|
||||
self._align_clips_to_voice_duration(layers, voice_duration)
|
||||
|
||||
# 3. 计算视频总时长(用于字幕显示时长)
|
||||
video_duration = self._estimate_total_duration(layers)
|
||||
video_duration_final = self._estimate_total_duration(layers)
|
||||
# Debug: 输出各图层时长明细
|
||||
for layer in layers:
|
||||
layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips)
|
||||
@@ -215,16 +220,16 @@ class UnifiedRenderService:
|
||||
self.transition_duration,
|
||||
", ".join(clip_details),
|
||||
)
|
||||
logger.info("[debug] estimated video_duration=%.3f", video_duration)
|
||||
logger.info("[debug] estimated video_duration=%.3f", video_duration_final)
|
||||
|
||||
# 3.5 TTS 配音生成(如果配置了)
|
||||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
|
||||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration_final)
|
||||
|
||||
# 3.6 配音素材库音频(如果传入了本地路径)
|
||||
self._maybe_add_voice_library_layer(layers, video_duration=video_duration)
|
||||
self._maybe_add_voice_library_layer(layers, video_duration=video_duration_final)
|
||||
|
||||
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
|
||||
ass_path = self._maybe_generate_ass(video_duration)
|
||||
ass_path = self._maybe_generate_ass(video_duration_final)
|
||||
|
||||
# 4.5 解析画中画配置
|
||||
pip_config = PiPConfig.from_dict((self.plan.config or {}).get("pip_config"))
|
||||
@@ -257,7 +262,7 @@ class UnifiedRenderService:
|
||||
# 先尝试 stream copy 优化(无重编码,性能提升 10 倍+)
|
||||
# 条件不满足或失败时回退到带滤镜的直通渲染
|
||||
stream_copy_ok = self._try_render_stream_copy(
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration_final
|
||||
)
|
||||
if stream_copy_ok:
|
||||
used_stream_copy = True
|
||||
@@ -271,7 +276,7 @@ class UnifiedRenderService:
|
||||
layers,
|
||||
output_path,
|
||||
ass_path=ass_path,
|
||||
video_duration=video_duration,
|
||||
video_duration=video_duration_final,
|
||||
)
|
||||
else:
|
||||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||||
@@ -327,7 +332,7 @@ class UnifiedRenderService:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(extract_cmd)
|
||||
final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration)
|
||||
final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration_final)
|
||||
# 合并回视频
|
||||
|
||||
bgm_output = self.work_dir / f"rendered_{self.plan.id}_bgm.mp4"
|
||||
@@ -353,7 +358,7 @@ class UnifiedRenderService:
|
||||
audio_path = mix_audio(
|
||||
ctx,
|
||||
layers,
|
||||
video_duration,
|
||||
video_duration_final,
|
||||
bgm_path=self.bgm_path,
|
||||
bgm_config=bgm_config,
|
||||
audio_tracks_config=audio_tracks_config,
|
||||
@@ -487,6 +492,147 @@ class UnifiedRenderService:
|
||||
"""
|
||||
return _estimate_total_duration_pure(layers, self.transition_duration)
|
||||
|
||||
def _get_voice_audio_duration(self) -> float:
|
||||
"""获取配音音频文件的时长(秒)。
|
||||
|
||||
Returns:
|
||||
配音音频时长,如果无配音或探测失败则返回 0.0
|
||||
"""
|
||||
if not self.voiceover_audio_path:
|
||||
return 0.0
|
||||
|
||||
audio_path = Path(self.voiceover_audio_path)
|
||||
if not audio_path.exists() or audio_path.stat().st_size == 0:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
duration = probe_duration(audio_path)
|
||||
logger.info("[voice-align] 配音音频时长: %.3fs path=%s", duration, self.voiceover_audio_path)
|
||||
return duration
|
||||
except Exception as e:
|
||||
logger.warning("[voice-align] 探测配音音频时长失败: %s", e)
|
||||
return 0.0
|
||||
|
||||
def _align_clips_to_voice_duration(
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
voice_duration: float,
|
||||
) -> None:
|
||||
"""调整片段时长以对齐配音时长。
|
||||
|
||||
核心逻辑:
|
||||
- 计算片段总时长与配音时长的比例
|
||||
- ±5% 以内不调整
|
||||
- ratio < 1(片段比配音长):按比例裁剪每段末尾
|
||||
- ratio > 1(片段比配音短):按比例慢放每段
|
||||
|
||||
Args:
|
||||
layers: 渲染图层列表
|
||||
voice_duration: 配音时长(秒)
|
||||
"""
|
||||
if voice_duration <= 0:
|
||||
return
|
||||
|
||||
# 只调整视频图层(main/broll/background),不调整音频图层
|
||||
video_layers = [layer for layer in layers if layer.role in ("main", "broll", "background")]
|
||||
if not video_layers:
|
||||
return
|
||||
|
||||
# 计算所有视频图层的总时长
|
||||
total_clips_duration = 0.0
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
clip_dur = self._clip_adjusted_duration(clip)
|
||||
total_clips_duration += clip_dur
|
||||
|
||||
if total_clips_duration <= 0:
|
||||
return
|
||||
|
||||
ratio = voice_duration / total_clips_duration
|
||||
|
||||
# ±5% 以内不调整
|
||||
if abs(ratio - 1.0) <= 0.05:
|
||||
logger.info(
|
||||
"[voice-align] 比例接近1:1,跳过调整: ratio=%.4f voice=%.3f clips=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
total_clips_duration,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[voice-align] 开始调整片段时长: ratio=%.4f voice=%.3f clips=%.3f",
|
||||
ratio,
|
||||
voice_duration,
|
||||
total_clips_duration,
|
||||
)
|
||||
|
||||
# 收集所有视频 clip
|
||||
all_clips: list[tuple[RenderLayer, ResolvedClip]] = []
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
all_clips.append((layer, clip))
|
||||
|
||||
if not all_clips:
|
||||
return
|
||||
|
||||
if ratio < 1.0:
|
||||
# 片段比配音长,按比例裁剪每段末尾
|
||||
# 减少每个 clip 的 duration
|
||||
for _layer, clip in all_clips:
|
||||
old_duration = clip.duration if clip.duration > 0 else clip.actual_duration
|
||||
new_duration = old_duration * ratio
|
||||
|
||||
# 更新 duration
|
||||
clip.duration = max(0.1, new_duration) # 至少 0.1s
|
||||
|
||||
# 如果有 trim_config,也需要调整
|
||||
if clip.trim_config is not None:
|
||||
new_trim_duration = clip.trim_config.duration * ratio
|
||||
clip.trim_config = TrimConfig(
|
||||
start_time=clip.trim_config.start_time,
|
||||
duration=max(0.1, new_trim_duration),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[voice-align] trim clip=%s: %.3f -> %.3f",
|
||||
clip.clip_id,
|
||||
old_duration,
|
||||
clip.duration,
|
||||
)
|
||||
|
||||
else:
|
||||
# ratio > 1.0: 片段比配音短,按比例慢放每段
|
||||
# 降低 playback_speed
|
||||
for _layer, clip in all_clips:
|
||||
old_speed = clip.playback_speed if clip.playback_speed > 0 else 1.0
|
||||
# speed = old_speed / ratio 会使视频变慢(ratio > 1 时)
|
||||
new_speed = old_speed / ratio
|
||||
|
||||
# 下限 0.25x(避免过慢)
|
||||
new_speed = max(0.25, round(new_speed, 4))
|
||||
clip.playback_speed = new_speed
|
||||
|
||||
logger.debug(
|
||||
"[voice-align] slowdown clip=%s: speed %.4f -> %.4f",
|
||||
clip.clip_id,
|
||||
old_speed,
|
||||
new_speed,
|
||||
)
|
||||
|
||||
# 调整后重新计算总时长用于日志
|
||||
new_total = 0.0
|
||||
for layer in video_layers:
|
||||
for clip in layer.clips:
|
||||
new_total += self._clip_adjusted_duration(clip)
|
||||
|
||||
logger.info(
|
||||
"[voice-align] 调整完成: 新总时长=%.3fs (目标=%.3fs, 差异=%.3fs)",
|
||||
new_total,
|
||||
voice_duration,
|
||||
abs(new_total - voice_duration),
|
||||
)
|
||||
|
||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||
"""根据 plan.config 生成 ASS 字幕文件。
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
微信同步登录/注册 Use Case
|
||||
|
||||
供 BFF 层调用的系统级接口:
|
||||
- 根据 openid 查找用户,找到则登录返回 token
|
||||
- 没找到则创建新用户并返回 token
|
||||
- 优先按 unionid 识别用户(跨应用/跨端识别同一微信用户)
|
||||
- 再按 openid 识别(同一应用内)
|
||||
- openid 命中老账号但 unionid 缺失时补写 unionid(开放平台绑定前的存量账号自动关联)
|
||||
- 都未命中则创建新用户
|
||||
- 支持 unionid 跨应用关联
|
||||
"""
|
||||
|
||||
@@ -82,10 +84,10 @@ class WechatSyncResponse:
|
||||
|
||||
|
||||
class WechatSyncUseCase:
|
||||
"""微信同步登录/注册用例
|
||||
"""微信登录/注册同步用例
|
||||
|
||||
系统级接口,由 BFF 通过 API Key 调用。
|
||||
职责:根据 openid 查找或创建用户,返回 SaaS token。
|
||||
职责:根据 unionid/openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
|
||||
@@ -105,24 +107,54 @@ class WechatSyncUseCase:
|
||||
return None, "openid is required"
|
||||
|
||||
is_new_user = False
|
||||
user = None
|
||||
openid_user = None
|
||||
unionid_user = None
|
||||
|
||||
# 1. 按 openid 查找用户
|
||||
user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
# 1. 先按 unionid 查找(跨应用识别同一微信用户,优先级最高)
|
||||
if request.unionid:
|
||||
unionid_user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
|
||||
# 2. 如果 openid 没找到,尝试 unionid
|
||||
if not user and request.unionid:
|
||||
user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if user:
|
||||
# 找到用户但 openid 为空,绑定一下当前 openid
|
||||
user.wechat_openid = request.openid
|
||||
self.user_repository.save(user)
|
||||
# 2. 再按 openid 查找(同一应用内)
|
||||
openid_user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
|
||||
# 3. 都没找到则创建新用户
|
||||
if not user:
|
||||
if unionid_user and openid_user:
|
||||
# 3a. 两边都命中
|
||||
if unionid_user.id == openid_user.id:
|
||||
# 同一个用户,直接登录
|
||||
user = unionid_user
|
||||
else:
|
||||
# unionid 与 openid 分属两个不同账号:数据异常,拒绝写入,
|
||||
# 交由人工/数据修复合并,避免账号被错误串联
|
||||
return None, ("wechat account conflict: unionid and openid bound to " "different users")
|
||||
elif unionid_user:
|
||||
# 3b. unionid 命中(跨端老用户),当前 openid 未绑定过:
|
||||
# 确认 openid 没有落在其他账号上后,把新 openid 绑到该用户
|
||||
if openid_user is not None and openid_user.id != unionid_user.id:
|
||||
return None, ("wechat account conflict: openid bound to another user")
|
||||
if unionid_user.wechat_openid != request.openid:
|
||||
unionid_user.wechat_openid = request.openid
|
||||
self.user_repository.save(unionid_user)
|
||||
user = unionid_user
|
||||
elif openid_user:
|
||||
# 3c. 仅 openid 命中(开放平台绑定前创建的存量账号):
|
||||
# 本次请求带了 unionid 且该账号还没有 unionid 时补写
|
||||
if request.unionid and not openid_user.wechat_unionid:
|
||||
# 去重:确认该 unionid 没有关联到其他用户
|
||||
conflict = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if conflict is not None and conflict.id != openid_user.id:
|
||||
return None, ("wechat account conflict: unionid already bound to " "another user")
|
||||
openid_user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(openid_user)
|
||||
user = openid_user
|
||||
else:
|
||||
# 4. 都没找到,创建新用户
|
||||
# 额外兜底:若 unionid 已被其他账号占用(理论上上面已查过),
|
||||
# 不创建带冲突 unionid 的新账号
|
||||
user = self._create_wechat_user(request)
|
||||
is_new_user = True
|
||||
|
||||
# 4. 创建 session 并生成 token
|
||||
# 5. 创建 session 并生成 token
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
|
||||
# 用法:
|
||||
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
|
||||
# ============================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
ACR_USERNAME="${ACR_USERNAME:-}"
|
||||
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
||||
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
|
||||
|
||||
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
|
||||
IMAGES=(
|
||||
"python:3.12-slim-bookworm"
|
||||
"python:3.12-slim"
|
||||
"node:20"
|
||||
"nginx:alpine"
|
||||
)
|
||||
|
||||
echo "============================================"
|
||||
echo " 基础镜像同步到 ACR"
|
||||
echo " ACR: $ACR_REGISTRY"
|
||||
echo " 源: $SOURCE_PREFIX"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 登录 ACR
|
||||
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
|
||||
echo "登录 ACR..."
|
||||
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
|
||||
echo "ACR 登录成功"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
success=0
|
||||
failed=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
source_image="${SOURCE_PREFIX}/${image}"
|
||||
target_image="${ACR_REGISTRY}/base/${image}"
|
||||
|
||||
echo "--- 同步: $image ---"
|
||||
echo " 源: $source_image"
|
||||
echo " 目标: $target_image"
|
||||
|
||||
# Pull 源镜像(带重试)
|
||||
pulled=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Pull 尝试 $attempt/3..."
|
||||
if docker pull "$source_image"; then
|
||||
pulled=1
|
||||
break
|
||||
fi
|
||||
echo " Pull 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pulled" -eq 0 ]; then
|
||||
echo " ❌ Pull 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tag
|
||||
docker tag "$source_image" "$target_image"
|
||||
echo " Tag 完成"
|
||||
|
||||
# Push 到 ACR
|
||||
pushed=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Push 尝试 $attempt/3..."
|
||||
if docker push "$target_image"; then
|
||||
pushed=1
|
||||
break
|
||||
fi
|
||||
echo " Push 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pushed" -eq 1 ]; then
|
||||
echo " ✅ 同步成功: $image"
|
||||
success=$((success + 1))
|
||||
else
|
||||
echo " ❌ Push 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "============================================"
|
||||
echo " 同步完成"
|
||||
echo " 成功: $success"
|
||||
echo " 失败: $failed"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
results = data.get('results', {})
|
||||
total = sum(len(v) for v in results.values())
|
||||
print(total)
|
||||
except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
for fpath, items in data.get('results', {}).items():
|
||||
for item in items:
|
||||
line = item.get('line_number', '?')
|
||||
stype = item.get('type', '?')
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查所有 CI Python 脚本语法
|
||||
for script in scripts/ci/*.py; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! python3 -m py_compile "$script" 2>&1; then
|
||||
echo "❌ Python语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查 .gitea/workflows 下的脚本(如果有)
|
||||
for script in .gitea/workflows/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$SYNTAX_ERROR" -ne 0 ]; then
|
||||
echo "❌ CI脚本语法校验失败,见上方错误"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
@@ -189,14 +189,25 @@ class TestComputeDuplicateRateFormula:
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
# 10 frames, all identical to existing → frame_match_rate = 1.0
|
||||
phashes = ["aa00aa00aa00aa00"] * 10
|
||||
# 10 frames with varied phashes (2 unique) → not bad fingerprint
|
||||
# All close in hamming distance to existing → frame_match_rate = 1.0
|
||||
phashes = ["aa00aa00aa00aa00", "ab00ab00ab00ab00"] * 5
|
||||
fingerprint = _make_fingerprint(md5="new", phashes=phashes, duration_ms=20000)
|
||||
session = MagicMock()
|
||||
|
||||
# 5 unique phashes to pass _is_bad_fingerprint check (PR #1688)
|
||||
existing = _make_video(
|
||||
"vid2",
|
||||
{"md5": "other", "keyframe_phashes": ["aa00aa00aa00aa00"] * 5},
|
||||
{
|
||||
"md5": "other",
|
||||
"keyframe_phashes": [
|
||||
"aa00aa00aa00aa00",
|
||||
"ab00ab00ab00ab00",
|
||||
"ac00ac00ac00ac00",
|
||||
"aa10aa10aa10aa10",
|
||||
"ba00ba00ba00ba00",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for voice duration alignment feature.
|
||||
|
||||
Tests the _align_clips_to_voice_duration method in UnifiedRenderService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip, UnifiedRenderService
|
||||
|
||||
|
||||
class TestAlignClipsToVoiceDuration:
|
||||
"""Test clip duration alignment to voice audio."""
|
||||
|
||||
def _make_clip(
|
||||
self,
|
||||
clip_id: str,
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
playback_speed: float = 1.0,
|
||||
) -> ResolvedClip:
|
||||
"""Helper to create a ResolvedClip for testing."""
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}",
|
||||
local_path=Path(f"/tmp/{clip_id}.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=duration,
|
||||
actual_duration=actual_duration or duration,
|
||||
playback_speed=playback_speed,
|
||||
)
|
||||
|
||||
def _make_layer(self, role: str, clips: list[ResolvedClip]) -> RenderLayer:
|
||||
"""Helper to create a RenderLayer for testing."""
|
||||
return RenderLayer(role=role, clips=clips, z_index=0)
|
||||
|
||||
def _make_service(self, voiceover_path: str | None = None) -> UnifiedRenderService:
|
||||
"""Helper to create a mock UnifiedRenderService."""
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
plan.config = {}
|
||||
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.plan = plan
|
||||
service.voiceover_audio_path = voiceover_path
|
||||
service.transition_duration = 0.0
|
||||
return service
|
||||
|
||||
def test_no_voice_audio_no_adjustment(self):
|
||||
"""No voice audio → no adjustment."""
|
||||
service = self._make_service(voiceover_path=None)
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=0.0)
|
||||
|
||||
# No change
|
||||
assert clips[0].duration == 10.0
|
||||
assert clips[1].duration == 10.0
|
||||
|
||||
def test_ratio_within_5_percent_no_adjustment(self):
|
||||
"""Ratio within ±5% → no adjustment."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 10s, voice = 10.3s → ratio = 1.03 (within 5%)
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=10.3)
|
||||
|
||||
assert clips[0].duration == 10.0 # Unchanged
|
||||
|
||||
def test_ratio_less_than_1_trim_clips(self):
|
||||
"""Ratio < 1 (clips too long) → trim clips proportionally."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 20s, voice = 15s → ratio = 0.75
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=15.0)
|
||||
|
||||
# Each clip should be trimmed to 75%
|
||||
assert abs(clips[0].duration - 7.5) < 0.01
|
||||
assert abs(clips[1].duration - 7.5) < 0.01
|
||||
|
||||
def test_ratio_greater_than_1_slowdown_clips(self):
|
||||
"""Ratio > 1 (clips too short) → slow down clips."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 10.0), self._make_clip("c2", 10.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 20s, voice = 25s → ratio = 1.25
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=25.0)
|
||||
|
||||
# Each clip's speed should be reduced: 1.0 / 1.25 = 0.8
|
||||
assert abs(clips[0].playback_speed - 0.8) < 0.01
|
||||
assert abs(clips[1].playback_speed - 0.8) < 0.01
|
||||
|
||||
def test_speed_lower_bound_025(self):
|
||||
"""Playback speed should not go below 0.25x."""
|
||||
service = self._make_service()
|
||||
clips = [self._make_clip("c1", 5.0)]
|
||||
layers = [self._make_layer("main", clips)]
|
||||
|
||||
# Total clips = 5s, voice = 50s → ratio = 10.0
|
||||
# Speed would be 1.0 / 10 = 0.1, but should be clamped to 0.25
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=50.0)
|
||||
|
||||
assert clips[0].playback_speed == 0.25
|
||||
|
||||
def test_only_video_layers_adjusted(self):
|
||||
"""Only main/broll/background layers are adjusted, not audio."""
|
||||
service = self._make_service()
|
||||
|
||||
video_clips = [self._make_clip("v1", 10.0)]
|
||||
audio_clips = [self._make_clip("a1", 10.0)]
|
||||
|
||||
layers = [
|
||||
self._make_layer("main", video_clips),
|
||||
self._make_layer("audio", audio_clips),
|
||||
]
|
||||
|
||||
# ratio = 0.5 → should trim video but not audio
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=5.0)
|
||||
|
||||
assert abs(video_clips[0].duration - 5.0) < 0.01 # Trimmed
|
||||
assert audio_clips[0].duration == 10.0 # Unchanged
|
||||
|
||||
def test_multiple_video_layers_all_adjusted(self):
|
||||
"""All video layers (main, broll, background) are adjusted."""
|
||||
service = self._make_service()
|
||||
|
||||
main_clips = [self._make_clip("m1", 10.0)]
|
||||
broll_clips = [self._make_clip("b1", 10.0)]
|
||||
bg_clips = [self._make_clip("bg1", 10.0)]
|
||||
|
||||
layers = [
|
||||
self._make_layer("main", main_clips),
|
||||
self._make_layer("broll", broll_clips),
|
||||
self._make_layer("background", bg_clips),
|
||||
]
|
||||
|
||||
# Total video = 30s, voice = 15s → ratio = 0.5
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=15.0)
|
||||
|
||||
# All should be trimmed to 50%
|
||||
assert abs(main_clips[0].duration - 5.0) < 0.01
|
||||
assert abs(broll_clips[0].duration - 5.0) < 0.01
|
||||
assert abs(bg_clips[0].duration - 5.0) < 0.01
|
||||
|
||||
def test_trim_config_also_adjusted(self):
|
||||
"""When clip has trim_config, it should also be adjusted."""
|
||||
from video_processing.trim_engine import TrimConfig
|
||||
|
||||
service = self._make_service()
|
||||
|
||||
clip = self._make_clip("c1", 10.0)
|
||||
clip.trim_config = TrimConfig(start_time=0.0, duration=10.0)
|
||||
|
||||
layers = [self._make_layer("main", [clip])]
|
||||
|
||||
# ratio = 0.5
|
||||
service._align_clips_to_voice_duration(layers, voice_duration=5.0)
|
||||
|
||||
assert abs(clip.duration - 5.0) < 0.01
|
||||
assert clip.trim_config is not None
|
||||
assert abs(clip.trim_config.duration - 5.0) < 0.01
|
||||
|
||||
|
||||
class TestGetVoiceAudioDuration:
|
||||
"""Test voice audio duration probing."""
|
||||
|
||||
def test_no_voiceover_path_returns_zero(self):
|
||||
"""No voiceover path → return 0."""
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = None
|
||||
|
||||
assert service._get_voice_audio_duration() == 0.0
|
||||
|
||||
def test_nonexistent_file_returns_zero(self):
|
||||
"""Nonexistent file → return 0."""
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = "/nonexistent/path.mp3"
|
||||
|
||||
assert service._get_voice_audio_duration() == 0.0
|
||||
|
||||
@patch("video_processing.unified_render_service.probe_duration")
|
||||
@patch("video_processing.unified_render_service.Path.exists", return_value=True)
|
||||
@patch("video_processing.unified_render_service.Path.stat")
|
||||
def test_probes_duration_from_file(self, mock_stat, mock_exists, mock_probe):
|
||||
"""Valid file → probe duration."""
|
||||
mock_stat.return_value.st_size = 1000 # Non-empty file
|
||||
mock_probe.return_value = 42.5
|
||||
|
||||
with patch.object(UnifiedRenderService, "__init__", lambda self, **kwargs: None):
|
||||
service = UnifiedRenderService.__new__(UnifiedRenderService)
|
||||
service.voiceover_audio_path = "/tmp/voice.mp3"
|
||||
|
||||
assert service._get_voice_audio_duration() == 42.5
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,10 +13,17 @@ from packages.application.auth.wechat_sync_use_case import (
|
||||
)
|
||||
from packages.domain.entities import User
|
||||
|
||||
JWT_KEY = "test-secret-key-for-jwt-12345"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_repo():
|
||||
return MagicMock()
|
||||
repo = MagicMock()
|
||||
# 默认全部查不到,具体用例再覆盖
|
||||
repo.find_by_wechat_openid.return_value = None
|
||||
repo.find_by_wechat_unionid.return_value = None
|
||||
repo.find_by_username.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -41,40 +48,29 @@ def sample_user():
|
||||
return user
|
||||
|
||||
|
||||
class TestWechatSyncRequest:
|
||||
"""WechatSyncRequest 测试"""
|
||||
def make_use_case(repo, store):
|
||||
return WechatSyncUseCase(repo, session_store=store, jwt_secret_key=JWT_KEY)
|
||||
|
||||
|
||||
class TestWechatSyncRequest:
|
||||
def test_openid_stripped(self):
|
||||
"""openid 被 strip"""
|
||||
req = WechatSyncRequest(openid=" openid_123 ")
|
||||
assert req.openid == "openid_123"
|
||||
assert WechatSyncRequest(openid=" openid_123 ").openid == "openid_123"
|
||||
|
||||
def test_unionid_stripped(self):
|
||||
"""unionid 被 strip"""
|
||||
req = WechatSyncRequest(openid="o1", unionid=" unionid_456 ")
|
||||
assert req.unionid == "unionid_456"
|
||||
assert WechatSyncRequest(openid="o1", unionid=" unionid_456 ").unionid == "unionid_456"
|
||||
|
||||
def test_default_nickname(self):
|
||||
"""默认昵称"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.nickname == "微信用户"
|
||||
assert WechatSyncRequest(openid="o1").nickname == "微信用户"
|
||||
|
||||
def test_default_source(self):
|
||||
"""默认来源"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.source == "miniapp"
|
||||
assert WechatSyncRequest(openid="o1").source == "miniapp"
|
||||
|
||||
def test_empty_unionid(self):
|
||||
"""不传 unionid 默认为空字符串"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.unionid == ""
|
||||
assert WechatSyncRequest(openid="o1").unionid == ""
|
||||
|
||||
|
||||
class TestWechatSyncResponse:
|
||||
"""WechatSyncResponse 测试"""
|
||||
|
||||
def test_to_dict_contains_fields(self):
|
||||
"""to_dict 包含所有必要字段"""
|
||||
resp = WechatSyncResponse(
|
||||
access_token="access_123",
|
||||
refresh_token="refresh_456",
|
||||
@@ -85,276 +81,241 @@ class TestWechatSyncResponse:
|
||||
expires_in=1800,
|
||||
)
|
||||
data = resp.to_dict()
|
||||
|
||||
assert data["access_token"] == "access_123"
|
||||
assert data["token"] == "access_123" # 兼容字段
|
||||
assert data["token"] == "access_123"
|
||||
assert data["refresh_token"] == "refresh_456"
|
||||
assert data["user_id"] == "user_001"
|
||||
assert data["is_new_user"] is False
|
||||
assert data["expires_in"] == 1800
|
||||
assert "user" in data
|
||||
assert "user_info" in data
|
||||
assert data["user"]["id"] == "user_001"
|
||||
assert data["user"]["nickname"] == "测试用户"
|
||||
assert data["user"]["display_name"] == "测试用户"
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseLoginExisting:
|
||||
"""已有用户登录测试"""
|
||||
|
||||
class TestWechatSyncLoginExisting:
|
||||
def test_login_by_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""通过 openid 登录已有用户"""
|
||||
"""openid 命中、unionid 一致,正常登录"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123", nickname="测试")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user_id == "user_001"
|
||||
assert response.is_new_user is False
|
||||
mock_user_repo.find_by_wechat_openid.assert_called_once_with("openid_123")
|
||||
mock_session_store.save_session.assert_called_once()
|
||||
|
||||
def test_login_by_unionid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""openid 没找到,通过 unionid 找到并绑定 openid"""
|
||||
sample_user.wechat_openid = None # 没有当前 openid
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||||
)
|
||||
request = WechatSyncRequest(
|
||||
openid="new_openid",
|
||||
unionid="unionid_456",
|
||||
nickname="测试",
|
||||
)
|
||||
response, error = use_case.execute(request)
|
||||
assert err is None
|
||||
assert resp.user_id == "user_001"
|
||||
assert resp.is_new_user is False
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is False
|
||||
# 应该保存了新的 openid
|
||||
assert sample_user.wechat_openid == "new_openid"
|
||||
def test_backfill_unionid_for_legacy_openid_user(self, mock_user_repo, mock_session_store):
|
||||
"""核心修复:openid 命中的老账号没有 unionid,请求带 unionid 时补写"""
|
||||
legacy = User(
|
||||
id="legacy_001",
|
||||
email="legacy@wechat.local",
|
||||
username="wx_legacy",
|
||||
display_name="微信用户",
|
||||
password_hash="h",
|
||||
email_verified=True,
|
||||
wechat_openid="oGjxK3_old",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = legacy
|
||||
# unionid 查找:补写前确认无其他账号占用
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="oGjxK3_old", unionid="o5nVk_union")
|
||||
)
|
||||
assert err is None
|
||||
assert resp.user_id == "legacy_001"
|
||||
assert resp.is_new_user is False
|
||||
assert legacy.wechat_unionid == "o5nVk_union"
|
||||
# 至少保存过一次(补写 + 最后登录更新)
|
||||
mock_user_repo.save.assert_called()
|
||||
|
||||
def test_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""登录时更新最后登录信息"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
def test_login_by_unionid_binds_new_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""unionid 命中(跨端老用户),openid 未绑定过 → 绑定新 openid"""
|
||||
sample_user.wechat_openid = None
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="new_openid", unionid="unionid_456", nickname="测试")
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
use_case.execute(request)
|
||||
assert err is None
|
||||
assert resp.is_new_user is False
|
||||
assert sample_user.wechat_openid == "new_openid"
|
||||
|
||||
def test_unionid_user_already_has_same_openid_no_extra_write(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""unionid 命中且 openid 已经是当前 openid,不额外改写"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
saved = []
|
||||
mock_user_repo.save.side_effect = lambda u: saved.append(u)
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||||
)
|
||||
# 只有最后登录信息那一次 save,没有绑定/补写导致的额外 save
|
||||
assert len(saved) == 1
|
||||
|
||||
def test_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert sample_user.last_login_at is not None
|
||||
assert sample_user.last_login_ip == "bff_gateway"
|
||||
|
||||
def test_returns_tokens(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""返回 access_token 和 refresh_token"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
resp, _ = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert resp.access_token and resp.refresh_token and resp.expires_in > 0
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
|
||||
class TestWechatSyncConflicts:
|
||||
def test_unionid_and_openid_bound_to_different_users(self, mock_user_repo, mock_session_store):
|
||||
"""unionid 与 openid 分属两个账号 → 冲突报错,不写库"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="o1",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
response, _ = use_case.execute(request)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="oX",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||||
|
||||
assert response.access_token is not None
|
||||
assert len(response.access_token) > 0
|
||||
assert response.refresh_token is not None
|
||||
assert len(response.refresh_token) > 0
|
||||
assert response.expires_in > 0
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="o1", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
# 补写不得发生
|
||||
assert ua.wechat_unionid is None
|
||||
|
||||
def test_backfill_unionid_already_used_by_other(self, mock_user_repo, mock_session_store):
|
||||
"""给 openid 老账号补 unionid 时发现 unionid 已被他人占用 → 冲突"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="o1",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="o2",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
# openid 命中 ua;unionid 首次查找(优先级查询)命中 ub
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="o1", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
assert ua.wechat_unionid is None
|
||||
|
||||
def test_unionid_user_openid_belongs_to_other(self, mock_user_repo, mock_session_store):
|
||||
"""unionid 命中 ua,但请求的 openid 属于另一个账号 ub → 冲突,不抢占 openid"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="oA",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="oB",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ub
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ua
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="oB", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
assert ua.wechat_openid == "oA" # 未被改写
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseNewUser:
|
||||
"""新用户注册测试"""
|
||||
|
||||
class TestWechatSyncNewUser:
|
||||
def test_create_new_user(self, mock_user_repo, mock_session_store):
|
||||
"""openid 和 unionid 都没找到,创建新用户"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None # username 不重复
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
saved = {}
|
||||
mock_user_repo.save.side_effect = lambda u: saved.update({u.id: u})
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="new_openid_789", unionid="new_union_789", nickname="新用户")
|
||||
)
|
||||
request = WechatSyncRequest(
|
||||
openid="new_openid_789",
|
||||
unionid="new_union_789",
|
||||
nickname="新用户",
|
||||
avatar_url="https://example.com/avatar.jpg",
|
||||
)
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
assert saved_user is not None
|
||||
assert saved_user.wechat_openid == "new_openid_789"
|
||||
assert saved_user.wechat_unionid == "new_union_789"
|
||||
assert saved_user.email.endswith("@wechat.local")
|
||||
assert saved_user.username.startswith("wx_")
|
||||
assert saved_user.email_verified is True
|
||||
|
||||
def test_new_user_email_based_on_openid(self, mock_user_repo, mock_session_store):
|
||||
"""新用户邮箱基于 openid 生成"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="abcdef1234567890")
|
||||
use_case.execute(request)
|
||||
|
||||
assert "abcdef1234567890" in saved_user.email or "abcdef1234567890"[:20] in saved_user.email
|
||||
assert saved_user.email.endswith("@wechat.local")
|
||||
assert err is None
|
||||
assert resp.is_new_user is True
|
||||
u = saved[resp.user_id]
|
||||
assert u.wechat_openid == "new_openid_789"
|
||||
assert u.wechat_unionid == "new_union_789"
|
||||
assert u.email.endswith("@wechat.local")
|
||||
assert u.username.startswith("wx_")
|
||||
assert u.email_verified is True
|
||||
assert u.password_hash
|
||||
|
||||
def test_username_conflict_adds_suffix(self, mock_user_repo, mock_session_store):
|
||||
"""用户名冲突时加后缀"""
|
||||
call_count = [0]
|
||||
|
||||
def mock_find_by_username(username):
|
||||
# 前两次返回存在(模拟冲突),第三次返回 None(可用)
|
||||
def find_by_username(username):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 2:
|
||||
return MagicMock()
|
||||
return None
|
||||
return MagicMock() if call_count[0] <= 2 else None
|
||||
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.side_effect = mock_find_by_username
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="test_openid")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
# find_by_username 被调用了多次(找不冲突的用户名)
|
||||
assert mock_user_repo.find_by_username.call_count >= 2
|
||||
|
||||
def test_new_user_has_password_hash(self, mock_user_repo, mock_session_store):
|
||||
"""新用户有随机密码哈希(不能是空的)"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="new_openid")
|
||||
use_case.execute(request)
|
||||
|
||||
assert saved_user.password_hash is not None
|
||||
assert len(saved_user.password_hash) > 0
|
||||
mock_user_repo.find_by_username.side_effect = find_by_username
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="test_openid"))
|
||||
assert err is None
|
||||
assert resp.is_new_user is True
|
||||
assert call_count[0] >= 2
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseErrors:
|
||||
"""错误场景测试"""
|
||||
|
||||
class TestWechatSyncErrors:
|
||||
def test_empty_openid(self, mock_user_repo, mock_session_store):
|
||||
"""空 openid 返回错误"""
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "openid is required" in error
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid=""))
|
||||
assert resp is None
|
||||
assert "openid is required" in err
|
||||
|
||||
def test_exception_returns_error(self, mock_user_repo, mock_session_store):
|
||||
"""异常时返回友好错误"""
|
||||
mock_user_repo.find_by_wechat_openid.side_effect = Exception("DB error")
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "Internal error" in error
|
||||
mock_user_repo.find_by_wechat_unionid.side_effect = Exception("DB error")
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert resp is None
|
||||
assert "Internal error" in err
|
||||
|
||||
|
||||
class TestWechatSyncSession:
|
||||
"""Session 相关测试"""
|
||||
|
||||
def test_session_saved(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""登录时保存 session"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", source="miniapp")
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123", source="miniapp")
|
||||
use_case.execute(request)
|
||||
|
||||
mock_session_store.save_session.assert_called_once()
|
||||
call_kwargs = mock_session_store.save_session.call_args[1]
|
||||
assert call_kwargs["user_id"] == "user_001"
|
||||
assert "wechat_miniapp" in call_kwargs["device_info"]
|
||||
assert call_kwargs["expires_in_seconds"] == 30 * 24 * 3600
|
||||
kw = mock_session_store.save_session.call_args[1]
|
||||
assert kw["user_id"] == "user_001"
|
||||
assert "wechat_miniapp" in kw["device_info"]
|
||||
assert kw["expires_in_seconds"] == 30 * 24 * 3600
|
||||
|
||||
Reference in New Issue
Block a user