Compare commits
23 Commits
e4b0ed3d74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0224c8b3e3 | |||
| 2ea88316d2 | |||
| 499698cb0d | |||
| 077a15451b | |||
| b1750302c8 | |||
| 703cc2697e | |||
| e99c0806de | |||
| d95b0a63dd | |||
| 71ed87371b | |||
| 3a3638295b | |||
| 248deeea0d | |||
| 8538b3cdb4 | |||
| 0666f3bc8c | |||
| 027db6efc9 | |||
| 98fb35f0b9 | |||
| 5477e2f261 | |||
| d5af885ce3 | |||
| 9397e0f177 | |||
| 8fb11fa6ca | |||
| b6ab8f2b9d | |||
| 66582c543d | |||
| 038f205413 | |||
| 8f8b0a03e4 |
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import dayjs from 'dayjs'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { UserPromptApi } from '@/api/userPrompt'
|
||||
|
||||
@@ -62,7 +63,7 @@ async function loadList() {
|
||||
name: item.name,
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
useCount: item.useCount || 0
|
||||
createTime: item.createTime
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -172,7 +173,7 @@ function handleUse(item) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="text-xs text-muted-foreground">使用 {{ item.useCount || 0 }} 次</span>
|
||||
<span class="text-xs text-muted-foreground" v-if="item.createTime">{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +244,7 @@ function handleUse(item) {
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -1,129 +1,98 @@
|
||||
<template>
|
||||
<Dialog :open="props.visible" @update:open="handleVisibleChange">
|
||||
<DialogContent class="max-w-[600px]">
|
||||
<DialogContent class="max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传素材</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="upload-modal-content">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area" v-if="!uploading">
|
||||
<div
|
||||
class="upload-dragger"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
@click="triggerFileInput"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="acceptTypes"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div class="upload-icon">
|
||||
<Icon icon="lucide:cloud-upload" class="text-4xl text-blue-500" />
|
||||
</div>
|
||||
<p class="upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p class="upload-hint">
|
||||
支持多文件上传,单文件最大 1GB
|
||||
<br />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</p>
|
||||
<div class="upload-body">
|
||||
<!-- ═══ 阶段 1:拖拽区 ═══ -->
|
||||
<div v-if="!uploading && fileList.length === 0" class="drop-zone" :class="{ 'drop-zone--active': isDragOver }"
|
||||
@click="triggerFileInput" @drop.prevent="handleDrop" @dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false">
|
||||
<input ref="fileInputRef" type="file" multiple :accept="acceptTypes" hidden @change="handleFileSelect" />
|
||||
<div class="drop-zone__icon">
|
||||
<Icon icon="lucide:cloud-upload" />
|
||||
</div>
|
||||
<p class="drop-zone__title">拖拽文件到此处,或点击选择</p>
|
||||
<p class="drop-zone__hint">视频 · 图片 · 音频 | 单文件最大 1GB</p>
|
||||
</div>
|
||||
|
||||
<!-- 已选文件列表 -->
|
||||
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
|
||||
<div class="upload-file-list-title">
|
||||
已选择 {{ fileList.length }} 个文件,总大小:{{ getTotalSize() }}
|
||||
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" class="text-red-500 ml-2">
|
||||
(已超出 1GB 限制)
|
||||
<!-- ═══ 阶段 2:已选文件列表 ═══ -->
|
||||
<div v-if="fileList.length > 0 && !uploading" class="file-pick-list">
|
||||
<div class="file-pick-list__header">
|
||||
<span>已选 <strong>{{ fileList.length }}</strong> 个文件</span>
|
||||
<span class="file-pick-list__total" :class="{ 'text-danger': getTotalSizeBytes() > MAX_TOTAL_SIZE }">
|
||||
{{ getTotalSize() }} / 1GB
|
||||
</span>
|
||||
</div>
|
||||
<div class="upload-file-items">
|
||||
<div
|
||||
v-for="(fileItem, index) in fileList"
|
||||
:key="fileItem.uid || index"
|
||||
class="upload-file-item"
|
||||
>
|
||||
<Icon icon="lucide:file" class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(fileItem) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-600"
|
||||
@click="handleRemove(fileItem)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div class="file-pick-list__items">
|
||||
<div v-for="(fileItem, index) in fileList" :key="fileItem.uid || index" class="pick-item">
|
||||
<Icon icon="lucide:file" class="pick-item__icon" />
|
||||
<span class="pick-item__name" :title="getFileName(fileItem)">{{ getFileName(fileItem) }}</span>
|
||||
<span class="pick-item__size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
|
||||
<button class="pick-item__remove" @click="handleRemove(fileItem)" title="移除">
|
||||
<Icon icon="lucide:x" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度区域 -->
|
||||
<div v-if="uploading" class="upload-progress-area">
|
||||
<div class="upload-progress-header">
|
||||
<span class="custom-spinner"></span>
|
||||
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
||||
<!-- ═══ 阶段 3:上传进度 ═══ -->
|
||||
<div v-if="uploading" class="upload-stage">
|
||||
<!-- 头部状态 -->
|
||||
<div class="upload-stage__status">
|
||||
<span class="upload-stage__spinner" />
|
||||
<span class="upload-stage__label">{{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
||||
<span class="upload-stage__eta" v-if="currentFileProgress > 0 && currentFileProgress < 100">
|
||||
剩余约 {{ estimatedRemaining }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 总体进度 -->
|
||||
<div class="upload-total-progress">
|
||||
<div class="progress-info">
|
||||
<span>总体进度</span>
|
||||
<span>{{ totalProgress }}%</span>
|
||||
<!-- 总进度条 -->
|
||||
<div class="total-bar">
|
||||
<div class="total-bar__track">
|
||||
<div class="total-bar__fill" :style="{ width: totalProgress + '%' }" />
|
||||
</div>
|
||||
<Progress :value="totalProgress" class="h-2" />
|
||||
<span class="total-bar__pct">{{ totalProgress }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件进度 -->
|
||||
<div class="upload-current-file">
|
||||
<div class="current-file-name">
|
||||
<!-- 当前文件 -->
|
||||
<div class="live-file">
|
||||
<div class="live-file__name">
|
||||
<Icon icon="lucide:file" />
|
||||
<span>{{ currentFileName }}</span>
|
||||
</div>
|
||||
<div class="current-file-progress">
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
|
||||
<span>{{ currentFileProgress }}%</span>
|
||||
<div class="live-file__bar">
|
||||
<div class="live-file__track">
|
||||
<div class="live-file__fill" :style="{ width: currentFileProgress + '%' }" />
|
||||
</div>
|
||||
<Progress :value="currentFileProgress" class="h-1.5" />
|
||||
<span class="live-file__pct">{{ currentFileProgress }}%</span>
|
||||
</div>
|
||||
<div class="live-file__bytes">{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize)
|
||||
}}</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成文件列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
||||
<div class="completed-header">
|
||||
<Icon icon="lucide:check-circle" class="text-green-500" />
|
||||
<span>已完成 {{ completedFiles.length }} 个文件</span>
|
||||
</div>
|
||||
<div class="completed-list">
|
||||
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
|
||||
<Icon icon="lucide:check" class="text-green-500 text-xs" />
|
||||
<span>{{ file.name }}</span>
|
||||
</div>
|
||||
<!-- 已完成 / 失败 列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="done-list">
|
||||
<div v-for="(f, i) in completedFiles" :key="i" class="done-item" :class="f.success ? 'done-item--ok' : 'done-item--fail'">
|
||||
<Icon :icon="f.success ? 'lucide:check-circle' : 'lucide:x-circle'" class="done-item__icon" />
|
||||
<span class="done-item__name">{{ f.name }}</span>
|
||||
<span class="done-item__tag">{{ f.success ? '成功' : '失败' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button v-if="!uploading" variant="outline" @click="handleCancel">取消</Button>
|
||||
<Button
|
||||
v-if="!uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
||||
<!-- ═══ 底部操作栏 ═══ -->
|
||||
<div class="upload-footer">
|
||||
<template v-if="!uploading">
|
||||
<Button variant="outline" size="sm" @click="handleCancel">取消</Button>
|
||||
<Button size="sm" :disabled="fileList.length === 0" @click="handleConfirm">
|
||||
<Icon icon="lucide:upload" class="mr-1.5 size-4" />
|
||||
上传 {{ fileList.length }} 个文件
|
||||
</Button>
|
||||
<span v-if="uploading" class="upload-tip">上传中请勿关闭窗口...</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="upload-footer__hint">上传中,请勿关闭窗口</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -135,47 +104,31 @@ import { ref, watch, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fileCategory: {
|
||||
type: String,
|
||||
default: 'video'
|
||||
},
|
||||
groupId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
visible: { type: Boolean, default: false },
|
||||
fileCategory: { type: String, default: 'video' },
|
||||
groupId: { type: Number, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
|
||||
|
||||
// Hooks
|
||||
const { upload } = useUpload()
|
||||
|
||||
// 数据
|
||||
// ── 状态 ──
|
||||
const fileList = ref([])
|
||||
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
||||
const isDragOver = ref(false)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
// 常量
|
||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
|
||||
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024 // 1GB (单个文件最大1GB)
|
||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024
|
||||
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024
|
||||
|
||||
// 上传状态
|
||||
const uploading = ref(false)
|
||||
const currentUploadIndex = ref(0)
|
||||
const totalUploadCount = ref(0)
|
||||
@@ -185,452 +138,337 @@ const currentFileSize = ref(0)
|
||||
const currentFileUploaded = ref(0)
|
||||
const completedFiles = ref([])
|
||||
|
||||
// 计算总进度
|
||||
// ── 计算 ──
|
||||
const totalProgress = computed(() => {
|
||||
if (totalUploadCount.value === 0) return 0
|
||||
const baseProgress = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
||||
const currentContribution = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
||||
return Math.round(baseProgress + currentContribution)
|
||||
const base = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
||||
const cur = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
||||
return Math.round(base + cur)
|
||||
})
|
||||
|
||||
// 计算总大小(字节)
|
||||
const getTotalSizeBytes = () => {
|
||||
return fileList.value.reduce((total, item) => {
|
||||
return total + getFileSize(item)
|
||||
}, 0)
|
||||
}
|
||||
const estimatedRemaining = computed(() => {
|
||||
if (currentFileProgress.value === 0) return '计算中…'
|
||||
const remainingFiles = totalUploadCount.value - currentUploadIndex.value
|
||||
const totalRemaining = remainingFiles * currentFileSize.value + (currentFileSize.value - currentFileUploaded.value)
|
||||
if (totalRemaining < 1024 * 1024) return '不到 1 分钟'
|
||||
return '约 ' + Math.ceil(totalRemaining / (10 * 1024 * 1024)) + ' 分钟'
|
||||
})
|
||||
|
||||
// 计算总大小(格式化)
|
||||
const getTotalSize = () => {
|
||||
return formatFileSize(getTotalSizeBytes())
|
||||
}
|
||||
const getTotalSizeBytes = () => fileList.value.reduce((t, i) => t + getFileSize(i), 0)
|
||||
const getTotalSize = () => formatFileSize(getTotalSizeBytes())
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
// ── 文件操作 ──
|
||||
const triggerFileInput = () => fileInputRef.value?.click()
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e) => {
|
||||
const files = e.target.files
|
||||
if (files) {
|
||||
addFiles(Array.from(files))
|
||||
}
|
||||
// 重置 input 以便可以再次选择相同文件
|
||||
if (e.target.files) addFiles(Array.from(e.target.files))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 处理拖放
|
||||
const handleDrop = (e) => {
|
||||
isDragOver.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) {
|
||||
addFiles(Array.from(files))
|
||||
}
|
||||
if (e.dataTransfer?.files) addFiles(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
const addFiles = (files) => {
|
||||
files.forEach(file => {
|
||||
// 检查单个文件大小
|
||||
if (file.size > MAX_SINGLE_FILE_SIZE) {
|
||||
toast.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算当前文件列表的总大小(包括新文件)
|
||||
const currentTotalSize = getTotalSizeBytes()
|
||||
const newTotalSize = currentTotalSize + file.size
|
||||
|
||||
// 检查总大小
|
||||
if (newTotalSize > MAX_TOTAL_SIZE) {
|
||||
toast.warning(`文件总大小超过 1GB(当前:${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已存在相同文件
|
||||
const exists = fileList.value.some(item => {
|
||||
const itemName = getFileName(item)
|
||||
const itemSize = getFileSize(item)
|
||||
return itemName === file.name && itemSize === file.size
|
||||
})
|
||||
if (exists) {
|
||||
toast.warning(`文件 ${file.name} 已存在,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
fileList.value.push({
|
||||
uid: `${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size
|
||||
})
|
||||
if (file.size > MAX_SINGLE_FILE_SIZE) return toast.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
||||
const currentTotal = getTotalSizeBytes()
|
||||
if (currentTotal + file.size > MAX_TOTAL_SIZE) return toast.warning(`总大小超过 1GB,已跳过 ${file.name}`)
|
||||
if (fileList.value.some(i => getFileName(i) === file.name && getFileSize(i) === file.size)) return
|
||||
fileList.value.push({ uid: `${Date.now()}-${Math.random()}`, file, name: file.name, size: file.size })
|
||||
})
|
||||
}
|
||||
|
||||
// 监听 visible 变化,重置文件列表
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
fileList.value = []
|
||||
resetUploadState()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置上传状态
|
||||
const resetUploadState = () => {
|
||||
uploading.value = false
|
||||
currentUploadIndex.value = 0
|
||||
totalUploadCount.value = 0
|
||||
currentFileName.value = ''
|
||||
currentFileProgress.value = 0
|
||||
currentFileSize.value = 0
|
||||
currentFileUploaded.value = 0
|
||||
completedFiles.value = []
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (fileItem) => {
|
||||
const index = fileList.value.findIndex(item =>
|
||||
(item.uid && item.uid === fileItem.uid) ||
|
||||
(getFileName(item) === getFileName(fileItem))
|
||||
)
|
||||
if (index > -1) {
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
const idx = fileList.value.findIndex(i => (i.uid && i.uid === fileItem.uid) || getFileName(i) === getFileName(fileItem))
|
||||
if (idx > -1) fileList.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 确认上传
|
||||
// ── 生命周期 ──
|
||||
watch(() => props.visible, (v) => { if (!v) { fileList.value = []; resetUploadState() } })
|
||||
|
||||
const resetUploadState = () => {
|
||||
uploading.value = false; currentUploadIndex.value = 0; totalUploadCount.value = 0
|
||||
currentFileName.value = ''; currentFileProgress.value = 0; currentFileSize.value = 0
|
||||
currentFileUploaded.value = 0; completedFiles.value = []
|
||||
}
|
||||
|
||||
// ── 上传 ──
|
||||
const handleConfirm = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
toast.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查总大小
|
||||
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) {
|
||||
toast.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
|
||||
return
|
||||
}
|
||||
|
||||
// 提取文件对象
|
||||
const files = fileList.value
|
||||
.map(item => {
|
||||
const fileObj = item.file || item.originFileObj || item
|
||||
if (!(fileObj instanceof File)) return null
|
||||
return fileObj
|
||||
})
|
||||
.filter(item => item !== null)
|
||||
|
||||
if (files.length === 0) {
|
||||
toast.error('无法获取文件对象,请重新选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 开始上传
|
||||
if (fileList.value.length === 0) return toast.warning('请选择文件')
|
||||
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) return toast.warning('总大小超过 1GB,请移除部分文件')
|
||||
const files = fileList.value.map(i => i.file || i.originFileObj || i).filter(f => f instanceof File)
|
||||
if (files.length === 0) return toast.error('无法获取文件对象,请重新选择')
|
||||
await performUpload(files)
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
const performUpload = async (files) => {
|
||||
uploading.value = true
|
||||
totalUploadCount.value = files.length
|
||||
currentUploadIndex.value = 0
|
||||
completedFiles.value = []
|
||||
|
||||
// 使用传入的fileCategory
|
||||
const category = props.fileCategory || 'video'
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
uploading.value = true; totalUploadCount.value = files.length; currentUploadIndex.value = 0; completedFiles.value = []
|
||||
let ok = 0, fail = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
currentUploadIndex.value = i + 1
|
||||
currentFileName.value = file.name
|
||||
currentFileSize.value = file.size
|
||||
currentFileUploaded.value = 0
|
||||
currentFileProgress.value = 0
|
||||
|
||||
currentUploadIndex.value = i + 1; currentFileName.value = file.name
|
||||
currentFileSize.value = file.size; currentFileUploaded.value = 0; currentFileProgress.value = 0
|
||||
try {
|
||||
await upload(file, {
|
||||
fileCategory: category,
|
||||
fileCategory: props.fileCategory || 'video',
|
||||
groupId: props.groupId,
|
||||
onProgress: (progress) => {
|
||||
currentFileProgress.value = progress
|
||||
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('文件上传成功:', file.name)
|
||||
successCount++
|
||||
completedFiles.value.push({
|
||||
name: file.name,
|
||||
success: true
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('文件上传失败:', file.name, error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
onProgress: (p) => { currentFileProgress.value = p; currentFileUploaded.value = Math.round((p / 100) * file.size) },
|
||||
onSuccess: () => { ok++; completedFiles.value.push({ name: file.name, success: true }) },
|
||||
onError: () => { fail++; completedFiles.value.push({ name: file.name, success: false }) }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('上传异常:', error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
} catch { fail++; completedFiles.value.push({ name: file.name, success: false }) }
|
||||
}
|
||||
|
||||
uploading.value = false
|
||||
|
||||
// 显示结果
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`成功上传 ${successCount} 个文件`)
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
||||
} else {
|
||||
toast.error('上传失败,请重试')
|
||||
}
|
||||
|
||||
// 通知父组件上传完成
|
||||
emit('complete', { successCount, failCount })
|
||||
|
||||
// 关闭弹窗
|
||||
if (ok > 0 && fail === 0) toast.success(`成功上传 ${ok} 个文件`)
|
||||
else if (ok > 0) toast.warning(`上传完成:${ok} 成功,${fail} 失败`)
|
||||
else toast.error('上传失败,请重试')
|
||||
emit('complete', { successCount: ok, failCount: fail })
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 处理 visible 变化
|
||||
const handleVisibleChange = (value) => {
|
||||
if (!value && uploading.value) return
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
if (uploading.value) return
|
||||
emit('update:visible', false)
|
||||
emit('cancel')
|
||||
}
|
||||
const handleVisibleChange = (v) => { if (!v && uploading.value) return; emit('update:visible', v) }
|
||||
const handleCancel = () => { if (uploading.value) return; emit('update:visible', false); emit('cancel') }
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.upload-modal-content {
|
||||
padding: 8px 0;
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
上传弹窗 - 清晰三段式
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.upload-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.upload-dragger {
|
||||
/* ── 拖拽区 ── */
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
border: 2px dashed var(--color-border);
|
||||
padding: 40px 24px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all .2s;
|
||||
|
||||
&:hover, &.drag-over {
|
||||
border-color: #3B82F6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
&:hover,
|
||||
&--active {
|
||||
border-color: var(--primary);
|
||||
background: hsl(var(--primary) / .04);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
margin-bottom: 12px;
|
||||
&__icon {
|
||||
width: 52px; height: 52px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: hsl(var(--primary) / .08);
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
&__title {
|
||||
font-size: 15px; font-weight: 500; color: var(--foreground); margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
&__hint {
|
||||
font-size: 12px; color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-file-list-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-file-items {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.upload-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-file-item:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
/* ── 已选文件列表 ── */
|
||||
.file-pick-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&__header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px; color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
strong { color: var(--foreground); }
|
||||
}
|
||||
|
||||
&__total {
|
||||
&.text-danger { color: var(--destructive); font-weight: 500; }
|
||||
}
|
||||
|
||||
&__items {
|
||||
max-height: 220px; overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
text-align: right;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
color: var(--color-text-3);
|
||||
.pick-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
&__icon { color: var(--muted-foreground); flex-shrink: 0; }
|
||||
|
||||
&__name {
|
||||
flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__size {
|
||||
color: var(--muted-foreground); font-size: 12px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 6px; border: none; background: transparent;
|
||||
color: var(--muted-foreground); cursor: pointer;
|
||||
&:hover { background: var(--destructive)/.1; color: var(--destructive); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 上传进度区域 */
|
||||
.upload-progress-area {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
/* ── 上传进度 ── */
|
||||
.upload-stage {
|
||||
&__status {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid hsl(var(--primary) / .15);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
|
||||
&__label { color: var(--foreground); }
|
||||
|
||||
&__eta {
|
||||
margin-left: auto;
|
||||
font-size: 12px; font-weight: 400; color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 总进度条 */
|
||||
.total-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
|
||||
&__track {
|
||||
flex: 1; height: 8px;
|
||||
background: var(--muted);
|
||||
border-radius: 4px; overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), hsl(var(--primary) / .7));
|
||||
border-radius: 4px;
|
||||
transition: width .3s ease;
|
||||
}
|
||||
|
||||
&__pct {
|
||||
font-size: 14px; font-weight: 600; color: var(--primary);
|
||||
min-width: 36px; text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3B82F6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.upload-total-progress {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.upload-current-file {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.current-file-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* 当前文件 */
|
||||
.live-file {
|
||||
padding: 14px;
|
||||
background: var(--muted);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
|
||||
&__name {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 500; color: var(--foreground);
|
||||
margin-bottom: 10px;
|
||||
span {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__track {
|
||||
flex: 1; height: 5px;
|
||||
background: var(--border);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%; background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width .15s ease;
|
||||
}
|
||||
|
||||
&__pct {
|
||||
font-size: 12px; font-weight: 500; color: var(--primary);
|
||||
min-width: 32px; text-align: right;
|
||||
}
|
||||
|
||||
&__bytes {
|
||||
font-size: 11px; color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.current-file-name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* 已完成 / 失败 */
|
||||
.done-list {
|
||||
max-height: 140px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.current-file-progress .progress-info {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.completed-files {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.completed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
.done-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
|
||||
&__icon { flex-shrink: 0; }
|
||||
|
||||
&--ok &__icon { color: var(--success); }
|
||||
&--fail &__icon { color: var(--destructive); }
|
||||
|
||||
&__name {
|
||||
flex: 1; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: 11px; font-weight: 500;
|
||||
padding: 1px 8px; border-radius: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.done-item--ok & {
|
||||
background: hsl(var(--success) / .1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.done-item--fail & {
|
||||
background: hsl(var(--destructive) / .1);
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.completed-list {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* ── 底部栏 ── */
|
||||
.upload-footer {
|
||||
display: flex; justify-content: flex-end; align-items: center; gap: 8px;
|
||||
padding-top: 16px; margin-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
.completed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.completed-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&__hint {
|
||||
font-size: 13px; color: var(--muted-foreground); margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,15 +24,19 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
'relative h-2 w-full overflow-hidden rounded-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: 'color-mix(in srgb, var(--primary) 20%, transparent)' }"
|
||||
>
|
||||
<ProgressIndicator
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
class="h-full w-full flex-1 transition-all"
|
||||
:style="{
|
||||
backgroundColor: 'var(--primary)',
|
||||
transform: `translateX(-${100 - (props.modelValue ?? 0)}%)`,
|
||||
}"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background relative overflow-hidden">
|
||||
<div class="login-page bg-background relative">
|
||||
<!-- 动态背景层 -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="gradient-orb orb-1"></div>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="min-h-screen flex flex-col lg:flex-row relative z-20">
|
||||
<div class="login-content flex flex-col lg:flex-row relative z-20">
|
||||
<!-- 左侧品牌区 -->
|
||||
<div class="flex-1 flex items-center justify-center p-6 sm:p-10 lg:p-16 animate-fade-in">
|
||||
<div class="w-full max-w-md">
|
||||
@@ -443,6 +443,19 @@ async function handleSmsLogin() {
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
// ========== 页面容器(dvh 适配移动端键盘)==========
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
// ========== 背景层 ==========
|
||||
.gradient-orb {
|
||||
position: absolute;
|
||||
@@ -718,7 +731,7 @@ async function handleSmsLogin() {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<div v-if="!store.videoPreviewUrl" class="upload-empty" @click="triggerFileSelect">
|
||||
<div class="upload-icon-wrapper">
|
||||
<CloudUploadOutlined class="upload-icon" />
|
||||
<Icon icon="lucide:upload" class="upload-icon" />
|
||||
</div>
|
||||
<div class="upload-text">点击上传或拖拽文件到此处</div>
|
||||
<div class="upload-hint">支持 MP4、MOV 格式,视频需大于 3 秒</div>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="param-item">
|
||||
<span class="param-label">生成</span>
|
||||
<Select v-model="formData.produceCount" @update:model-value="saveProduceCount">
|
||||
<SelectTrigger class="w-16 h-7">
|
||||
<SelectTrigger class="w-24 h-7">
|
||||
<SelectValue placeholder="选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -100,7 +100,7 @@
|
||||
<Icon icon="lucide:eraser" class="mr-1" />
|
||||
清空
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="autoFillAllScenes">
|
||||
<Button size="sm" @click="autoFillAllScenes">
|
||||
<Icon icon="lucide:zap" class="mr-1" />
|
||||
一键填充
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
|
||||
* * ✅ 提供订阅者模式,监听令牌变化事件
|
||||
*
|
||||
|
||||
*/
|
||||
|
||||
// localStorage 中存储的键名常量
|
||||
|
||||
@@ -1,127 +1,473 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 芋道 (Yudao) 全量部署脚本 - 生产增强版
|
||||
# 芋道 (Yudao) 生产部署脚本 - 运维级
|
||||
# ============================================
|
||||
set -e
|
||||
# 特性:
|
||||
# - 优雅启停 (SIGTERM → 等待 → SIGKILL 兜底)
|
||||
# - 健康检查 + 自动回滚
|
||||
# - 版本备份 + 保留最近 5 个版本
|
||||
# - 并发锁,防止同时执行
|
||||
# - 前置检查 (磁盘/内存/端口/编译产物)
|
||||
# - 结构化日志
|
||||
# ============================================
|
||||
set -o pipefail
|
||||
|
||||
# ==================== 1. 核心路径配置 ====================
|
||||
PROJECT_DIR="/www/wwwroot/sionrui" # 源码目录
|
||||
BACKEND_DIR="/www/wwwroot/yudao-server" # 后端运行目录
|
||||
FRONTEND_DIR="/www/wwwroot/muyetools.cn" # 用户端前端 (web-gold)
|
||||
ADMIN_DIR="/www/wwwroot/8.155.172.147" # 管理后台前端 (yudao-ui-admin-vue3)
|
||||
# ==================== 配置 ====================
|
||||
readonly PROJECT_DIR="/www/wwwroot/sionrui"
|
||||
readonly BACKEND_DIR="/www/wwwroot/yudao-server"
|
||||
readonly FRONTEND_DIR="/www/wwwroot/muyetools.cn"
|
||||
readonly ADMIN_DIR="/www/wwwroot/8.155.172.147"
|
||||
|
||||
BACKEND_JAR_NAME="sion-rui.jar"
|
||||
BUILD_LOG="/tmp/gitea_build_$(date +%Y%m%d).log"
|
||||
readonly BACKEND_JAR="sion-rui.jar"
|
||||
readonly BACKEND_PORT=9900
|
||||
readonly BACKUP_KEEP=5 # 保留最近 N 个版本
|
||||
readonly SHUTDOWN_GRACE_SEC=30 # 优雅关闭等待上限
|
||||
readonly HEALTH_CHECK_RETRIES=30 # 健康检查重试次数 (30 × 2s = 60s)
|
||||
readonly HEALTH_CHECK_INTERVAL=2 # 健康检查间隔(秒)
|
||||
|
||||
# ==================== 2. 环境强制指定 ====================
|
||||
# 强制指定 JDK 17 (根据你的截图)
|
||||
export JAVA_HOME="/www/server/java/jdk-17.0.8"
|
||||
export PATH=$JAVA_HOME/bin:$PATH
|
||||
readonly DEPLOY_LOCK="/tmp/yudao_deploy.lock"
|
||||
readonly BUILD_LOG="/tmp/yudao_build_$(date +%Y%m%d_%H%M%S).log"
|
||||
readonly DEPLOY_LOG="/tmp/yudao_deploy.log"
|
||||
readonly BACKUP_DIR="$BACKEND_DIR/backups"
|
||||
|
||||
# 自动寻找 Maven 路径
|
||||
MVN_EXEC=$(which mvn || find /www/server -name "mvn" | head -n 1 || echo "mvn")
|
||||
|
||||
# 限制内存防止 OOM,设置 Maven 多线程加速编译
|
||||
# Java 环境
|
||||
export JAVA_HOME="/www/server/java/jdk-17.0.8"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export MAVEN_OPTS="-Xms512m -Xmx1024m"
|
||||
export NODE_OPTIONS="--max-old-space-size=2048"
|
||||
|
||||
# 指定本地仓库(解决 www 用户权限问题)
|
||||
# Maven
|
||||
MVN_EXEC=$(which mvn 2>/dev/null || find /www/server -name "mvn" -type f 2>/dev/null | head -1 || echo "mvn")
|
||||
MAVEN_REPO="$PROJECT_DIR/.m2_repo"
|
||||
mkdir -p $MAVEN_REPO && chmod -R 777 $MAVEN_REPO
|
||||
|
||||
# ==================== 3. 工具函数 ====================
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $BUILD_LOG
|
||||
}
|
||||
# ==================== 工具函数 ====================
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1" | tee -a "$DEPLOY_LOG"; }
|
||||
success() { echo "[$(date '+%H:%M:%S')] ✅ $1" | tee -a "$DEPLOY_LOG"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] ⚠️ $1" | tee -a "$DEPLOY_LOG"; }
|
||||
error() { echo "[$(date '+%H:%M:%S')] ❌ $1" | tee -a "$DEPLOY_LOG"; }
|
||||
die() { error "$1"; release_lock; exit 1; }
|
||||
|
||||
# ==================== 4. 后端部署逻辑 ====================
|
||||
deploy_backend() {
|
||||
log "🚀 [后端] 开始部署流程..."
|
||||
|
||||
cd $PROJECT_DIR
|
||||
log "1.1 同步最新代码..."
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
log "1.2 开始多线程构建 (4核加速)..."
|
||||
# -T 1C 表示每个 CPU 核心一个线程,显著缩短 99% CPU 占用的时长
|
||||
$MVN_EXEC clean package -DskipTests -pl yudao-server -am -T 1C -Dmaven.repo.local=$MAVEN_REPO >> $BUILD_LOG 2>&1
|
||||
|
||||
log "1.3 清理旧进程与文件锁..."
|
||||
# 查找并强杀旧 JAR 进程
|
||||
OLD_PID=$(ps -ef | grep "$BACKEND_JAR_NAME" | grep -v "grep" | awk '{print $2}')
|
||||
[ -z "$OLD_PID" ] || kill -9 $OLD_PID
|
||||
sleep 2
|
||||
|
||||
log "1.4 复制 JAR 包 (安全覆盖)..."
|
||||
SOURCE_JAR="$PROJECT_DIR/yudao-server/target/$BACKEND_JAR_NAME"
|
||||
if [ ! -f "$SOURCE_JAR" ]; then
|
||||
log "❌ 编译失败:未在 $SOURCE_JAR 找到文件!请检查 $BUILD_LOG"
|
||||
exit 1
|
||||
# ==================== 并发锁 ====================
|
||||
acquire_lock() {
|
||||
if [ -f "$DEPLOY_LOCK" ]; then
|
||||
local pid
|
||||
pid=$(cat "$DEPLOY_LOCK" 2>/dev/null)
|
||||
if ps -p "$pid" > /dev/null 2>&1; then
|
||||
die "部署脚本已在运行中 (PID=$pid),请等待完成或手动删除 $DEPLOY_LOCK"
|
||||
fi
|
||||
warn "发现残留锁文件(进程 $pid 已不存在),清理后继续"
|
||||
rm -f "$DEPLOY_LOCK"
|
||||
fi
|
||||
|
||||
mkdir -p $BACKEND_DIR/logs
|
||||
rm -f "$BACKEND_DIR/$BACKEND_JAR_NAME"
|
||||
cp -f "$SOURCE_JAR" "$BACKEND_DIR/"
|
||||
|
||||
log "1.5 后台启动服务..."
|
||||
cd $BACKEND_DIR
|
||||
# 完全脱离终端重定向,防止脚本挂起
|
||||
# 注意:JVM参数(-Xms/-Xmx)必须在 -jar 之前
|
||||
nohup $JAVA_HOME/bin/java -Xms512m -Xmx1024m -jar $BACKEND_JAR_NAME --server.port=9900 > ./logs/console.log 2>&1 &
|
||||
|
||||
log "✅ 后端启动成功 (PID: $!)"
|
||||
echo $$ > "$DEPLOY_LOCK"
|
||||
}
|
||||
|
||||
# ==================== 5. 用户端前端部署逻辑 ====================
|
||||
release_lock() {
|
||||
rm -f "$DEPLOY_LOCK"
|
||||
}
|
||||
|
||||
# 确保退出时释放锁
|
||||
trap release_lock EXIT INT TERM
|
||||
|
||||
# ==================== 前置检查 ====================
|
||||
pre_flight() {
|
||||
log "=== 前置检查 ==="
|
||||
|
||||
# 磁盘空间 (至少 2GB)
|
||||
local avail
|
||||
avail=$(df -m "$BACKEND_DIR" | awk 'NR==2 {print $4}')
|
||||
if [ "${avail:-0}" -lt 2048 ]; then
|
||||
die "磁盘空间不足:${BACKEND_DIR} 可用 ${avail}MB,需要至少 2048MB"
|
||||
fi
|
||||
log " 磁盘可用: ${avail}MB ✓"
|
||||
|
||||
# 内存 (至少 512MB 空闲)
|
||||
local mem_free
|
||||
mem_free=$(free -m | awk '/Mem:/ {print $7}')
|
||||
if [ "${mem_free:-0}" -lt 512 ]; then
|
||||
warn "可用内存偏低:${mem_free}MB,构建可能失败"
|
||||
else
|
||||
log " 可用内存: ${mem_free}MB ✓"
|
||||
fi
|
||||
|
||||
# JDK
|
||||
if [ ! -x "$JAVA_HOME/bin/java" ]; then
|
||||
die "JDK 未找到:$JAVA_HOME/bin/java"
|
||||
fi
|
||||
log " JDK: $($JAVA_HOME/bin/java -version 2>&1 | head -1) ✓"
|
||||
|
||||
# 项目目录
|
||||
if [ ! -d "$PROJECT_DIR/.git" ]; then
|
||||
die "项目目录不存在或不是 Git 仓库:$PROJECT_DIR"
|
||||
fi
|
||||
log " 项目目录: $PROJECT_DIR ✓"
|
||||
|
||||
# 端口 (是否被非本应用的进程占用)
|
||||
local port_pid
|
||||
port_pid=$(ss -tlnp 2>/dev/null | grep ":$BACKEND_PORT " | grep -oP 'pid=\K\d+' || true)
|
||||
if [ -n "$port_pid" ]; then
|
||||
local port_cmd
|
||||
port_cmd=$(ps -p "$port_pid" -o comm= 2>/dev/null || echo "unknown")
|
||||
if ! echo "$port_cmd" | grep -q "java"; then
|
||||
die "端口 $BACKEND_PORT 被非 Java 进程占用 (PID=$port_pid, cmd=$port_cmd)"
|
||||
fi
|
||||
log " 端口 $BACKEND_PORT: 被当前后端占用 (PID=$port_pid),将在停止阶段处理"
|
||||
else
|
||||
log " 端口 $BACKEND_PORT: 空闲 ✓"
|
||||
fi
|
||||
|
||||
success "前置检查通过"
|
||||
}
|
||||
|
||||
# ==================== 停止旧服务 ====================
|
||||
stop_service() {
|
||||
log "=== 停止旧服务 ==="
|
||||
|
||||
local old_pid
|
||||
old_pid=$(ps -ef | grep "$BACKEND_JAR" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$old_pid" ]; then
|
||||
log " 未发现运行中的旧进程"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 记录旧进程信息用于回滚
|
||||
local old_start
|
||||
old_start=$(ps -p "$old_pid" -o lstart= 2>/dev/null || echo "unknown")
|
||||
log " 旧进程: PID=$old_pid, 启动时间=$old_start"
|
||||
|
||||
# Step 1: SIGTERM 优雅关闭
|
||||
log " 发送 SIGTERM ..."
|
||||
kill -15 "$old_pid" 2>/dev/null || true
|
||||
|
||||
local waited=0
|
||||
while [ $waited -lt $SHUTDOWN_GRACE_SEC ]; do
|
||||
if ! ps -p "$old_pid" > /dev/null 2>&1; then
|
||||
success "旧进程优雅退出 (PID=$old_pid, 耗时 ${waited}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
# 每 10 秒汇报一次状态
|
||||
if [ $((waited % 10)) -eq 0 ] && [ $waited -gt 0 ]; then
|
||||
log " 等待中... (${waited}s/${SHUTDOWN_GRACE_SEC}s)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 2: 仍未退出 → SIGKILL
|
||||
if ps -p "$old_pid" > /dev/null 2>&1; then
|
||||
warn "优雅关闭超时 (${SHUTDOWN_GRACE_SEC}s),执行 kill -9"
|
||||
kill -9 "$old_pid" 2>/dev/null || true
|
||||
sleep 3
|
||||
if ps -p "$old_pid" > /dev/null 2>&1; then
|
||||
die "无法杀死进程 PID=$old_pid,请手动处理"
|
||||
fi
|
||||
log " 进程已强制终止"
|
||||
fi
|
||||
|
||||
# Step 3: 确认端口释放
|
||||
local retries=10
|
||||
while [ $retries -gt 0 ]; do
|
||||
if ! ss -tlnp 2>/dev/null | grep -q ":$BACKEND_PORT "; then
|
||||
log " 端口 $BACKEND_PORT 已释放 ✓"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
retries=$((retries - 1))
|
||||
done
|
||||
|
||||
# 最后手段:fuser 释放端口
|
||||
warn "端口 $BACKEND_PORT 未自动释放,使用 fuser 强制释放"
|
||||
fuser -k "${BACKEND_PORT}/tcp" 2>/dev/null || true
|
||||
sleep 2
|
||||
}
|
||||
|
||||
# ==================== 版本备份 ====================
|
||||
backup_current() {
|
||||
if [ -f "$BACKEND_DIR/$BACKEND_JAR" ]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup_name="${BACKEND_JAR%.jar}_$(date +%Y%m%d_%H%M%S).jar"
|
||||
cp "$BACKEND_DIR/$BACKEND_JAR" "$BACKUP_DIR/$backpack_name"
|
||||
log " 已备份: $backup_name"
|
||||
|
||||
# 清理旧备份,只保留最近 N 个
|
||||
local count
|
||||
count=$(ls -1t "$BACKUP_DIR"/*.jar 2>/dev/null | wc -l)
|
||||
if [ "$count" -gt "$BACKUP_KEEP" ]; then
|
||||
ls -1t "$BACKUP_DIR"/*.jar | tail -n +$((BACKUP_KEEP + 1)) | xargs rm -f
|
||||
log " 清理旧备份,保留最近 $BACKUP_KEEP 个"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ==================== 回滚 ====================
|
||||
rollback() {
|
||||
error "=== 健康检查失败,开始回滚 ==="
|
||||
|
||||
# 停止新进程
|
||||
local new_pid="$1"
|
||||
if [ -n "$new_pid" ] && ps -p "$new_pid" > /dev/null 2>&1; then
|
||||
log " 停止新进程 PID=$new_pid"
|
||||
kill -9 "$new_pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# 恢复最近的备份
|
||||
local latest_backup
|
||||
latest_backup=$(ls -1t "$BACKUP_DIR"/*.jar 2>/dev/null | head -1)
|
||||
if [ -n "$latest_backup" ]; then
|
||||
log " 恢复备份: $latest_backup"
|
||||
cp -f "$latest_backup" "$BACKEND_DIR/$BACKEND_JAR"
|
||||
else
|
||||
error "没有可用的备份文件!"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 启动旧版本
|
||||
log " 启动旧版本..."
|
||||
cd "$BACKEND_DIR"
|
||||
nohup "$JAVA_HOME/bin/java" -Xms512m -Xmx1024m \
|
||||
-jar "$BACKEND_JAR" --server.port="$BACKEND_PORT" \
|
||||
> ./logs/console.log 2>&1 &
|
||||
|
||||
local rollback_pid=$!
|
||||
log " 回滚进程 PID=$rollback_pid"
|
||||
|
||||
# 等待旧版本启动
|
||||
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||
if curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then
|
||||
success "回滚成功 (PID=$rollback_pid)"
|
||||
return 0
|
||||
fi
|
||||
if ! ps -p "$rollback_pid" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep $HEALTH_CHECK_INTERVAL
|
||||
done
|
||||
|
||||
error "回滚失败!请手动检查!"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ==================== 启动新服务 ====================
|
||||
start_service() {
|
||||
log "=== 启动新服务 ==="
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# 确保日志目录
|
||||
mkdir -p "$BACKEND_DIR/logs"
|
||||
|
||||
nohup "$JAVA_HOME/bin/java" \
|
||||
-Xms512m -Xmx1024m \
|
||||
-jar "$BACKEND_JAR" --server.port="$BACKEND_PORT" \
|
||||
> ./logs/console.log 2>&1 &
|
||||
|
||||
local new_pid=$!
|
||||
log " 新进程 PID=$new_pid"
|
||||
|
||||
# 健康检查
|
||||
log " 等待健康检查..."
|
||||
local started=false
|
||||
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||
if ! ps -p "$new_pid" > /dev/null 2>&1; then
|
||||
error "新进程已退出!最近日志:"
|
||||
tail -30 "$BACKEND_DIR/logs/console.log" | while read -r line; do
|
||||
error " $line"
|
||||
done
|
||||
rollback "$new_pid"
|
||||
die "启动失败,已回滚到上一版本"
|
||||
fi
|
||||
|
||||
if curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then
|
||||
success "后端启动成功 (PID=$new_pid, 耗时 $((i * HEALTH_CHECK_INTERVAL))s)"
|
||||
started=true
|
||||
break
|
||||
fi
|
||||
|
||||
# 每 5 次汇报进度
|
||||
if [ $((i % 5)) -eq 0 ]; then
|
||||
log " 等待中... ($((i * HEALTH_CHECK_INTERVAL))s / $((HEALTH_CHECK_RETRIES * HEALTH_CHECK_INTERVAL))s)"
|
||||
fi
|
||||
sleep $HEALTH_CHECK_INTERVAL
|
||||
done
|
||||
|
||||
if [ "$started" != "true" ]; then
|
||||
error "健康检查超时"
|
||||
rollback "$new_pid"
|
||||
die "启动超时,已回滚到上一版本"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==================== 后端部署 ====================
|
||||
deploy_backend() {
|
||||
log ""
|
||||
log "╔══════════════════════════════════════════╗"
|
||||
log "║ 后端部署 ║"
|
||||
log "╚══════════════════════════════════════════╝"
|
||||
|
||||
# 0. 前置检查
|
||||
pre_flight
|
||||
|
||||
# 1. 拉取代码 & 构建
|
||||
log "=== 构建 ==="
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
log " 拉取最新代码..."
|
||||
git fetch origin 2>&1 | tee -a "$BUILD_LOG"
|
||||
local old_commit new_commit
|
||||
old_commit=$(git rev-parse --short HEAD)
|
||||
git reset --hard origin/main 2>&1 | tee -a "$BUILD_LOG"
|
||||
new_commit=$(git rev-parse --short HEAD)
|
||||
|
||||
if [ "$old_commit" != "$new_commit" ]; then
|
||||
log " 代码更新: ${old_commit} → ${new_commit}"
|
||||
else
|
||||
log " 代码无变化 (${old_commit})"
|
||||
fi
|
||||
|
||||
log " Maven 构建..."
|
||||
mkdir -p "$MAVEN_REPO" && chmod -R 777 "$MAVEN_REPO" 2>/dev/null || true
|
||||
if ! $MVN_EXEC clean package -Dmaven.test.skip=true -pl yudao-server -am -T 1C \
|
||||
-Dmaven.repo.local="$MAVEN_REPO" >> "$BUILD_LOG" 2>&1; then
|
||||
error "Maven 构建失败!查看:tail -100 $BUILD_LOG"
|
||||
die "构建失败"
|
||||
fi
|
||||
success "Maven 构建完成"
|
||||
|
||||
# 2. 检查产物
|
||||
local source_jar="$PROJECT_DIR/yudao-server/target/$BACKEND_JAR"
|
||||
if [ ! -f "$source_jar" ]; then
|
||||
die "编译产物不存在:$source_jar"
|
||||
fi
|
||||
log " 产物大小: $(du -h "$source_jar" | cut -f1)"
|
||||
|
||||
# 3. 备份当前版本
|
||||
backup_current
|
||||
|
||||
# 4. 停止旧服务
|
||||
stop_service
|
||||
|
||||
# 5. 部署新 JAR
|
||||
log "=== 部署 ==="
|
||||
rm -f "$BACKEND_DIR/$BACKEND_JAR"
|
||||
cp -f "$source_jar" "$BACKEND_DIR/"
|
||||
log " JAR 已复制到 $BACKEND_DIR/"
|
||||
|
||||
# 6. 启动
|
||||
start_service
|
||||
|
||||
log ""
|
||||
success "后端部署完成 ($old_commit → $new_commit)"
|
||||
}
|
||||
|
||||
# ==================== 前端部署 ====================
|
||||
deploy_frontend() {
|
||||
log "🚀 [用户端前端] 开始部署流程..."
|
||||
cd $PROJECT_DIR/frontend
|
||||
log ""
|
||||
log "╔══════════════════════════════════════════╗"
|
||||
log "║ 用户端前端部署 ║"
|
||||
log "╚══════════════════════════════════════════╝"
|
||||
|
||||
log "2.1 安装依赖 (pnpm)..."
|
||||
pnpm install >> $BUILD_LOG 2>&1
|
||||
log "=== 构建 ==="
|
||||
cd "$PROJECT_DIR/frontend"
|
||||
|
||||
log "2.2 构建生产文件..."
|
||||
pnpm build:gold >> $BUILD_LOG 2>&1
|
||||
log " pnpm install..."
|
||||
pnpm install >> "$BUILD_LOG" 2>&1 || die "pnpm install 失败"
|
||||
|
||||
log "2.3 刷新 Web 目录..."
|
||||
rm -rf $FRONTEND_DIR/*
|
||||
cp -r $PROJECT_DIR/frontend/app/web-gold/dist/* $FRONTEND_DIR/
|
||||
log " pnpm build:gold..."
|
||||
pnpm build:gold >> "$BUILD_LOG" 2>&1 || die "pnpm build:gold 失败"
|
||||
|
||||
log "✅ 用户端前端部署完成"
|
||||
log "=== 部署 ==="
|
||||
rm -rf "${FRONTEND_DIR:?}"/*
|
||||
cp -r "$PROJECT_DIR/frontend/app/web-gold/dist/"* "$FRONTEND_DIR/"
|
||||
success "用户端前端部署完成"
|
||||
}
|
||||
|
||||
# ==================== 5.1 管理后台前端部署逻辑 ====================
|
||||
deploy_admin() {
|
||||
log "🚀 [管理后台] 开始部署流程..."
|
||||
cd $PROJECT_DIR/yudao-ui-admin-vue3
|
||||
log ""
|
||||
log "╔══════════════════════════════════════════╗"
|
||||
log "║ 管理后台前端部署 ║"
|
||||
log "╚══════════════════════════════════════════╝"
|
||||
|
||||
log "3.1 安装依赖 (pnpm)..."
|
||||
pnpm install >> $BUILD_LOG 2>&1
|
||||
log "=== 构建 ==="
|
||||
cd "$PROJECT_DIR/yudao-ui-admin-vue3"
|
||||
|
||||
log "3.2 构建生产文件..."
|
||||
pnpm build:prod >> $BUILD_LOG 2>&1
|
||||
log " pnpm install..."
|
||||
pnpm install >> "$BUILD_LOG" 2>&1 || die "pnpm install 失败"
|
||||
|
||||
log "3.3 刷新管理后台目录..."
|
||||
rm -rf $ADMIN_DIR/*
|
||||
cp -r $PROJECT_DIR/yudao-ui-admin-vue3/dist-prod/* $ADMIN_DIR/
|
||||
log " pnpm build:prod..."
|
||||
pnpm build:prod >> "$BUILD_LOG" 2>&1 || die "pnpm build:prod 失败"
|
||||
|
||||
log "✅ 管理后台部署完成"
|
||||
log "=== 部署 ==="
|
||||
rm -rf "${ADMIN_DIR:?}"/*
|
||||
cp -r "$PROJECT_DIR/yudao-ui-admin-vue3/dist-prod/"* "$ADMIN_DIR/"
|
||||
success "管理后台部署完成"
|
||||
}
|
||||
|
||||
# ==================== 6. 执行主流程 ====================
|
||||
# 清理可能残留的构建进程
|
||||
pkill -f "maven" || true
|
||||
# ==================== 部署后摘要 ====================
|
||||
print_summary() {
|
||||
log ""
|
||||
log "╔══════════════════════════════════════════╗"
|
||||
log "║ 部署摘要 ║"
|
||||
log "╚══════════════════════════════════════════╝"
|
||||
log " 构建日志: $BUILD_LOG"
|
||||
log " 部署日志: $DEPLOY_LOG"
|
||||
log " 版本备份: $BACKUP_DIR/ ($(ls -1 "$BACKUP_DIR"/*.jar 2>/dev/null | wc -l) 个)"
|
||||
log " 控制台日志: $BACKEND_DIR/logs/console.log"
|
||||
|
||||
case "$1" in
|
||||
backend-only) deploy_backend ;;
|
||||
frontend-only) deploy_frontend ;;
|
||||
admin-only) deploy_admin ;;
|
||||
*)
|
||||
deploy_backend
|
||||
deploy_frontend
|
||||
deploy_admin
|
||||
;;
|
||||
esac
|
||||
# 快速验证
|
||||
if curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://127.0.0.1:${BACKEND_PORT}/actuator/health" 2>/dev/null | grep -q "200"; then
|
||||
success "健康检查: UP"
|
||||
else
|
||||
warn "健康检查: DOWN (可能仍在启动中)"
|
||||
fi
|
||||
|
||||
log "🏁 [$(date '+%H:%M:%S')] 部署流水线全部执行完毕!"
|
||||
# 磁盘
|
||||
local disk_use
|
||||
disk_use=$(df -h "$BACKEND_DIR" | awk 'NR==2 {print $5}')
|
||||
log " 磁盘使用: ${disk_use}"
|
||||
|
||||
log ""
|
||||
}
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
main() {
|
||||
acquire_lock
|
||||
|
||||
# 清理残留 Maven 进程
|
||||
pkill -f "maven" 2>/dev/null || true
|
||||
|
||||
echo "" >> "$DEPLOY_LOG"
|
||||
log "═══════════════════════════════════════════"
|
||||
log " 部署开始 (模式: ${1:-full})"
|
||||
log "═══════════════════════════════════════════"
|
||||
|
||||
case "${1:-full}" in
|
||||
backend-only)
|
||||
deploy_backend
|
||||
;;
|
||||
frontend-only)
|
||||
deploy_frontend
|
||||
;;
|
||||
admin-only)
|
||||
deploy_admin
|
||||
;;
|
||||
backend|full|"")
|
||||
deploy_backend
|
||||
deploy_frontend
|
||||
deploy_admin
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {backend-only|frontend-only|admin-only|full}"
|
||||
release_lock
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
print_summary
|
||||
log "🏁 部署流水线全部执行完毕 ($(date '+%H:%M:%S'))"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -88,6 +88,40 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- 排除 BouncyCastle:签名 JAR 在 Spring Boot fat JAR 中会导致 JCE 验证失败 -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-handler</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-codec-http3</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- 锁定 volcengine SDK 版本,避免 agents-flex-image-volcengine 的 LATEST 解析到不存在的 2.0.6 -->
|
||||
<dependency>
|
||||
<groupId>com.volcengine</groupId>
|
||||
<artifactId>volcengine-java-sdk-ark-runtime</artifactId>
|
||||
<version>1.0.16</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.volcengine</groupId>
|
||||
<artifactId>volcengine-java-sdk-ark</artifactId>
|
||||
<version>1.0.16</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||
<version>4.6.3</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nls</groupId>
|
||||
@@ -247,6 +257,15 @@
|
||||
<artifactId>spring-cloud-function-core</artifactId>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
</exclusion>
|
||||
<!-- 排除 BouncyCastle,避免 Spring Boot 嵌套 JAR 导致 JCE 签名校验失败 -->
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Bouncy Castle:ICE SDK 底层依赖,用于 API 请求签名 -->
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>ice20201109</artifactId>
|
||||
|
||||
@@ -50,6 +50,13 @@ public class TikAsyncConfig {
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
}
|
||||
|
||||
/** ICE 混剪异步提交专用线程池 */
|
||||
@Bean("iceSubmitExecutor")
|
||||
public Executor iceSubmitExecutor() {
|
||||
return createExecutor("ice-submit-", 4, 10, 200, 60,
|
||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
}
|
||||
|
||||
/** 对标分析任务专用线程池 */
|
||||
@Bean("benchmarkAsyncExecutor")
|
||||
public Executor benchmarkAsyncExecutor() {
|
||||
|
||||
@@ -2,13 +2,11 @@ package cn.iocoder.yudao.module.tik.media;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.aliyun.ice20201109.Client;
|
||||
import com.aliyun.ice20201109.models.*;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -16,301 +14,162 @@ import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
// 成功视频
|
||||
// http://oushu-test-shanghai.oss-cn-shanghai.aliyuncs.com/ice_output/46b29eb5775f4f758846171ab79bfca7.mp4
|
||||
|
||||
/**
|
||||
* 需要maven引入二方包依赖:
|
||||
* <dependency>
|
||||
* <groupId>com.aliyun</groupId>
|
||||
* <artifactId>ice20201109</artifactId>
|
||||
* <version>2.1.0</version>
|
||||
* </dependency>
|
||||
* <dependency>
|
||||
* <groupId>com.alibaba</groupId>
|
||||
* <artifactId>fastjson</artifactId>
|
||||
* <version>1.2.9</version>
|
||||
* </dependency>
|
||||
* Timeline 构建器 + ICE 提交编排
|
||||
* <p>
|
||||
* 职责:将素材列表构建为 ICE Timeline JSON,委托 {@link IceClient} 提交任务。
|
||||
* <p>
|
||||
* 架构原则:
|
||||
* <ul>
|
||||
* <li>不自行创建 SDK Client —— 统一通过 {@link IceClient} 调用 ICE API</li>
|
||||
* <li>专注于 Timeline 构建逻辑(随机截取、裁剪参数、音频静音等)</li>
|
||||
* <li>OSS URL 转换通过 {@link IceProperties} 完成</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BatchProduceAlignment {
|
||||
|
||||
private final IceProperties properties;
|
||||
private final FileApi fileApi;
|
||||
private final TikOssInitService ossInitService;
|
||||
private Client iceClient;
|
||||
private final IceClient iceClient;
|
||||
|
||||
public void initClient() throws Exception {
|
||||
if (iceClient == null) {
|
||||
synchronized (this) {
|
||||
if (iceClient == null) {
|
||||
if (!properties.isEnabled()) {
|
||||
log.error("ICE 配置未启用或未配置 AccessKey");
|
||||
throw new IllegalStateException("未配置 ICE AccessKey");
|
||||
}
|
||||
log.info("初始化阿里云 ICE 客户端... regionId={}, bucket={}",
|
||||
properties.getRegionId(), properties.getBucket());
|
||||
Config config = new Config();
|
||||
config.accessKeyId = properties.getAccessKeyId();
|
||||
config.accessKeySecret = properties.getAccessKeySecret();
|
||||
config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com";
|
||||
config.regionId = properties.getRegionId();
|
||||
iceClient = new Client(config);
|
||||
log.info("ICE 客户端初始化成功");
|
||||
}
|
||||
}
|
||||
}
|
||||
public BatchProduceAlignment(IceProperties properties, FileApi fileApi,
|
||||
TikOssInitService ossInitService, IceClient iceClient) {
|
||||
this.properties = properties;
|
||||
this.fileApi = fileApi;
|
||||
this.ossInitService = ossInitService;
|
||||
this.iceClient = iceClient;
|
||||
}
|
||||
|
||||
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
|
||||
// 初始化 ICE 客户端
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 旧版 API(兼容保留)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
// 批量提交任务,返回 "jobId:url" 格式
|
||||
public List<String> batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception {
|
||||
List<String> jobIdWithUrls = new ArrayList<>();
|
||||
|
||||
if (produceCount <= 1) {
|
||||
// 生成1个视频,包含所有片段
|
||||
String jobIdWithUrl = produceSingleVideo(title, videoArray, userId);
|
||||
jobIdWithUrls.add(jobIdWithUrl);
|
||||
} else {
|
||||
// 生成多个视频:将视频数组分成多份,每份生成一个视频
|
||||
int videoCount = videoArray.length;
|
||||
|
||||
// 计算每份的起始和结束索引
|
||||
int videosPerGroup = Math.max(1, videoCount / produceCount);
|
||||
int remainder = videoCount % produceCount;
|
||||
|
||||
int start = 0;
|
||||
for (int i = 0; i < produceCount; i++) {
|
||||
// 计算当前组的视频数量(尽可能平均分配)
|
||||
int groupSize = videosPerGroup + (i < remainder ? 1 : 0);
|
||||
|
||||
// 提取当前组的视频片段
|
||||
String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize);
|
||||
|
||||
// 生成单个视频
|
||||
String jobIdWithUrl = produceSingleVideo(title, groupVideos, userId);
|
||||
jobIdWithUrls.add(jobIdWithUrl);
|
||||
|
||||
start += groupSize;
|
||||
}
|
||||
}
|
||||
|
||||
// 改为异步模式,不在这里等待
|
||||
return jobIdWithUrls;
|
||||
}
|
||||
|
||||
public String produceSingleVideo(String title, String[] videoArray, Long userId) throws Exception {
|
||||
// 初始化 ICE 客户端
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
// 纯画面模式:仅拼接视频片段,为每个视频添加静音音频轨道
|
||||
JSONArray videoClipArray = new JSONArray();
|
||||
JSONArray audioClipArray = new JSONArray();
|
||||
|
||||
// 按顺序拼接视频片段(不随机打乱)
|
||||
for (int i = 0; i < videoArray.length; i++) {
|
||||
String videoUrl = videoArray[i];
|
||||
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
videoArray[i] = videoUrl; // 更新数组中的URL
|
||||
String videoUrl = properties.convertToOssUrl(videoArray[i]);
|
||||
videoArray[i] = videoUrl;
|
||||
|
||||
log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
|
||||
// 使用标准的 MediaURL 参数(符合 ICE API 文档规范)
|
||||
String videoClip = "{\"MediaURL\":\"" + videoUrl + "\"}";
|
||||
videoClipArray.add(JSONObject.parseObject(videoClip));
|
||||
|
||||
// 为每个视频片段添加静音的音频轨道
|
||||
JSONObject audioClip = new JSONObject();
|
||||
audioClip.put("MediaURL", videoUrl);
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
put("Gain", 0); // 0 表示静音
|
||||
put("Gain", 0);
|
||||
}});
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
}
|
||||
|
||||
// 构建时间线,包含视频轨道和音频轨道
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}";
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString()
|
||||
+ "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}";
|
||||
|
||||
// 生成输出文件路径:{用户目录}/mix/{yyyyMMdd}/{uuid}.mp4
|
||||
String targetFileName = UUID.randomUUID().toString().replace("-", "");
|
||||
String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix");
|
||||
String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
|
||||
|
||||
// ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限)
|
||||
String outputMediaPath = buildOutputPath(userId);
|
||||
String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath);
|
||||
|
||||
// ICE需要将处理结果写入到该URL,签名URL会导致写入失败
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
int bitrate = 2000; // 输出码率 2000 Kbit/s(符合 ICE API 文档推荐)
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
int width = 720, height = 1280, bitrate = 2000;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl
|
||||
+ "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
|
||||
log.info("[ICE][提交任务][视频数量={}, timeline={}]", videoArray.length, timeline);
|
||||
SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request);
|
||||
log.info("[ICE][提交任务][视频数量={}]", videoArray.length);
|
||||
String jobId = iceClient.submitJob(request);
|
||||
log.info("[ICE][任务提交成功][jobId={}]", jobId);
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl);
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个任务状态
|
||||
*
|
||||
* @param jobId 任务ID
|
||||
* @return 任务状态:Pending/Running/Success/Failed
|
||||
*/
|
||||
public String checkJobStatus(String jobId) throws Exception {
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
GetMediaProducingJobRequest req = new GetMediaProducingJobRequest();
|
||||
req.setJobId(jobId);
|
||||
GetMediaProducingJobResponse response = iceClient.getMediaProducingJob(req);
|
||||
GetMediaProducingJobResponseBody.GetMediaProducingJobResponseBodyMediaProducingJob mediaProducingJob = response.getBody().getMediaProducingJob();
|
||||
String status = mediaProducingJob.getStatus();
|
||||
log.debug("jobId: {}, status: {}", mediaProducingJob.getJobId(), status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务ID中获取输出URL
|
||||
*
|
||||
* @param jobIdWithUrl jobId : url 格式的字符串
|
||||
* @return 输出URL
|
||||
*/
|
||||
public String extractOutputUrl(String jobIdWithUrl) {
|
||||
if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) {
|
||||
return null;
|
||||
}
|
||||
return jobIdWithUrl.split(" : ")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算裁剪参数
|
||||
*
|
||||
* @param sourceWidth 源素材宽度
|
||||
* @param sourceHeight 源素材高度
|
||||
* @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式)
|
||||
* @return 裁剪参数Map,包含X、Y、Width、Height
|
||||
*/
|
||||
private Map<String, Integer> calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) {
|
||||
Map<String, Integer> cropParams = new HashMap<>();
|
||||
|
||||
// 填充模式:不裁剪,保持原尺寸
|
||||
if ("fill".equals(cropMode)) {
|
||||
cropParams.put("X", 0);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", sourceWidth);
|
||||
cropParams.put("Height", sourceHeight);
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式=fill, 裁剪参数={}", sourceWidth, sourceHeight, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
|
||||
// center/smart模式:居中裁剪(smart暂未开放,降级为center)
|
||||
if ("smart".equals(cropMode)) {
|
||||
log.info("[裁剪模式] smart模式暂未开放,自动降级为center模式");
|
||||
}
|
||||
|
||||
double targetRatio = 9.0 / 16.0; // 9:16竖屏比例
|
||||
double cropHeight = sourceHeight;
|
||||
double cropWidth = cropHeight * targetRatio;
|
||||
int cropX = (int) Math.round((sourceWidth - cropWidth) / 2);
|
||||
|
||||
cropParams.put("X", cropX);
|
||||
cropParams.put("Y", 0);
|
||||
cropParams.put("Width", (int) Math.round(cropWidth));
|
||||
cropParams.put("Height", (int) Math.round(cropHeight));
|
||||
|
||||
log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 裁剪参数={}", sourceWidth, sourceHeight, cropMode, cropParams);
|
||||
return cropParams;
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 新版 API(核心方法)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* 生成单个视频(支持随机截取起始点)
|
||||
*
|
||||
* 多视频差异化原理:
|
||||
* - 每个视频使用随机截取起点,确保内容完全不同
|
||||
* - 支持不同长度的素材,ICE自动容错处理
|
||||
* - 容错机制:如果起点超出素材长度,从0开始截取
|
||||
*
|
||||
* @param materials 素材列表(包含fileUrl和duration)
|
||||
* @param videoIndex 视频序号(0开始),用于生成随机种子
|
||||
* @param userId 用户ID
|
||||
* @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式)
|
||||
* @return jobId : outputUrl 格式字符串
|
||||
* @param materials 素材列表
|
||||
* @param videoIndex 视频序号(用于随机种子)
|
||||
* @param userId 用户 ID
|
||||
* @param cropMode 裁剪模式
|
||||
* @return "jobId : cdnUrl" 格式字符串
|
||||
*/
|
||||
public String produceSingleVideoWithOffset(List<MixTaskSaveReqVO.MaterialItem> materials,
|
||||
int videoIndex, Long userId, String cropMode) throws Exception {
|
||||
if (iceClient == null) {
|
||||
initClient();
|
||||
}
|
||||
|
||||
JSONArray videoClipArray = new JSONArray();
|
||||
JSONArray audioClipArray = new JSONArray();
|
||||
float timelinePos = 0;
|
||||
|
||||
for (int i = 0; i < materials.size(); i++) {
|
||||
MixTaskSaveReqVO.MaterialItem material = materials.get(i);
|
||||
String videoUrl = material.getFileUrl();
|
||||
String videoUrl = properties.convertToOssUrl(material.getFileUrl());
|
||||
material.setFileUrl(videoUrl);
|
||||
int duration = material.getDuration();
|
||||
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
material.setFileUrl(videoUrl); // 更新material中的URL
|
||||
|
||||
// 计算随机截取起点
|
||||
// 优先使用前端传入的素材实际时长,无则从0开始截取(兜底)
|
||||
// 计算随机截取区间
|
||||
Integer fileDuration = material.getFileDuration();
|
||||
int startOffset = 0;
|
||||
int endOffset = duration;
|
||||
int actualDuration = duration; // 实际截取时长
|
||||
int startOffset = 0, endOffset = duration, actualDuration = duration;
|
||||
|
||||
if (fileDuration != null && fileDuration >= duration) {
|
||||
// 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长)
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) +
|
||||
(videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000);
|
||||
long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L)
|
||||
+ (videoIndex * 10000L) + (videoUrl.hashCode() % 1000);
|
||||
Random random = new Random(randomSeed);
|
||||
int maxStartOffset = fileDuration - duration;
|
||||
startOffset = random.nextInt(maxStartOffset + 1);
|
||||
endOffset = startOffset + duration;
|
||||
log.debug("[ICE][随机截取] fileId={}, fileDuration={}s, In={}, Out={}",
|
||||
material.getFileId(), fileDuration, startOffset, endOffset);
|
||||
} else if (fileDuration != null && fileDuration < duration) {
|
||||
// 素材时长不足:使用素材全部时长,避免超出范围
|
||||
material.getFileId(), fileDuration, startOffset, endOffset);
|
||||
} else if (fileDuration != null) {
|
||||
actualDuration = fileDuration;
|
||||
endOffset = fileDuration;
|
||||
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s, 将使用全部时长",
|
||||
material.getFileId(), duration, fileDuration);
|
||||
log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s",
|
||||
material.getFileId(), duration, fileDuration);
|
||||
} else {
|
||||
// 无时长信息:从0开始截取(兜底,ICE可能会失败)
|
||||
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}, ICE可能会失败",
|
||||
material.getFileId(), duration);
|
||||
log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}",
|
||||
material.getFileId(), duration);
|
||||
}
|
||||
|
||||
log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]",
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration);
|
||||
videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration);
|
||||
|
||||
// 构建视频片段(带 In/Out 参数)
|
||||
// 构建视频片段
|
||||
JSONObject videoClip = new JSONObject();
|
||||
videoClip.put("MediaURL", videoUrl);
|
||||
videoClip.put("In", startOffset);
|
||||
@@ -318,31 +177,23 @@ public class BatchProduceAlignment {
|
||||
videoClip.put("TimelineIn", timelinePos);
|
||||
videoClip.put("TimelineOut", timelinePos + actualDuration);
|
||||
|
||||
// 添加裁剪效果(9:16竖屏输出)
|
||||
// 假设源素材为1920x1080(16:9),可根据实际情况调整
|
||||
int sourceWidth = 1920;
|
||||
int sourceHeight = 1080;
|
||||
|
||||
if (cropMode != null && !"fill".equals(cropMode)) {
|
||||
// 非填充模式需要裁剪
|
||||
Map<String, Integer> cropParams = calculateCropParams(sourceWidth, sourceHeight, cropMode);
|
||||
|
||||
JSONArray effects = new JSONArray();
|
||||
JSONObject cropEffect = new JSONObject();
|
||||
cropEffect.put("Type", "Crop");
|
||||
cropEffect.put("X", cropParams.get("X"));
|
||||
cropEffect.put("Y", cropParams.get("Y"));
|
||||
cropEffect.put("Width", cropParams.get("Width"));
|
||||
cropEffect.put("Height", cropParams.get("Height"));
|
||||
effects.add(cropEffect);
|
||||
|
||||
videoClip.put("Effects", effects);
|
||||
log.debug("[裁剪效果] 视频{}应用裁剪,模式={}, 参数={}", i + 1, cropMode, cropParams);
|
||||
// 使用 ICE 原生 AdaptMode 自动适配不同分辨率的素材
|
||||
// Cover: 保持宽高比裁剪填满(无黑边,等同于之前的 center 模式)
|
||||
// Fill: 拉伸填充(等同于之前的 fill 模式)
|
||||
// 必须同时设置 Width 和 Height,AdaptMode 才会生效
|
||||
videoClip.put("Width", 720);
|
||||
videoClip.put("Height", 1280);
|
||||
if ("fill".equals(cropMode)) {
|
||||
videoClip.put("AdaptMode", "Fill");
|
||||
} else {
|
||||
// center / smart 均使用 Cover,保持宽高比裁剪填满,无黑边
|
||||
videoClip.put("AdaptMode", "Cover");
|
||||
}
|
||||
log.debug("[ICE][自适应模式] cropMode={}, AdaptMode={}", cropMode, videoClip.get("AdaptMode"));
|
||||
|
||||
videoClipArray.add(videoClip);
|
||||
|
||||
// 为每个视频片段添加静音的音频轨道
|
||||
// 静音音频轨道
|
||||
JSONObject audioClip = new JSONObject();
|
||||
audioClip.put("MediaURL", videoUrl);
|
||||
audioClip.put("In", startOffset);
|
||||
@@ -352,7 +203,7 @@ public class BatchProduceAlignment {
|
||||
audioClip.put("Effects", new JSONArray() {{
|
||||
add(new JSONObject() {{
|
||||
put("Type", "Volume");
|
||||
put("Gain", 0); // 静音
|
||||
put("Gain", 0);
|
||||
}});
|
||||
}});
|
||||
audioClipArray.add(audioClip);
|
||||
@@ -360,38 +211,52 @@ public class BatchProduceAlignment {
|
||||
timelinePos += actualDuration;
|
||||
}
|
||||
|
||||
// 构建时间线
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() +
|
||||
"}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}";
|
||||
// 构建 Timeline
|
||||
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString()
|
||||
+ "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}";
|
||||
|
||||
// 生成输出文件路径
|
||||
String targetFileName = UUID.randomUUID().toString().replace("-", "");
|
||||
String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix");
|
||||
String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
|
||||
|
||||
// ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限)
|
||||
// 输出配置
|
||||
String outputMediaPath = buildOutputPath(userId);
|
||||
String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath);
|
||||
|
||||
int width = 720;
|
||||
int height = 1280;
|
||||
int bitrate = 2000;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width +
|
||||
",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
int width = 720, height = 1280, bitrate = 2000;
|
||||
String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl
|
||||
+ "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}";
|
||||
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
|
||||
log.info("[ICE][提交任务][videoIndex={}, 素材数量={}, 总时长={}s]",
|
||||
videoIndex, materials.size(), (int)timelinePos);
|
||||
SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request);
|
||||
videoIndex, materials.size(), (int) timelinePos);
|
||||
String jobId = iceClient.submitJob(request);
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}]", videoIndex, jobId);
|
||||
|
||||
String jobId = response.getBody().getJobId();
|
||||
log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl);
|
||||
// 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL)
|
||||
String cdnUrl = properties.getCdnStorageUrl(outputMediaPath);
|
||||
return jobId + " : " + cdnUrl;
|
||||
}
|
||||
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 工具方法
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
public String checkJobStatus(String jobId) throws Exception {
|
||||
return iceClient.getJobStatus(jobId);
|
||||
}
|
||||
|
||||
public String extractOutputUrl(String jobIdWithUrl) {
|
||||
if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) {
|
||||
return null;
|
||||
}
|
||||
return jobIdWithUrl.split(" : ")[1];
|
||||
}
|
||||
|
||||
private String buildOutputPath(Long userId) {
|
||||
String targetFileName = UUID.randomUUID().toString().replace("-", "");
|
||||
String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix");
|
||||
String dateDir = java.time.LocalDate.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
return mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,114 +1,161 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.client;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.aliyun.ice20201109.Client;
|
||||
import com.aliyun.ice20201109.models.*;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
|
||||
import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.VOICE_TTS_FAILED;
|
||||
|
||||
/**
|
||||
* 阿里云 ICE 客户端
|
||||
* 阿里云 ICE 客户端 —— 项目中 ICE SDK 的唯一封装入口
|
||||
* <p>
|
||||
* 设计原则:
|
||||
* <ul>
|
||||
* <li>单一入口:所有 ICE API 调用必须经过此类,禁止各业务类自行创建 {@link Client}</li>
|
||||
* <li>统一超时:通过 {@link IceProperties} 集中管理连接/读取超时</li>
|
||||
* <li>懒加载 + 线程安全:DCL 保证全局唯一 Client 实例</li>
|
||||
* <li>统一异常转换:ICE SDK 异常统一转为 {@link cn.iocoder.yudao.framework.common.exception.ServiceException}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class IceClient {
|
||||
|
||||
private final IceProperties properties;
|
||||
|
||||
private volatile Client iceClient;
|
||||
private volatile Client client;
|
||||
|
||||
/**
|
||||
* 获取 ICE 客户端(懒加载,线程安全)
|
||||
*/
|
||||
public Client getClient() {
|
||||
if (iceClient == null) {
|
||||
public IceClient(IceProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* Client 生命周期管理
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
private Client getClient() {
|
||||
if (client == null) {
|
||||
synchronized (this) {
|
||||
if (iceClient == null) {
|
||||
if (client == null) {
|
||||
if (!properties.isEnabled()) {
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "未配置 ICE AccessKey");
|
||||
}
|
||||
iceClient = createClient();
|
||||
client = createClient();
|
||||
}
|
||||
}
|
||||
}
|
||||
return iceClient;
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ICE 客户端
|
||||
*/
|
||||
private Client createClient() {
|
||||
try {
|
||||
log.info("[ICE][开始初始化客户端][regionId={}, bucket={}]",
|
||||
properties.getRegionId(), properties.getBucket());
|
||||
log.info("[ICE][初始化] regionId={}, bucket={}, connectTimeout={}, readTimeout={}",
|
||||
properties.getRegionId(), properties.getBucket(),
|
||||
properties.getConnectTimeout(), properties.getReadTimeout());
|
||||
|
||||
Config config = new Config();
|
||||
config.accessKeyId = properties.getAccessKeyId();
|
||||
config.accessKeySecret = properties.getAccessKeySecret();
|
||||
config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com";
|
||||
config.regionId = properties.getRegionId();
|
||||
config.connectTimeout = (int) properties.getConnectTimeout().toMillis();
|
||||
config.readTimeout = (int) properties.getReadTimeout().toMillis();
|
||||
|
||||
Client client = new Client(config);
|
||||
|
||||
log.info("[ICE][客户端初始化成功]");
|
||||
return client;
|
||||
Client c = new Client(config);
|
||||
log.info("[ICE][初始化成功]");
|
||||
return c;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][客户端初始化失败]", e);
|
||||
log.error("[ICE][初始化失败]", e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 客户端初始化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 业务 API
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* 提交媒体制作任务
|
||||
* 提交媒体制作任务(底层方法,接受已构建好的 Request)
|
||||
* <p>
|
||||
* 供 {@link cn.iocoder.yudao.module.tik.media.BatchProduceAlignment} 等
|
||||
* 需要自定义 Timeline 构建逻辑的上层组件调用。
|
||||
*
|
||||
* @param request 已填充 timeline 和 outputMediaConfig 的请求对象
|
||||
* @return ICE 任务 ID(jobId)
|
||||
*/
|
||||
public String submitMediaProducingJob(String timeline, String outputMediaConfig) {
|
||||
public String submitJob(SubmitMediaProducingJobRequest request) {
|
||||
try {
|
||||
Client client = getClient();
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
|
||||
SubmitMediaProducingJobResponse response = client.submitMediaProducingJob(request);
|
||||
SubmitMediaProducingJobResponse response = getClient().submitMediaProducingJob(request);
|
||||
String jobId = response.getBody().getJobId();
|
||||
|
||||
log.info("[ICE][提交任务成功][jobId={}]", jobId);
|
||||
log.info("[ICE][提交成功] jobId={}", jobId);
|
||||
return jobId;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][提交任务失败]", e);
|
||||
log.error("[ICE][提交失败]", e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 提交任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询媒体制作任务状态
|
||||
* 提交媒体制作任务(便捷方法,接受字符串参数)
|
||||
*
|
||||
* @param timeline 时间线 JSON
|
||||
* @param outputMediaConfig 输出媒体配置 JSON
|
||||
* @return ICE 任务 ID(jobId)
|
||||
*/
|
||||
public String getMediaProducingJobStatus(String jobId) {
|
||||
public String submitJob(String timeline, String outputMediaConfig) {
|
||||
SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest();
|
||||
request.setTimeline(timeline);
|
||||
request.setOutputMediaConfig(outputMediaConfig);
|
||||
return submitJob(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询媒体制作任务状态
|
||||
*
|
||||
* @param jobId ICE 任务 ID
|
||||
* @return 任务状态:Success / Failed / Running / Pending 等
|
||||
*/
|
||||
public String getJobStatus(String jobId) {
|
||||
try {
|
||||
Client client = getClient();
|
||||
GetMediaProducingJobRequest request = new GetMediaProducingJobRequest();
|
||||
request.setJobId(jobId);
|
||||
|
||||
GetMediaProducingJobResponse response = client.getMediaProducingJob(request);
|
||||
GetMediaProducingJobResponse response = getClient().getMediaProducingJob(request);
|
||||
String status = response.getBody().getMediaProducingJob().getStatus();
|
||||
|
||||
log.debug("[ICE][查询任务状态][jobId={}, status={}]", jobId, status);
|
||||
log.debug("[ICE][状态查询] jobId={}, status={}", jobId, status);
|
||||
return status;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[ICE][查询任务状态失败][jobId={}]", jobId, e);
|
||||
log.error("[ICE][状态查询失败] jobId={}", jobId, e);
|
||||
throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* 历史兼容方法(逐步废弃)
|
||||
* ════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 {@link #submitJob(String, String)}
|
||||
*/
|
||||
@Deprecated
|
||||
public String submitMediaProducingJob(String timeline, String outputMediaConfig) {
|
||||
return submitJob(timeline, outputMediaConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 {@link #getJobStatus(String)}
|
||||
*/
|
||||
@Deprecated
|
||||
public String getMediaProducingJobStatus(String jobId) {
|
||||
return getJobStatus(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.security.Security;
|
||||
|
||||
/**
|
||||
* 混剪任务配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class MixTaskConfig {
|
||||
|
||||
/**
|
||||
* 注册 BouncyCastle 安全提供者
|
||||
* <p>
|
||||
* ICE SDK 底层依赖 BC 做 API 请求签名({@code org.bouncycastle.crypto.Digest})。
|
||||
* Java 17 已内置 JCE,不再存在嵌套 JAR 签名校验失败的问题。
|
||||
* <p>
|
||||
* 注意:ICE 提交专用线程池 {@code iceSubmitExecutor} 定义在
|
||||
* {@link cn.iocoder.yudao.module.tik.config.TikAsyncConfig} 中。
|
||||
*/
|
||||
static {
|
||||
try {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
log.info("[MixTask] BouncyCastle Provider 已注册");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[MixTask] BouncyCastle Provider 注册失败(如已存在则忽略): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 混剪任务状态同步定时任务
|
||||
* 混剪任务定时调度
|
||||
* <p>
|
||||
* 双路径架构:
|
||||
* 1. createMixTask 即时异步提交(低延迟,最佳尝试)
|
||||
* 2. 本定时任务兜底恢复(高可靠,DB 驱动,JVM 重启不丢任务)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@@ -20,7 +24,23 @@ public class MixTaskStatusSyncJob {
|
||||
private final MixTaskService mixTaskService;
|
||||
|
||||
/**
|
||||
* 每30秒检查一次任务状态
|
||||
* 每30秒:恢复僵尸 pending 任务(job_ids 为空的待提交任务)
|
||||
* <p>
|
||||
* 这是 DB 驱动的兜底恢复路径。createMixTask 中的 CompletableFuture
|
||||
* 可能因 JVM 重启、线程池拒绝等原因丢失任务,此调度保证任务不丢。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 30_000, initialDelay = 15_000)
|
||||
public void recoverPendingSubmissions() {
|
||||
log.debug("开始恢复僵尸 pending 任务");
|
||||
try {
|
||||
mixTaskService.processPendingSubmissions();
|
||||
} catch (Exception e) {
|
||||
log.error("恢复僵尸 pending 任务失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟:同步运行中任务的状态(从 ICE 查询 jobId 进度)
|
||||
*/
|
||||
@Scheduled(cron = MixTaskConstants.CRON_CHECK_STATUS)
|
||||
public void syncTaskStatus() {
|
||||
|
||||
@@ -72,6 +72,14 @@ public interface MixTaskService {
|
||||
*/
|
||||
void saveTaskResult(Long taskId, List<String> outputUrls);
|
||||
|
||||
/**
|
||||
* 处理待提交任务(定时兜底恢复)
|
||||
*
|
||||
* 扫描 pending 状态且 job_ids 为空的僵尸任务,重新提交到 ICE。
|
||||
* 只处理创建超过2分钟的任务,避免与即时异步提交冲突。
|
||||
*/
|
||||
void processPendingSubmissions();
|
||||
|
||||
/**
|
||||
* 生成签名URL
|
||||
*
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -47,6 +48,9 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
@Resource
|
||||
private FileApi fileApi;
|
||||
|
||||
@Resource(name = "iceSubmitExecutor")
|
||||
private Executor iceSubmitExecutor;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) {
|
||||
@@ -68,7 +72,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
// 2. 保存到数据库
|
||||
mixTaskMapper.insert(task);
|
||||
|
||||
// 3. 异步提交到阿里云 ICE(使用 TTL 自动传递上下文)
|
||||
// 3. 异步提交到阿里云 ICE(使用专用线程池 + TTL 自动传递上下文)
|
||||
CompletableFuture.runAsync(TtlRunnable.get(() -> {
|
||||
try {
|
||||
submitToICE(task.getId(), createReqVO, userId);
|
||||
@@ -76,7 +80,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
log.error("[MixTask][提交ICE失败] taskId={}", task.getId(), e);
|
||||
updateTaskError(task.getId(), "提交任务失败: " + e.getMessage());
|
||||
}
|
||||
}));
|
||||
}), iceSubmitExecutor);
|
||||
|
||||
return task.getId();
|
||||
}
|
||||
@@ -178,25 +182,10 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
updateTask.setOutputUrlList(null);
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
|
||||
// 3. 重新提交到ICE(使用 TTL 自动传递上下文)
|
||||
// 3. 重新提交到ICE(使用专用线程池 + TTL 自动传递上下文)
|
||||
CompletableFuture.runAsync(TtlRunnable.get(() -> {
|
||||
try {
|
||||
// 从 materialsJson 重建请求对象
|
||||
List<MixTaskSaveReqVO.MaterialItem> materials = null;
|
||||
if (StrUtil.isNotEmpty(existTask.getMaterialsJson())) {
|
||||
materials = JsonUtils.parseArray(existTask.getMaterialsJson(), MixTaskSaveReqVO.MaterialItem.class);
|
||||
} else if (existTask.getVideoUrlList() != null && !existTask.getVideoUrlList().isEmpty()) {
|
||||
// 兼容旧版本:从 videoUrls 重建(默认3秒时长)
|
||||
materials = existTask.getVideoUrlList().stream()
|
||||
.map(url -> {
|
||||
MixTaskSaveReqVO.MaterialItem item = new MixTaskSaveReqVO.MaterialItem();
|
||||
item.setFileUrl(url);
|
||||
item.setDuration(3); // 默认3秒
|
||||
return item;
|
||||
})
|
||||
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
|
||||
}
|
||||
|
||||
List<MixTaskSaveReqVO.MaterialItem> materials = rebuildMaterialsFromTask(existTask);
|
||||
if (materials == null || materials.isEmpty()) {
|
||||
throw new IllegalArgumentException("无法重建素材列表");
|
||||
}
|
||||
@@ -210,7 +199,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
log.error("[MixTask][重新提交失败] taskId={}", id, e);
|
||||
updateTaskError(id, "重新提交失败: " + e.getMessage());
|
||||
}
|
||||
}));
|
||||
}), iceSubmitExecutor);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -282,6 +271,84 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理待提交的僵尸任务(定时兜底恢复)
|
||||
*
|
||||
* 设计意图:
|
||||
* - createMixTask 中的 CompletableFuture 是"即时路径",追求低延迟
|
||||
* - 本方法是"兜底路径",处理即时路径丢失的任务(JVM 重启、线程池拒绝等)
|
||||
* - 只处理创建超过2分钟的任务,避免与即时路径的异步提交冲突
|
||||
*
|
||||
* 执行策略:
|
||||
* - 扫描最近6小时内、pending 状态、job_ids 为空的僵尸任务
|
||||
* - 每次至多处理 20 个,避免单次执行时间过长
|
||||
* - 使用租户上下文重建请求并同步提交到 ICE
|
||||
* - 单个任务失败不影响其他任务
|
||||
*/
|
||||
@Override
|
||||
public void processPendingSubmissions() {
|
||||
// 安全窗口:只处理创建超过2分钟的任务,避免与即时异步提交冲突
|
||||
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(2);
|
||||
LocalDateTime startTime = LocalDateTime.now().minusHours(MixTaskConstants.CHECK_HOURS_LIMIT);
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
// 查询僵尸任务(pending 且 job_ids 为空,在时间窗口内)
|
||||
List<MixTaskDO> zombieTasks = TenantUtils.executeIgnore(() ->
|
||||
mixTaskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<MixTaskDO>()
|
||||
.eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_PENDING)
|
||||
.isNull(MixTaskDO::getJobIds)
|
||||
.ge(MixTaskDO::getCreateTime, startTime)
|
||||
.le(MixTaskDO::getCreateTime, cutoffTime)
|
||||
.orderByAsc(MixTaskDO::getCreateTime)
|
||||
.last("LIMIT 20")
|
||||
)
|
||||
);
|
||||
|
||||
if (zombieTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[MixTask][僵尸任务恢复] 发现 {} 个待提交的僵尸任务", zombieTasks.size());
|
||||
|
||||
for (MixTaskDO task : zombieTasks) {
|
||||
try {
|
||||
Long taskId = task.getId();
|
||||
Long userId = task.getUserId();
|
||||
Long tenantId = task.getTenantId();
|
||||
|
||||
List<MixTaskSaveReqVO.MaterialItem> materials = rebuildMaterialsFromTask(task);
|
||||
if (materials == null || materials.isEmpty()) {
|
||||
log.warn("[MixTask][僵尸任务跳过] taskId={}, 无法重建素材列表", taskId);
|
||||
updateTaskError(taskId, "素材数据缺失,无法恢复提交");
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
MixTaskSaveReqVO saveReqVO = new MixTaskSaveReqVO();
|
||||
saveReqVO.setTitle(task.getTitle());
|
||||
saveReqVO.setMaterials(materials);
|
||||
saveReqVO.setProduceCount(task.getProduceCount());
|
||||
|
||||
// 使用任务原有的租户上下文提交
|
||||
TenantUtils.execute(tenantId, () -> submitToICE(taskId, saveReqVO, userId));
|
||||
|
||||
successCount++;
|
||||
log.info("[MixTask][僵尸任务恢复成功] taskId={}, tenantId={}, materialCount={}",
|
||||
taskId, tenantId, materials.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[MixTask][僵尸任务恢复失败] taskId={}", task.getId(), e);
|
||||
updateTaskError(task.getId(), "恢复提交失败: " + e.getMessage());
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[MixTask][僵尸任务恢复完成] 共处理 {} 个, 成功 {} 个, 失败 {} 个",
|
||||
successCount + failCount, successCount, failCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务状态(检查所有jobId,综合判断)
|
||||
*
|
||||
@@ -317,7 +384,7 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
|
||||
for (String jobId : jobIds) {
|
||||
try {
|
||||
String status = iceClient.getMediaProducingJobStatus(jobId);
|
||||
String status = iceClient.getJobStatus(jobId);
|
||||
log.debug("[MixTask][jobId状态] taskId={}, jobId={}, status={}", taskId, jobId, status);
|
||||
if ("Success".equalsIgnoreCase(status)) {
|
||||
successJobIds.add(jobId);
|
||||
@@ -560,16 +627,25 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务错误信息
|
||||
* 更新任务错误信息(自带异常保护,永不对外抛异常)
|
||||
*
|
||||
* 这是错误处理链的最后一道防线。即使 DB 更新失败,也至少记录日志,
|
||||
* 避免因 updateTaskError 自身失败导致 error_msg 永久为 null。
|
||||
*/
|
||||
private void updateTaskError(Long taskId, String errorMsg) {
|
||||
MixTaskDO updateTask = new MixTaskDO();
|
||||
updateTask.setId(taskId);
|
||||
updateTask.setStatus(MixTaskConstants.STATUS_FAILED);
|
||||
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
|
||||
updateTask.setErrorMsg(errorMsg);
|
||||
updateTask.setFinishTime(java.time.LocalDateTime.now());
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
try {
|
||||
MixTaskDO updateTask = new MixTaskDO();
|
||||
updateTask.setId(taskId);
|
||||
updateTask.setStatus(MixTaskConstants.STATUS_FAILED);
|
||||
updateTask.setProgress(MixTaskConstants.PROGRESS_COMPLETED);
|
||||
updateTask.setErrorMsg(errorMsg);
|
||||
updateTask.setFinishTime(LocalDateTime.now());
|
||||
mixTaskMapper.updateById(updateTask);
|
||||
log.info("[MixTask][错误已记录] taskId={}, errorMsg={}", taskId, errorMsg);
|
||||
} catch (Exception e) {
|
||||
// 最后兜底:至少确保日志中有错误信息,方便运维排查
|
||||
log.error("[MixTask][严重] 无法更新任务错误信息到数据库!taskId={}, errorMsg={}", taskId, errorMsg, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,6 +710,31 @@ public class MixTaskServiceImpl implements MixTaskService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务记录重建素材列表(供 retryTask 和 processPendingSubmissions 共用)
|
||||
* <p>
|
||||
* 优先从 materialsJson 解析,降级到 videoUrls(兼容旧版本,默认3秒时长)
|
||||
*
|
||||
* @param task 任务记录
|
||||
* @return 素材列表,无法重建时返回 null
|
||||
*/
|
||||
private List<MixTaskSaveReqVO.MaterialItem> rebuildMaterialsFromTask(MixTaskDO task) {
|
||||
if (StrUtil.isNotEmpty(task.getMaterialsJson())) {
|
||||
return JsonUtils.parseArray(task.getMaterialsJson(), MixTaskSaveReqVO.MaterialItem.class);
|
||||
}
|
||||
if (task.getVideoUrlList() != null && !task.getVideoUrlList().isEmpty()) {
|
||||
return task.getVideoUrlList().stream()
|
||||
.map(url -> {
|
||||
MixTaskSaveReqVO.MaterialItem item = new MixTaskSaveReqVO.MaterialItem();
|
||||
item.setFileUrl(url);
|
||||
item.setDuration(3);
|
||||
return item;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验混剪任务时长
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,8 @@ public class AiAgentServiceImpl implements AiAgentService {
|
||||
public Long createAiAgent(AiAgentSaveReqVO createReqVO) {
|
||||
// 插入
|
||||
AiAgentDO aiAgent = BeanUtils.toBean(createReqVO, AiAgentDO.class);
|
||||
aiAgent.setOperatorId(SecurityFrameworkUtils.getLoginUserId());
|
||||
aiAgent.setOperatorName(String.valueOf(SecurityFrameworkUtils.getLoginUserNickname()));
|
||||
aiAgentMapper.insert(aiAgent);
|
||||
|
||||
// 返回
|
||||
@@ -47,6 +49,8 @@ public class AiAgentServiceImpl implements AiAgentService {
|
||||
validateAiAgentExists(updateReqVO.getId());
|
||||
// 更新
|
||||
AiAgentDO updateObj = BeanUtils.toBean(updateReqVO, AiAgentDO.class);
|
||||
updateObj.setOperatorId(SecurityFrameworkUtils.getLoginUserId());
|
||||
updateObj.setOperatorName(String.valueOf(SecurityFrameworkUtils.getLoginUserNickname()));
|
||||
aiAgentMapper.updateById(updateObj);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,20 +19,17 @@ public class AiAgentSaveReqVO {
|
||||
@NotEmpty(message = "智能体名称不能为空")
|
||||
private String agentName;
|
||||
|
||||
@Schema(description = "图标URL", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "图标URL不能为空")
|
||||
@Schema(description = "图标URL", example = "https://example.com/icon.png")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "状态(0-禁用 1-启用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "状态(0-禁用 1-启用)不能为空")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "设定描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "你说的对")
|
||||
@NotEmpty(message = "设定描述不能为空")
|
||||
@Schema(description = "设定描述", example = "你说的对")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "预置提示词", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "预置提示词不能为空")
|
||||
@Schema(description = "预置提示词")
|
||||
private String systemPrompt;
|
||||
|
||||
@Schema(description = "备注", example = "你说的对")
|
||||
@@ -41,12 +38,10 @@ public class AiAgentSaveReqVO {
|
||||
@Schema(description = "分类名称(中文)", example = "文案创作")
|
||||
private String categoryName;
|
||||
|
||||
@Schema(description = "操作人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "6593")
|
||||
@NotNull(message = "操作人用户编号不能为空")
|
||||
@Schema(description = "操作人用户编号", example = "6593")
|
||||
private Long operatorId;
|
||||
|
||||
@Schema(description = "操作人账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "赵六")
|
||||
@NotEmpty(message = "操作人账号不能为空")
|
||||
@Schema(description = "操作人账号", example = "赵六")
|
||||
private String operatorName;
|
||||
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package cn.iocoder.yudao.module.tik.media;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.mix.client.IceClient;
|
||||
import cn.iocoder.yudao.module.tik.mix.config.IceProperties;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
@@ -23,14 +25,20 @@ class BatchProduceAlignmentTest {
|
||||
@Mock
|
||||
private IceProperties iceProperties;
|
||||
|
||||
@Mock
|
||||
private FileApi fileApi;
|
||||
|
||||
@Mock
|
||||
private TikOssInitService ossInitService;
|
||||
|
||||
@Mock
|
||||
private IceClient iceClient;
|
||||
|
||||
private BatchProduceAlignment batchProduceAlignment;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
batchProduceAlignment = new BatchProduceAlignment(iceProperties, null, ossInitService);
|
||||
batchProduceAlignment = new BatchProduceAlignment(iceProperties, fileApi, ossInitService, iceClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring.boot.version}</version>
|
||||
<configuration>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
server:
|
||||
port: 9900
|
||||
# 优雅关闭:等待 HTTP 请求和后台任务完成后再退出
|
||||
shutdown: graceful
|
||||
|
||||
spring:
|
||||
lifecycle:
|
||||
# 每个阶段最多等待 30 秒,给 CompletableFuture 和线程池排空的机会
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
--- #################### 数据库相关配置 ####################
|
||||
spring:
|
||||
|
||||
Reference in New Issue
Block a user