feat: 功能优化

This commit is contained in:
2025-11-22 21:45:15 +08:00
parent 6b582ba6de
commit c35a2219c9
4 changed files with 167 additions and 136 deletions

134
.claude/commands/plan.md Normal file
View File

@@ -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. 批准后开始实施
```

View File

@@ -498,7 +498,7 @@ const startPollingTask = () => {
} catch (error) {
console.error('polling error:', error)
}
}, 2000) // 每2秒轮询一次
}, 10000) // 每2秒轮询一次
}
// 取消任务
@@ -868,7 +868,7 @@ let previewObjectUrl = ''
</div>
<!-- 语音合成 -->
<div class="tts-actions">
<!-- <div class="tts-actions">
<a-button
type="primary"
size="large"
@@ -899,7 +899,7 @@ let previewObjectUrl = ''
<div v-else class="synth-audio-hint">
先生成语音,再上传视频,即可开始混剪
</div>
</div>
</div> -->
<!-- 视频上传 -->
<div class="video-section">
@@ -953,24 +953,6 @@ let previewObjectUrl = ''
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
</div>
</div>
<div class="action-buttons">
<a-button
v-if="currentTaskStatus === 'PROCESSING'"
danger
block
@click="handleCancelTask"
>
取消任务
</a-button>
<a-button
v-if="currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'"
type="primary"
block
@click="handleRetryTask"
>
重试任务
</a-button>
</div>
</div>
<a-progress

View File

@@ -70,7 +70,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
* Redis缓存键前缀
*/
private static final String REDIS_TASK_RESULT_PREFIX = "digital_human:task:result:";
private static final String REDIS_TASK_STATUS_PREFIX = "digital_human:task:status:";
/**
* 缓存过期时间24小时
@@ -429,7 +428,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNTHESIZE_VOICE, "语音合成完成");
// 步骤3口型同步异步提交不等待完成
String syncedVideoUrl = syncLip(task, audioUrl);
syncLip(task, audioUrl);
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNC_LIP, "口型同步任务已提交,等待处理");
// Latentsync是异步处理这里不调用generateVideo
@@ -584,21 +583,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
return task.getVideoUrl();
}
/**
* 生成视频
*/
private String generateVideo(TikDigitalHumanTaskDO task, String syncedVideoUrl) throws Exception {
log.info("[generateVideo][任务({})开始生成视频]", task.getId());
// TODO: 这里可以添加视频后处理逻辑,比如添加字幕、特效等
// 保存同步后的视频到OSS
String resultVideoUrl = saveVideoToOss(task, syncedVideoUrl);
log.info("[generateVideo][任务({})视频生成完成]", task.getId());
return resultVideoUrl;
}
/**
* 更新任务状态
*/
@@ -635,9 +619,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
taskMapper.updateById(updateObj);
// 缓存状态(支持增量轮询)
cacheTaskStatus(taskId, status, progress);
log.info("[updateTaskStatus][任务({})状态更新: status={}, progress={}%]", taskId, status, progress);
}
@@ -667,70 +648,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
updateTaskStatus(taskId, "PROCESSING", step.getStep(), step.getProgress(), message, null);
}
/**
* 保存视频到OSS - 流式处理优化内存
*/
private String saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception {
log.info("[saveVideoToOss][任务({})开始下载并保存视频到OSS][remoteUrl={}]", task.getId(), remoteVideoUrl);
try {
// 1. 下载远程视频文件流式处理避免OOM
byte[] videoBytes = downloadRemoteFile(remoteVideoUrl);
// 2. 内存检查超过50MB记录警告
int sizeMB = videoBytes.length / 1024 / 1024;
if (sizeMB > 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;
}
}
}

View File

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