From 0d85a430828e60490bb9bdf3feaf0f1d2ab0177b Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Wed, 4 Mar 2026 03:27:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TaskActionButtons.vue | 18 +-- .../digital-human-task/index.vue | 35 +++-- .../system/task-management/mix-task/index.vue | 33 ++--- .../app/web-gold/src/views/user/Profile.vue | 9 +- .../file/core/client/s3/S3FileClient.java | 53 ++++++- .../file/service/TikOssInitServiceImpl.java | 139 ++++++++---------- .../tik/mix/constants/MixTaskConstants.java | 4 +- .../tik/mix/service/MixTaskServiceImpl.java | 36 +++-- 8 files changed, 179 insertions(+), 148 deletions(-) diff --git a/frontend/app/web-gold/src/views/system/task-management/components/TaskActionButtons.vue b/frontend/app/web-gold/src/views/system/task-management/components/TaskActionButtons.vue index 717c0e697b..5db8549675 100644 --- a/frontend/app/web-gold/src/views/system/task-management/components/TaskActionButtons.vue +++ b/frontend/app/web-gold/src/views/system/task-management/components/TaskActionButtons.vue @@ -45,15 +45,7 @@ - - - 删除 - - + 删除 @@ -125,11 +117,6 @@ const canRetry = computed(() => { return props.task.status === 'failed' }) -// 计算属性:是否可删除 -const canDelete = computed(() => { - return true // 所有任务都可以删除 -}) - // 处理预览 const handlePreview = async () => { if (getSignedUrlsApi) { @@ -162,8 +149,7 @@ const handleDelete = () => { } - diff --git a/frontend/app/web-gold/src/views/user/Profile.vue b/frontend/app/web-gold/src/views/user/Profile.vue index c6ca4e5f5d..ec4cfc0782 100644 --- a/frontend/app/web-gold/src/views/user/Profile.vue +++ b/frontend/app/web-gold/src/views/user/Profile.vue @@ -171,7 +171,6 @@ onMounted(async () => {

{{ userStore.displayName }}

