Compare commits

..

23 Commits

Author SHA1 Message Date
0224c8b3e3 refactor(tik): 使用 ICE 原生 AdaptMode 替换自定义裁剪逻辑
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 移除手动计算裁剪参数的 `calculateCropParams` 方法
- 利用 ICE 的 `AdaptMode` 属性实现素材自适应,支持 `Cover`(裁切填充)和 `Fill`(拉伸填充)模式
- 简化代码并提高对不同分辨率素材的兼容性
2026-06-04 22:21:23 +08:00
2ea88316d2 refactor(ui): 重构素材上传弹窗组件,优化用户交互体验
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 重构整个上传流程为清晰的三阶段结构:拖拽区、文件列表、上传进度
- 使用 BEM 命名规范替换原有嵌套 CSS 选择器,提升可维护性
- 简化模板逻辑和模板代码,移除冗余状态变量和条件判断
- 优化进度显示,新增剩余时间估算功能
- 替换 `Progress` 组件为原生样式实现,减少外部组件依赖
- 统一文件操作和上传逻辑的错误处理流程
- 优化视觉设计:调整弹窗宽度、改进拖拽区样式、精简按钮布局
2026-06-04 01:16:22 +08:00
499698cb0d refactor(tik): 移除 ICE 异步提交专用线程池定义
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
将线程池定义移至 `TikAsyncConfig` 配置类中统一管理,清理 `MixTaskConfig` 中冗余的 `iceSubmitExecutor` Bean 定义,提升配置职责的清晰度。
2026-06-03 22:58:57 +08:00
077a15451b build(deps): 指定 Bouncy Castle 版本为 1.78.1
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-06-03 22:51:39 +08:00
b1750302c8 fix(tik): 恢复 Bouncy Castle 依赖并移除强制排除
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-06-03 22:49:44 +08:00
703cc2697e feat(mix): 添加 ICE 异步提交专用线程池并注册 BouncyCastle 安全提供者
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
为混剪任务配置增加两个关键基础设施:创建 ICE 异步提交专用线程池(核心 4 线程,最大 10 线程,使用 CallerRunsPolicy 拒绝策略),并注册 BouncyCastle 安全提供者以满足 ICE SDK 签名需求。这为混剪任务的异步执行提供了可靠的并发控制和安全依赖。
2026-06-03 22:45:38 +08:00
e99c0806de fix(deploy): 在部署脚本中使用 -Dmaven.test.skip=true 跳过测试
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
由于 `-DskipTests` 仅跳过测试执行但不会跳过测试编译,在无网络环境中可能导致测试编译依赖无法解析而失败。改为 `-Dmaven.test.skip=true` 完全跳过测试阶段,确保在离线或受限环境下的构建稳定性。
2026-06-03 22:42:41 +08:00
d95b0a63dd refactor(tik): 调整混测用例构造参数并修复前端按钮样式
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 在 `BatchProduceAlignmentTest` 中添加 `FileApi` 和 `IceClient` 模拟依赖,更新构造方法调用
- 移除前端 `一键填充` 按钮的 `variant="outline"` 属性
2026-06-03 22:31:56 +08:00
71ed87371b refactor(deploy): 升级生产部署脚本,增加优雅启停、健康检查与自动回滚
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
升级部署脚本为运维级实现,新增以下核心特性:
- 优雅启停 (SIGTERM → 等待 → SIGKILL 兜底)
- 健康检查 + 自动回滚
- 版本备份,保留最近 5 个版本
- 并发锁防止重复执行
- 前置检查 (磁盘/内存/端口/编译产物)
- 结构化日志

同时启用 Spring Boot 优雅关闭功能,配置 30 秒超时以等待请求和后台任务完成。
2026-06-03 22:22:05 +08:00
3a3638295b refactor(tik): 抽取 ICE SDK 调用到统一客户端层
将 `BatchProduceAlignment` 中的 ICE `Client` 初始化与 API 调用逻辑抽离到 `IceClient`,
作为项目中唯一 ICE SDK 入口,避免各业务类自行创建客户端实例。

