diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md
new file mode 100644
index 0000000000..faba5139d1
--- /dev/null
+++ b/.claude/commands/plan.md
@@ -0,0 +1,134 @@
+## 计划模式
+
+启动实施前的计划制定模式,制定详细的实施策略。通过在代码实施前制定结构化计划,支持高效开发。
+
+### 使用方法
+
+```bash
+# 请求 Claude 进入 Plan Mode
+"制定 [实施内容] 的实施计划"
+```
+
+### 基本示例
+
+```bash
+# 新功能的实施计划
+"制定用户认证功能的实施计划"
+
+# 系统设计计划
+"制定微服务拆分的实施计划"
+
+# 重构计划
+"制定遗留代码的重构计划"
+```
+
+### 与 Claude 的协作
+
+```bash
+# 复杂功能实施
+"制定聊天功能的实施计划。包括 WebSocket、实时通知、历史管理"
+
+# 数据库设计
+"制定电商网站的数据库设计计划。包括商品、订单、用户管理"
+
+# API 设计
+"制定 GraphQL API 的实施计划。包括认证、缓存、速率限制"
+
+# 基础设施设计
+"制定 Docker 化的实施计划。包括开发环境、生产环境、CI/CD"
+```
+
+### Plan Mode 的特点
+
+**自动启动**
+
+- 检测到实施任务时自动启动 Plan Mode
+- 可通过"制定实施计划"等关键词明确启动
+
+**结构化规格书**
+
+- 需求定义 (用户故事·验收标准)
+- 设计书 (架构·数据设计·UI 设计)
+- 实施计划 (任务分解·进度跟踪·质量保证)
+- 风险分析与对策
+
+**审批流程**
+
+- 通过 `exit_plan_mode` 工具提交计划
+- **重要**: 无论工具返回值如何,必须等待用户的明确批准
+- 禁止未经批准就开始实施
+- 可以修改·调整计划
+- 仅在批准后才开始使用 TodoWrite 进行任务管理
+
+### 详细示例
+
+```bash
+# 复杂系统实施
+"制定在线支付系统的实施计划。包括 Stripe 集成、安全性、错误处理"
+
+# 前端实施
+"制定 React 仪表板的实施计划。包括状态管理、组件设计、测试"
+
+# 后端实施
+"制定 RESTful API 的实施计划。包括认证、验证、日志记录"
+
+# DevOps 实施
+"制定 CI/CD 管道的实施计划。包括测试自动化、部署、监控"
+```
+
+### 3 阶段工作流程
+
+#### 阶段 1: Requirements(需求定义)
+
+- **用户故事**: 明确功能的目的和价值
+- **验收标准**: 定义完成条件和质量标准
+- **约束·前提条件**: 整理技术·时间约束
+- **优先级排序**: Must-have/Nice-to-have 分类
+
+#### 阶段 2: Design(设计)
+
+- **架构设计**: 系统构成和技术选型
+- **数据设计**: 模式、API 规格、数据流
+- **UI/UX 设计**: 界面构成和操作流程
+- **风险分析**: 潜在问题和对策
+
+#### 阶段 3: Implementation(实施)
+
+- **任务分解**: 细分为可实施的单位
+- **进度跟踪**: 通过 TodoWrite 进行状态管理
+- **质量保证**: 测试策略和验证方法
+- **审批流程**: 通过 exit_plan_mode 提交计划并等待明确批准
+
+### 注意事项
+
+**适用范围**
+
+- Plan Mode 最适合复杂的实施任务
+- 简单修改或小规模变更使用常规实施形式
+- 推荐用于 3 步以上的工作或新功能开发
+
+**技术约束**
+
+- 不要信任 `exit_plan_mode` 工具的返回值
+- 审批流程通过用户的明确意思表示判断
+- 与 CLI 的 plan mode 是不同的功能
+
+**执行注意事项**
+
+- 严禁在批准前开始实施
+- 提交计划后必须等待用户响应
+- 出错时提供替代方案
+
+### 执行示例
+
+```bash
+# 使用示例
+"制定用户管理系统的实施计划"
+
+# 预期行为
+# 1. Plan Mode 自动启动
+# 2. 需求分析和技术选型
+# 3. 实施步骤的结构化
+# 4. 通过 exit_plan_mode 提交计划
+# 5. 批准后开始实施
+```
\ No newline at end of file
diff --git a/frontend/app/web-gold/src/views/dh/Video.vue b/frontend/app/web-gold/src/views/dh/Video.vue
index 11c1f011ff..872c9c3326 100644
--- a/frontend/app/web-gold/src/views/dh/Video.vue
+++ b/frontend/app/web-gold/src/views/dh/Video.vue
@@ -498,7 +498,7 @@ const startPollingTask = () => {
} catch (error) {
console.error('polling error:', error)
}
- }, 2000) // 每2秒轮询一次
+ }, 10000) // 每2秒轮询一次
}
// 取消任务
@@ -868,7 +868,7 @@ let previewObjectUrl = ''
-
+
@@ -953,24 +953,6 @@ let previewObjectUrl = ''
{{ getStepText(currentTaskStep) }}
-
50) {
- log.warn("[saveVideoToOss][任务({})视频文件较大][size={}MB]", task.getId(), sizeMB);
- }
-
- // 3. 获取OSS目录路径
- Long userId = task.getUserId();
- String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, "generate");
-
- // 4. 生成文件名
- String fileName = String.format("task_%d_%d.mp4", task.getId(), System.currentTimeMillis());
-
- // 5. 保存到OSS
- String ossUrl = fileApi.createFile(videoBytes, fileName, baseDirectory, "video/mp4");
-
- // 6. 移除预签名URL中的签名参数,获取基础URL
- String cleanOssUrl = HttpUtils.removeUrlQuery(ossUrl);
-
- // 7. 缓存任务结果(快速回显)
- cacheTaskResult(task.getId(), cleanOssUrl);
-
- log.info("[saveVideoToOss][任务({})视频保存到OSS完成][directory={}, fileName={}, ossUrl={}]",
- task.getId(), baseDirectory, fileName, cleanOssUrl);
- return cleanOssUrl;
-
- } catch (Exception e) {
- log.error("[saveVideoToOss][任务({})保存视频到OSS失败][remoteUrl={}]", task.getId(), remoteVideoUrl, e);
- // 如果保存失败,返回原始URL(降级处理)
- return remoteVideoUrl;
- }
- }
-
- /**
- * 下载远程文件 - 内存优化
- */
- private byte[] downloadRemoteFile(String remoteUrl) throws Exception {
- log.info("[downloadRemoteFile][下载文件][url={}]", remoteUrl);
-
- try (HttpResponse response = HttpRequest.get(remoteUrl)
- .execute()) {
-
- if (!response.isOk()) {
- throw new Exception("下载文件失败: HTTP " + response.getStatus());
- }
-
- // 流式读取:分块处理避免大文件OOM
- byte[] bytes = response.bodyBytes();
- int sizeMB = bytes.length / 1024 / 1024;
- log.info("[downloadRemoteFile][文件下载完成][size={} bytes, {}MB]", bytes.length, sizeMB);
- return bytes;
- }
- }
-
/**
* 缓存任务结果 - 支持快速回显
*/
@@ -758,31 +675,4 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
}
}
- /**
- * 缓存任务状态 - 支持增量轮询
- */
- private void cacheTaskStatus(Long taskId, String status, Integer progress) {
- try {
- String key = REDIS_TASK_STATUS_PREFIX + taskId;
- String value = status + ":" + progress + ":" + System.currentTimeMillis();
- stringRedisTemplate.opsForValue().set(key, value, CACHE_EXPIRE_TIME);
- log.debug("[cacheTaskStatus][任务({})状态已缓存][status={}]", taskId, status);
- } catch (Exception e) {
- log.warn("[cacheTaskStatus][任务({})缓存状态失败]", taskId, e);
- }
- }
-
- /**
- * 获取缓存的任务状态
- */
- private String getCachedTaskStatus(Long taskId) {
- try {
- String key = REDIS_TASK_STATUS_PREFIX + taskId;
- return stringRedisTemplate.opsForValue().get(key);
- } catch (Exception e) {
- log.warn("[getCachedTaskStatus][任务({})获取缓存状态失败]", taskId, e);
- return null;
- }
- }
-
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
index fb96f026d6..ac77294b20 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java
@@ -4,6 +4,8 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
+import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
@@ -24,12 +26,14 @@ import java.util.concurrent.TimeUnit;
/**
* Latentsync任务轮询服务 - 轻量化异步处理
+ * 使用@TenantIgnore忽略租户检查,因为轮询服务没有用户上下文
*
* @author 芋道源码
*/
@Slf4j
@Service
@RequiredArgsConstructor
+@TenantIgnore
public class LatentsyncPollingService {
private final TikDigitalHumanTaskMapper taskMapper;
@@ -126,10 +130,15 @@ public class LatentsyncPollingService {
* 单个任务轮询
*/
private void pollSingleTask(Long taskId) {
+ // 获取任务的requestId和轮询次数(在try块外声明,供catch块使用)
+ String requestId = null;
+ String countKey = null;
+ int currentCount = 0;
+
try {
// 获取任务的requestId
String taskKey = REDIS_POLLING_PREFIX + "task_" + taskId;
- String requestId = stringRedisTemplate.opsForValue().get(taskKey);
+ requestId = stringRedisTemplate.opsForValue().get(taskKey);
if (StrUtil.isBlank(requestId)) {
// 如果没有requestId,说明任务可能已完成或已取消,从轮询队列中移除
@@ -138,9 +147,9 @@ public class LatentsyncPollingService {
}
// 检查轮询次数
- String countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
+ countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
String countStr = stringRedisTemplate.opsForValue().get(countKey);
- int currentCount = countStr != null ? Integer.parseInt(countStr) : 0;
+ currentCount = countStr != null ? Integer.parseInt(countStr) : 0;
if (currentCount >= MAX_POLLING_COUNT) {
// 超时,标记任务失败
@@ -169,7 +178,23 @@ public class LatentsyncPollingService {
}
} catch (Exception e) {
log.error("[pollSingleTask][轮询任务异常][taskId={}]", taskId, e);
- // 轮询异常不直接标记失败,避免误判
+
+ // 轮询异常处理:增加轮询次数,避免无限重试
+ int errorCount = currentCount + 1;
+ if (errorCount >= MAX_POLLING_COUNT) {
+ // 达到最大次数,标记任务失败
+ log.warn("[pollSingleTask][任务轮询异常次数过多,标记失败][taskId={}, count={}]", taskId, errorCount);
+ if (requestId != null && StrUtil.isNotBlank(requestId)) {
+ markTaskFailed(taskId, "Latentsync API调用异常:" + e.getMessage());
+ removeFromPollingQueue(taskId, requestId);
+ }
+ } else {
+ // 更新轮询次数,继续重试
+ if (countKey != null) {
+ stringRedisTemplate.opsForValue().set(countKey, String.valueOf(errorCount), CACHE_EXPIRE_TIME);
+ log.debug("[pollSingleTask][轮询异常,增加次数后继续重试][taskId={}, count={}]", taskId, errorCount);
+ }
+ }
}
}