-
ID: {{ userStore.userId || '未设置' }}
普通用户
@@ -370,14 +369,8 @@ onMounted(async () => { .user-name { font-size: 20px; font-weight: 600; - margin-bottom: 4px; - color: var(--color-text); -} - -.user-id { - color: var(--color-text-secondary); - font-size: 13px; margin-bottom: 12px; + color: var(--color-text); } .user-role-badge { diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 663ff97523..368dc39757 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -198,9 +198,8 @@ public class S3FileClient extends AbstractFileClient { @Override public String presignGetUrl(String url, Integer expirationSeconds) { - // 1. 将 url 转换为 path - String path = StrUtil.removePrefix(url, config.getDomain() + "/"); - path = HttpUtils.removeUrlQuery(path); + // 1. 将 url 转换为 path(支持 CDN 域名和 OSS 原始域名) + String path = extractPathFromUrl(url); String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8); // 2. 公开访问:无需签名 @@ -272,6 +271,54 @@ public class S3FileClient extends AbstractFileClient { return StrUtil.format("https://{}", config.getEndpoint()); } + /** + * 从 URL 中提取 path(支持 CDN 域名和 OSS 原始域名) + * + * @param url 完整的 URL + * @return 相对路径(不含查询参数) + */ + private String extractPathFromUrl(String url) { + if (StrUtil.isEmpty(url)) { + return url; + } + + // 移除查询参数 + String cleanUrl = HttpUtils.removeUrlQuery(url); + + // 1. 尝试使用配置的 domain 提取 path(CDN 域名) + if (StrUtil.isNotEmpty(config.getDomain()) && cleanUrl.startsWith(config.getDomain() + "/")) { + return StrUtil.removePrefix(cleanUrl, config.getDomain() + "/"); + } + + // 2. 尝试从 OSS 原始域名提取 path(阿里云格式) + // 格式:https://{bucket}.oss-{region}.aliyuncs.com/{path} + if (cleanUrl.contains(".aliyuncs.com/")) { + int pathStart = cleanUrl.indexOf(".aliyuncs.com/") + ".aliyuncs.com/".length(); + if (pathStart < cleanUrl.length()) { + return cleanUrl.substring(pathStart); + } + } + + // 3. 尝试从 buildDomain() 格式提取 path + String builtDomain = buildDomain(); + if (cleanUrl.startsWith(builtDomain + "/")) { + return StrUtil.removePrefix(cleanUrl, builtDomain + "/"); + } + + // 4. 兜底:如果 URL 包含路径分隔符,尝试提取路径部分 + int slashIndex = cleanUrl.indexOf("://"); + if (slashIndex > 0) { + String afterProtocol = cleanUrl.substring(slashIndex + 3); + int pathIndex = afterProtocol.indexOf('/'); + if (pathIndex > 0 && pathIndex < afterProtocol.length() - 1) { + return afterProtocol.substring(pathIndex + 1); + } + } + + // 5. 最后兜底:直接返回原始 URL(可能已经是 path) + return cleanUrl; + } + /** * 从 endpoint 中提取 region */ diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java index 4cf356ecd4..23b61784d5 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java @@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserOssInitDO; import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserOssInitMapper; import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -45,35 +46,50 @@ public class TikOssInitServiceImpl implements TikOssInitService { return BeanUtils.toBean(existing, AppTikUserOssInitRespVO.class); } - // 获取用户信息(获取手机号) - MemberUserRespDTO user = memberUserApi.getUser(userId); - if (user == null || StrUtil.isBlank(user.getMobile())) { - throw exception(OSS_INIT_FAILED, "用户手机号不存在"); - } - - // 计算手机号MD5和OSS路径 + // 获取用户信息(优先使用手机号MD5,否则使用userId) Long tenantId = TenantContextHolder.getRequiredTenantId(); - String mobileMd5 = DigestUtil.md5Hex(user.getMobile()); - OssPathInfo pathInfo = buildOssPaths(mobileMd5, tenantId); + String pathIdentifier = getPathIdentifier(userId, tenantId); + OssPathInfo pathInfo = buildOssPaths(pathIdentifier, tenantId); + + // 创建OSS初始化记录 + TikUserOssInitDO ossInit = createOssInitDO(userId, pathIdentifier, pathInfo); - // 创建或更新OSS初始化记录 - // 注意:OSS中目录是虚拟的,不需要显式创建,直接上传文件时包含路径即可自动创建 - TikUserOssInitDO ossInit; if (existing != null) { - // 更新现有记录(补充缺失的字段或重新初始化) - ossInit = existing; - updateOssInitFields(ossInit, mobileMd5, pathInfo); + // 更新现有记录 + ossInit.setId(existing.getId()); ossInitMapper.updateById(ossInit); } else { - // 创建新记录 - ossInit = createOssInitDO(userId, mobileMd5, pathInfo); - ossInitMapper.insert(ossInit); + // 尝试插入,如果并发冲突则更新 + try { + ossInitMapper.insert(ossInit); + } catch (DuplicateKeyException e) { + log.info("[initOssDirectory][用户({})并发插入冲突,改为更新]", userId); + existing = ossInitMapper.selectByUserId(userId); + if (existing != null) { + ossInit.setId(existing.getId()); + ossInitMapper.updateById(ossInit); + } + } } log.info("[initOssDirectory][用户({})OSS初始化成功,根路径({})]", userId, pathInfo.ossRootPath); return BeanUtils.toBean(ossInit, AppTikUserOssInitRespVO.class); } + /** + * 获取路径标识符 + * 优先使用手机号MD5,否则使用userId + */ + private String getPathIdentifier(Long userId, Long tenantId) { + MemberUserRespDTO user = memberUserApi.getUser(userId); + if (user != null && StrUtil.isNotBlank(user.getMobile())) { + return DigestUtil.md5Hex(user.getMobile()); + } + // 无手机号时使用userId作为标识 + log.info("[getPathIdentifier][用户({})无手机号,使用userId作为路径标识]", userId); + return "u" + userId; + } + /** * OSS路径信息 */ @@ -88,9 +104,12 @@ public class TikOssInitServiceImpl implements TikOssInitService { /** * 构建OSS路径信息 + * + * @param pathIdentifier 路径标识符(手机号MD5或u{userId}格式) + * @param tenantId 租户ID */ - private OssPathInfo buildOssPaths(String mobileMd5, Long tenantId) { - String ossRootPath = mobileMd5 + "/" + tenantId; + private OssPathInfo buildOssPaths(String pathIdentifier, Long tenantId) { + String ossRootPath = pathIdentifier + "/" + tenantId; return new OssPathInfo( ossRootPath, ossRootPath + "/video", @@ -103,25 +122,15 @@ public class TikOssInitServiceImpl implements TikOssInitService { /** * 创建OSS初始化DO对象 + * + * @param userId 用户ID + * @param pathIdentifier 路径标识符(手机号MD5或u{userId}格式) + * @param pathInfo 路径信息 */ - private TikUserOssInitDO createOssInitDO(Long userId, String mobileMd5, OssPathInfo pathInfo) { + private TikUserOssInitDO createOssInitDO(Long userId, String pathIdentifier, OssPathInfo pathInfo) { return new TikUserOssInitDO() .setUserId(userId) - .setMobileMd5(mobileMd5) - .setOssRootPath(pathInfo.ossRootPath) - .setVideoPath(pathInfo.videoPath) - .setGeneratePath(pathInfo.generatePath) - .setAudioPath(pathInfo.audioPath) - .setMixPath(pathInfo.mixPath) - .setVoicePath(pathInfo.voicePath) - .setInitStatus(1); - } - - /** - * 更新OSS初始化DO对象的字段 - */ - private void updateOssInitFields(TikUserOssInitDO ossInit, String mobileMd5, OssPathInfo pathInfo) { - ossInit.setMobileMd5(mobileMd5) + .setMobileMd5(pathIdentifier) .setOssRootPath(pathInfo.ossRootPath) .setVideoPath(pathInfo.videoPath) .setGeneratePath(pathInfo.generatePath) @@ -139,27 +148,27 @@ public class TikOssInitServiceImpl implements TikOssInitService { } } - @Override - public String getOssRootPath(Long userId) { + /** + * 获取已初始化的OSS记录,未初始化则抛出异常 + */ + private TikUserOssInitDO getRequiredOssInit(Long userId) { TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId); if (ossInit == null || ossInit.getInitStatus() == 0) { throw exception(OSS_INIT_FAILED); } - return ossInit.getOssRootPath(); + return ossInit; + } + + @Override + public String getOssRootPath(Long userId) { + return getRequiredOssInit(userId).getOssRootPath(); } @Override public String getOssDirectoryByCategory(Long userId, String fileCategory) { - // 确保OSS已初始化 ensureOssInitialized(userId); + TikUserOssInitDO ossInit = getRequiredOssInit(userId); - // 获取OSS初始化记录 - TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId); - if (ossInit == null || ossInit.getInitStatus() == 0) { - throw exception(OSS_INIT_FAILED); - } - - // 根据分类返回对应基础目录路径 return switch (fileCategory) { case "video" -> ossInit.getVideoPath(); case "generate" -> ossInit.getGeneratePath(); @@ -173,7 +182,7 @@ public class TikOssInitServiceImpl implements TikOssInitService { /** * 基于分类和分组获取OSS目录路径 - * 新路径格式:/user-files/{category}/{date}/{groupName}/ + * 路径格式:{mobileMd5}/user-files/{category}/{date}/{groupName} * * @param userId 用户编号 * @param category 分类:MIX 或 DIGITAL_HUMAN @@ -182,36 +191,18 @@ public class TikOssInitServiceImpl implements TikOssInitService { * @return OSS目录路径 */ public String getOssDirectoryByCategoryAndGroup(Long userId, String category, String groupName, String dateStr) { - // 确保OSS已初始化 ensureOssInitialized(userId); + TikUserOssInitDO ossInit = getRequiredOssInit(userId); - // 构建新格式的路径 - // 路径格式:{mobileMd5}/{tenantId}/user-files/{category}/{date}/{groupName} - TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId); - if (ossInit == null || ossInit.getInitStatus() == 0) { - throw exception(OSS_INIT_FAILED); + String path = ossInit.getMobileMd5() + "/user-files/" + category.toLowerCase() + "/" + dateStr; + + if (StrUtil.isNotBlank(groupName)) { + // 对分组名进行URL安全处理:保留中文、字母、数字、下划线和连字符 + String safeGroupName = groupName.trim().replaceAll("[^a-zA-Z0-9一-鿿_-]", "_"); + path += "/" + safeGroupName; } - // 获取基础路径(去掉tenantId部分) - String basePath = ossInit.getMobileMd5(); - - // 构建完整路径 - StringBuilder pathBuilder = new StringBuilder(); - pathBuilder.append(basePath) - .append("/user-files/") - .append(category.toLowerCase()) - .append("/") - .append(dateStr); - - // 如果有分组名,添加到路径 - if (groupName != null && !groupName.trim().isEmpty()) { - // 对分组名进行URL安全处理 - String safeGroupName = groupName.trim() - .replaceAll("[^a-zA-Z0-9一-鿿_-]", "_"); // 保留中文、字母、数字、下划线和连字符 - pathBuilder.append("/").append(safeGroupName); - } - - return pathBuilder.toString(); + return path; } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java index 7e13823d5a..bbfd984f23 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java @@ -24,9 +24,9 @@ public class MixTaskConstants { /** * 定时任务配置 - * 改为每30秒检查一次,提供更实时的进度更新 + * 每1分钟检查一次,平衡响应速度和系统压力 */ - public static final String CRON_CHECK_STATUS = "*/30 * * * * ?"; + public static final String CRON_CHECK_STATUS = "0 */1 * * * ?"; /** * 任务状态检查优化配置 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java index 0775a174df..87f164e4c1 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java @@ -3,8 +3,11 @@ package cn.iocoder.yudao.module.tik.mix.service; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.infra.service.file.FileService; +import com.alibaba.ttl.TtlRunnable; import cn.iocoder.yudao.module.tik.mix.client.IceClient; import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants; import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO; @@ -65,15 +68,15 @@ public class MixTaskServiceImpl implements MixTaskService { // 2. 保存到数据库 mixTaskMapper.insert(task); - // 3. 异步提交到阿里云 ICE - CompletableFuture.runAsync(() -> { + // 3. 异步提交到阿里云 ICE(使用 TTL 自动传递上下文) + CompletableFuture.runAsync(TtlRunnable.get(() -> { try { submitToICE(task.getId(), createReqVO, userId); } catch (Exception e) { log.error("[MixTask][提交ICE失败] taskId={}", task.getId(), e); updateTaskError(task.getId(), "提交任务失败: " + e.getMessage()); } - }); + })); return task.getId(); } @@ -175,8 +178,8 @@ public class MixTaskServiceImpl implements MixTaskService { updateTask.setOutputUrlList(null); mixTaskMapper.updateById(updateTask); - // 3. 重新提交到ICE - CompletableFuture.runAsync(() -> { + // 3. 重新提交到ICE(使用 TTL 自动传递上下文) + CompletableFuture.runAsync(TtlRunnable.get(() -> { try { // 从 materialsJson 重建请求对象 List materials = null; @@ -207,7 +210,7 @@ public class MixTaskServiceImpl implements MixTaskService { log.error("[MixTask][重新提交失败] taskId={}", id, e); updateTaskError(id, "重新提交失败: " + e.getMessage()); } - }); + })); } @Override @@ -247,20 +250,22 @@ public class MixTaskServiceImpl implements MixTaskService { // 4. 频率优化:定时任务频率从30秒改为2分钟 LocalDateTime startTime = LocalDateTime.now().minusHours(MixTaskConstants.CHECK_HOURS_LIMIT); - // 查询运行中的任务(限制时间和数量) - List runningTasks = mixTaskMapper.selectList( - new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() - .eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_RUNNING) - .ge(MixTaskDO::getCreateTime, startTime) - .orderByDesc(MixTaskDO::getCreateTime) - .last("LIMIT " + MixTaskConstants.CHECK_BATCH_SIZE) // 限制数量 + // 查询运行中的任务(忽略租户过滤,因为定时任务没有租户上下文) + List runningTasks = TenantUtils.executeIgnore(() -> + mixTaskMapper.selectList( + new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() + .eq(MixTaskDO::getStatus, MixTaskConstants.STATUS_RUNNING) + .ge(MixTaskDO::getCreateTime, startTime) + .orderByDesc(MixTaskDO::getCreateTime) + .last("LIMIT " + MixTaskConstants.CHECK_BATCH_SIZE) + ) ); if (runningTasks.isEmpty()) { return; } - // 逐个检查任务状态 + // 逐个检查任务状态(每个任务使用自己的租户上下文) int failureCount = 0; for (MixTaskDO task : runningTasks) { try { @@ -268,7 +273,8 @@ public class MixTaskServiceImpl implements MixTaskService { if (jobIds != null && !jobIds.isEmpty()) { // 每个任务可能有多个jobId,取第一个进行检查 String jobId = jobIds.get(0); - syncTaskStatus(task.getId(), jobId); + // 使用任务的租户ID执行状态同步 + TenantUtils.execute(task.getTenantId(), () -> syncTaskStatus(task.getId(), jobId)); } } catch (Exception e) { log.error("[MixTask][单个任务检查失败] taskId={}", task.getId(), e);