主要变更:
- `IceClient`:重构为最终的统一封装入口,支持懒加载 + 线程安全 Client 创建,
  新增 `submitJob(SubmitMediaProducingJobRequest)` 和 `getJobStatus()` 方法,
  保留旧方法标记为 `@Deprecated` 以兼容存量调用。
- `BatchProduceAlignment`:移除内部 `initClient()` 与直接 SDK 调用,全部委托 `IceClient`;
  同时重构 Timeline 构建逻辑,提取 `buildOutputPath()` 等工具方法,
  并补充 Javadoc 架构说明。
- `MixTaskServiceImpl`:同步使用 `iceClient.getJobStatus()` 替代已废弃的旧方法名。
2026-06-03 22:19:03 +08:00
248deeea0d feat(mix): 添加混剪任务定时恢复与专用线程池
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
新增 ICE 异步提交专用线程池,支持定时兜底恢复僵尸 pending 任务,增强任务提交的可靠性。

- 创建 `iceSubmitExecutor` 线程池(核心4/最大10/有界队列200/CallerRunsPolicy)
- 新增 `processPendingSubmissions` 方法,扫描并恢复6小时内、pending且job_ids为空的僵尸任务
- 新增定时任务 `recoverPendingSubmissions`,每30秒执行一次
- 将异步提交改为使用专用线程池,并增加异常保护
- 优化 `updateTaskError` 方法,增加日志兜底
- 修复前端生成数量选择器宽度不足的问题
2026-06-03 21:55:17 +08:00
8538b3cdb4 fix(ui): 限制文件名显示宽度并改用 color-mix 实现进度条背景色
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 为 MaterialUploadModal 中的文件名元素设置 `min-width: 0` 和 `max-width`,确保溢出文本正确显示省略号
- 将 Progress 组件的背景色从 Tailwind `bg-primary/20` 改为 CSS `color-mix`,避免透明度叠加异常
2026-06-02 21:29:11 +08:00
0666f3bc8c style(IdentifyFace): 将 CloudUploadOutlined 图标替换为 lucide 的 upload 图标
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-05-30 22:27:40 +08:00
027db6efc9 feat(auth): 优化登录页面移动端键盘适配与输入框样式
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
- 将页面容器的高度单位从 vh 改为 dvh 以适配移动端键盘弹出
- 分离登录页面容器样式,提高可维护性
- 调整输入框字体大小为 16px 防止 iOS 自动缩放
2026-05-30 20:39:27 +08:00
98fb35f0b9 build(yudao-server): 使用 spring-boot-maven-plugin 排除 BouncyCastle 替代 maven-enforcer-plugin
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
将防 BouncyCastle 签名 JAR 进入 fat JAR 的策略从 maven-enforcer-plugin 的编译时限制
改为 spring-boot-maven-plugin 的打包时排除,并从根 pom 移除不再需要的 enforcer 插件
及依赖版本属性。同时删除 yudao-dependencies 中全局提供的空壳 BouncyCastle 依赖,
避免污染所有模块的类路径。
2026-05-27 20:52:06 +08:00
5477e2f261 build(deps): 全局排除 BouncyCastle 依赖以解决 Spring Boot fat JAR 签名问题
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
在 `yudao-dependencies/pom.xml` 中为所有 BouncyCastle 变体添加空壳版本(`0.0.0`)和 `provided` 作用域,从源头杜绝 JCE 签名校验失败。同时更新注释以更清晰地说明原因。同步排除 `yudao-module-ai` 中 `aliyun-java-sdk-core` 依赖和 `yudao-module-pay` 中 `weixin-java-pay` 依赖的 `bcprov-jdk18on`,确保所有模块不受影响。
2026-05-26 22:48:27 +08:00
d5af885ce3 feat(build): 添加 BouncyCastle 依赖排除与打包检查
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
添加 `maven-enforcer-plugin` 并配置规则,禁止 BouncyCastle 签名 JAR 进入 fat JAR 包,避免 Spring Boot 嵌套 JAR 导致 JCE 签名校验失败。

