feat: 功能优化
This commit is contained in:
134
.claude/commands/plan.md
Normal file
134
.claude/commands/plan.md
Normal 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. 批准后开始实施
|
||||||
|
```
|
||||||
@@ -498,7 +498,7 @@ const startPollingTask = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('polling error:', error)
|
console.error('polling error:', error)
|
||||||
}
|
}
|
||||||
}, 2000) // 每2秒轮询一次
|
}, 10000) // 每2秒轮询一次
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消任务
|
// 取消任务
|
||||||
@@ -868,7 +868,7 @@ let previewObjectUrl = ''
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 语音合成 -->
|
<!-- 语音合成 -->
|
||||||
<div class="tts-actions">
|
<!-- <div class="tts-actions">
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -899,7 +899,7 @@ let previewObjectUrl = ''
|
|||||||
<div v-else class="synth-audio-hint">
|
<div v-else class="synth-audio-hint">
|
||||||
先生成语音,再上传视频,即可开始混剪
|
先生成语音,再上传视频,即可开始混剪
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- 视频上传 -->
|
<!-- 视频上传 -->
|
||||||
<div class="video-section">
|
<div class="video-section">
|
||||||
@@ -953,24 +953,6 @@ let previewObjectUrl = ''
|
|||||||
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
|
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<a-progress
|
<a-progress
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
* Redis缓存键前缀
|
* Redis缓存键前缀
|
||||||
*/
|
*/
|
||||||
private static final String REDIS_TASK_RESULT_PREFIX = "digital_human:task:result:";
|
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小时)
|
* 缓存过期时间(24小时)
|
||||||
@@ -429,7 +428,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNTHESIZE_VOICE, "语音合成完成");
|
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNTHESIZE_VOICE, "语音合成完成");
|
||||||
|
|
||||||
// 步骤3:口型同步(异步提交,不等待完成)
|
// 步骤3:口型同步(异步提交,不等待完成)
|
||||||
String syncedVideoUrl = syncLip(task, audioUrl);
|
syncLip(task, audioUrl);
|
||||||
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNC_LIP, "口型同步任务已提交,等待处理");
|
updateTaskProgress(taskId, DigitalHumanTaskStepEnum.SYNC_LIP, "口型同步任务已提交,等待处理");
|
||||||
|
|
||||||
// Latentsync是异步处理,这里不调用generateVideo
|
// Latentsync是异步处理,这里不调用generateVideo
|
||||||
@@ -584,21 +583,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
|||||||
return task.getVideoUrl();
|
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);
|
taskMapper.updateById(updateObj);
|
||||||
|
|
||||||
// 缓存状态(支持增量轮询)
|
|
||||||
cacheTaskStatus(taskId, status, progress);
|
|
||||||
|
|
||||||
log.info("[updateTaskStatus][任务({})状态更新: status={}, progress={}%]", 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);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import cn.hutool.core.util.StrUtil;
|
|||||||
import cn.hutool.http.HttpRequest;
|
import cn.hutool.http.HttpRequest;
|
||||||
import cn.hutool.http.HttpResponse;
|
import cn.hutool.http.HttpResponse;
|
||||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
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.file.service.TikOssInitService;
|
||||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
||||||
@@ -24,12 +26,14 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Latentsync任务轮询服务 - 轻量化异步处理
|
* Latentsync任务轮询服务 - 轻量化异步处理
|
||||||
|
* 使用@TenantIgnore忽略租户检查,因为轮询服务没有用户上下文
|
||||||
*
|
*
|
||||||
* @author 芋道源码
|
* @author 芋道源码
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@TenantIgnore
|
||||||
public class LatentsyncPollingService {
|
public class LatentsyncPollingService {
|
||||||
|
|
||||||
private final TikDigitalHumanTaskMapper taskMapper;
|
private final TikDigitalHumanTaskMapper taskMapper;
|
||||||
@@ -126,10 +130,15 @@ public class LatentsyncPollingService {
|
|||||||
* 单个任务轮询
|
* 单个任务轮询
|
||||||
*/
|
*/
|
||||||
private void pollSingleTask(Long taskId) {
|
private void pollSingleTask(Long taskId) {
|
||||||
|
// 获取任务的requestId和轮询次数(在try块外声明,供catch块使用)
|
||||||
|
String requestId = null;
|
||||||
|
String countKey = null;
|
||||||
|
int currentCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取任务的requestId
|
// 获取任务的requestId
|
||||||
String taskKey = REDIS_POLLING_PREFIX + "task_" + taskId;
|
String taskKey = REDIS_POLLING_PREFIX + "task_" + taskId;
|
||||||
String requestId = stringRedisTemplate.opsForValue().get(taskKey);
|
requestId = stringRedisTemplate.opsForValue().get(taskKey);
|
||||||
|
|
||||||
if (StrUtil.isBlank(requestId)) {
|
if (StrUtil.isBlank(requestId)) {
|
||||||
// 如果没有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);
|
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) {
|
if (currentCount >= MAX_POLLING_COUNT) {
|
||||||
// 超时,标记任务失败
|
// 超时,标记任务失败
|
||||||
@@ -169,7 +178,23 @@ public class LatentsyncPollingService {
|
|||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[pollSingleTask][轮询任务异常][taskId={}]", taskId, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user