同时在 `yudao-module-ai` 和 `yudao-module-tik` 模块的 `spring-cloud-function-core` 依赖中排除 `bcprov-jdk18on` 和 `bcpkix-jdk18on`。
2026-05-26 22:29:46 +08:00
9397e0f177 build(deps): 锁定 volcengine SDK 版本至 1.0.16
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-05-26 21:45:39 +08:00
8fb11fa6ca build(deps): 防止 Netty 传输层与 HTTP/3 模块携带 BouncyCastle 导致打包异常
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
由于 Netty 4.2.x 默认将 `bcprov-jdk18on` 作为传递依赖引入,在 Spring Boot 可执行 JAR 的嵌套类加载环境下会触发 JCE 签名验证失败。为 `netty-handler` 和 `netty-codec-http3` 统一声明排除规则,确保依赖树洁净。
2026-05-26 02:22:25 +08:00
b6ab8f2b9d chore(tik): 排除阿里云依赖中的 bouncycastle 冲突包
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
在 `ice20201109` 和 `aliyun-java-sdk-core` 依赖中排除存在版本冲突的 `bouncycastle` 相关包(`bcpkix-jdk15on`、`bcprov-jdk15on`、`bcprov-jdk18on`),以解决潜在的类路径冲突问题。
2026-05-26 02:00:51 +08:00
66582c543d 好的,这是根据您提供的 git diff 和指令生成的符合 Conventional Commits 规范的提交信息。
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
style(token-manager): 移除文件开头的多余空行
2026-05-26 01:21:54 +08:00
038f205413 feat(ai-agent): 在创建和更新智能体时自动设置操作人信息
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
移除 AiAgentSaveReqVO 中 icon、description、systemPrompt、operatorId 和 operatorName 字段的必填校验,改为由服务层自动获取当前登录用户的信息进行填充。
2026-05-01 14:38:30 +08:00
8f8b0a03e4 feat(agent): 将收藏夹卡片使用次数改为显示创建时间
在收藏夹列表中,将使用次数显示替换为创建时间戳,使用 dayjs 格式化显示,并调整卡片底部布局为两端对齐。
2026-04-27 20:00:36 +08:00
24 changed files with 1273 additions and 919 deletions

View File

@@ -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;

View File

@@ -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 />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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">支持 MP4MOV 格式视频需大于 3 </div>

View File

@@ -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>

View File

@@ -6,7 +6,6 @@
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
* * ✅ 提供订阅者模式,监听令牌变化事件
*
*/
// localStorage 中存储的键名常量

View File

@@ -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 "$@"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -20,6 +20,13 @@
</properties>
<dependencies>
<!-- Bouncy CastleICE 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>

View File

@@ -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() {

View File

@@ -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原始URLICE需要原始域名
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原始URLICE需要原始域名
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竖屏输出
// 假设源素材为1920x108016: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 和 HeightAdaptMode 才会生效
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";
}
}

View File

@@ -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 任务 IDjobId
*/
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 任务 IDjobId
*/
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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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() {

View File

@@ -72,6 +72,14 @@ public interface MixTaskService {
*/
void saveTaskResult(Long taskId, List<String> outputUrls);
/**
* 处理待提交任务(定时兜底恢复)
*
* 扫描 pending 状态且 job_ids 为空的僵尸任务,重新提交到 ICE。
* 只处理创建超过2分钟的任务避免与即时异步提交冲突。
*/
void processPendingSubmissions();
/**
* 生成签名URL
*

View File

@@ -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;
}
/**
* 校验混剪任务时长
*/

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -1,5 +1,12 @@
server:
port: 9900
# 优雅关闭:等待 HTTP 请求和后台任务完成后再退出
shutdown: graceful
spring:
lifecycle:
# 每个阶段最多等待 30 秒,给 CompletableFuture 和线程池排空的机会
timeout-per-shutdown-phase: 30s
--- #################### 数据库相关配置 ####################
spring: