diff --git a/src/main/java/com/youlai/boot/admin/constant/AuditConstants.java b/src/main/java/com/youlai/boot/admin/constant/AuditConstants.java index a57f5af..df22024 100644 --- a/src/main/java/com/youlai/boot/admin/constant/AuditConstants.java +++ b/src/main/java/com/youlai/boot/admin/constant/AuditConstants.java @@ -23,18 +23,32 @@ public final class AuditConstants { /** 任务状态: 机审成功 */ public static final String TASK_SUCCESS = "success"; /** 任务状态: 转人工 */ - public static final String TASK_TO_MANUAL = "to_manual"; + public static final String TASK_TO_MANUAL = "manual"; /** 任务结果: 通过 */ public static final String RESULT_PASSED = "passed"; /** 任务结果: 未通过 */ public static final String RESULT_FAILED = "failed"; - /** 机审建议: 通过 */ - public static final String SUGGESTION_PASS = "pass"; - /** 机审建议: 复审 */ - public static final String SUGGESTION_REVIEW = "review"; - /** 机审建议: 违规 */ - public static final String SUGGESTION_BLOCK = "block"; + /** 风险等级: 无风险 */ + public static final String RISK_NONE = "none"; + /** 风险等级: 中等风险 */ + public static final String RISK_MEDIUM = "medium"; + /** 风险等级: 高风险 */ + public static final String RISK_HIGH = "high"; + + /** 审核策略: 机审自决 */ + public static final String STRATEGY_AUTO = "auto"; + /** 审核策略: 均衡 */ + public static final String STRATEGY_NORMAL = "normal"; + /** 审核策略: 保守 */ + public static final String STRATEGY_CAUTIOUS = "cautious"; + + /** 审核类型: 机器审核(AI 自行裁决) */ + public static final String AUDIT_TYPE_MACHINE = "machine"; + /** 审核类型: 人工审核(不调 AI,直接转人工) */ + public static final String AUDIT_TYPE_MANUAL = "manual"; + /** 审核类型: 混合审核(AI 给出风险提示,交由人工判定) */ + public static final String AUDIT_TYPE_MIXED = "mixed"; } diff --git a/src/main/java/com/youlai/boot/admin/job/AuditTimeoutJob.java b/src/main/java/com/youlai/boot/admin/job/AuditTimeoutJob.java new file mode 100644 index 0000000..31eea2e --- /dev/null +++ b/src/main/java/com/youlai/boot/admin/job/AuditTimeoutJob.java @@ -0,0 +1,75 @@ +package com.youlai.boot.admin.job; + +import com.youlai.boot.admin.constant.AuditConstants; +import com.youlai.boot.admin.model.entity.MiniContentAudit; +import com.youlai.boot.admin.model.entity.MiniContentAuditTask; +import com.youlai.boot.admin.service.ContentAuditService; +import com.youlai.boot.admin.service.ContentAuditTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 审核超时扫描定时任务 + *

+ * 扫描长时间停留在 reviewing 状态的审核记录,自动转为人工审核。 + * 主要覆盖场景: + * - 机审 API 部分任务调用失败,导致 audit 汇总卡在 reviewing + * - 视频异步审核长时间未回调 + * - 其他异常导致的审核流程中断 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class AuditTimeoutJob { + + private final ContentAuditService contentAuditService; + private final ContentAuditTaskService contentAuditTaskService; + + @Value("${audit.timeout-minutes:30}") + private int timeoutMinutes; + + /** + * 每 5 分钟扫描一次超时的审核记录 + */ + @Scheduled(cron = "0 */5 * * * ?") + public void handleTimeoutAudits() { + long thresholdTimestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(timeoutMinutes); + + List stuckAudits = contentAuditService.lambdaQuery() + .eq(MiniContentAudit::getStatus, AuditConstants.AUDIT_REVIEWING) + .lt(MiniContentAudit::getCreateTimestamp, thresholdTimestamp) + .list(); + + if (stuckAudits.isEmpty()) { + return; + } + + log.info("扫描到 {} 条超时审核记录,开始转为人工审核", stuckAudits.size()); + + for (MiniContentAudit audit : stuckAudits) { + try { + // 更新 status 为 manual_review,final_result 留空等待人工判定 + contentAuditService.updateAuditStatus(audit.getId(), + AuditConstants.AUDIT_MANUAL_REVIEW, null); + + // 将关联的 reviewing 状态任务统一更新为 to_manual + contentAuditTaskService.lambdaUpdate() + .eq(MiniContentAuditTask::getContentAuditId, audit.getId()) + .eq(MiniContentAuditTask::getStatus, AuditConstants.TASK_REVIEWING) + .set(MiniContentAuditTask::getStatus, AuditConstants.TASK_TO_MANUAL) + .update(); + + log.info("超时审核已转人工, auditId={}, moduleCode={}, bizId={}", + audit.getId(), audit.getModuleCode(), audit.getBizId()); + } catch (Exception e) { + log.error("超时审核转人工失败, auditId={}", audit.getId(), e); + } + } + } +} diff --git a/src/main/java/com/youlai/boot/admin/job/VideoAuditPollJob.java b/src/main/java/com/youlai/boot/admin/job/VideoAuditPollJob.java new file mode 100644 index 0000000..67f1961 --- /dev/null +++ b/src/main/java/com/youlai/boot/admin/job/VideoAuditPollJob.java @@ -0,0 +1,36 @@ +package com.youlai.boot.admin.job; + +import com.youlai.boot.admin.service.AuditExecutorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 视频审核异步结果轮询任务 + *

+ * 视频审核为异步模式,提交后阿里云返回 taskId, + * 需定时轮询 {@code videoModerationResult} 查询审核结果并回填。 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class VideoAuditPollJob { + + private final AuditExecutorService auditExecutorService; + + /** + * 每 30 秒轮询一次待处理的视频审核异步结果 + */ + @Scheduled(fixedDelay = 30000) + public void pollVideoAuditResult() { + try { + int count = auditExecutorService.pollVideoAuditResults(); + if (count > 0) { + log.info("视频审核轮询完成, 本次处理 {} 条", count); + } + } catch (Exception e) { + log.error("视频审核轮询任务异常", e); + } + } +} diff --git a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAudit.java b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAudit.java index b9fca9b..2061d1a 100644 --- a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAudit.java +++ b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAudit.java @@ -45,7 +45,7 @@ public class MiniContentAudit implements Serializable { private String status; @TableField("final_result") - @Schema(description = "最终结果") + @Schema(description = "最终结果:passed通过 / failed未通过") private String finalResult; @TableField("operator") diff --git a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditConfig.java b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditConfig.java index b9fca45..5241cb6 100644 --- a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditConfig.java +++ b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditConfig.java @@ -41,7 +41,7 @@ public class MiniContentAuditConfig implements Serializable { private String auditType; @TableField("risk_strategy") - @Schema(description = "机器审核风险策略:none无,medium中等,high高") + @Schema(description = "机审风险策略:auto--none转passed,medium转failed, high转failed;normal--none转passed,medium转to_manual, high转failed;cautious--none转passed,medium转to_manual, high转to_manual;") private String riskStrategy; @TableField("create_time") diff --git a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditTask.java b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditTask.java index 93b6edb..503e822 100644 --- a/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditTask.java +++ b/src/main/java/com/youlai/boot/admin/model/entity/MiniContentAuditTask.java @@ -45,7 +45,7 @@ public class MiniContentAuditTask implements Serializable { private String auditType; @TableField("status") - @Schema(description = "审核状态:reviewing审核中 / success成功 / to_manual转手动") + @Schema(description = "审核状态:reviewing审核中 / success成功 /manual手动") private String status; @TableField("risk_level") diff --git a/src/main/java/com/youlai/boot/admin/service/AuditExecutorService.java b/src/main/java/com/youlai/boot/admin/service/AuditExecutorService.java index cf97f3a..28ab9e7 100644 --- a/src/main/java/com/youlai/boot/admin/service/AuditExecutorService.java +++ b/src/main/java/com/youlai/boot/admin/service/AuditExecutorService.java @@ -16,4 +16,11 @@ public interface AuditExecutorService { */ Map executeAudit(String moduleCode, String bizId, AuditContentDTO content); + /** + * 轮询所有待处理的视频审核异步结果,更新任务和汇总状态 + * + * @return 本次处理的视频任务数量 + */ + int pollVideoAuditResults(); + } diff --git a/src/main/java/com/youlai/boot/admin/service/ContentAuditTaskService.java b/src/main/java/com/youlai/boot/admin/service/ContentAuditTaskService.java index a5139c8..576ea28 100644 --- a/src/main/java/com/youlai/boot/admin/service/ContentAuditTaskService.java +++ b/src/main/java/com/youlai/boot/admin/service/ContentAuditTaskService.java @@ -16,4 +16,7 @@ public interface ContentAuditTaskService extends IService /** 查询某个审核汇总下的所有任务明细 */ List listTasksByAuditId(Long auditId); + /** 查询待轮询的视频审核任务(contentType=video, status=reviewing, taskId不为空) */ + List getPendingVideoTasks(); + } diff --git a/src/main/java/com/youlai/boot/admin/service/impl/AuditExecutorServiceImpl.java b/src/main/java/com/youlai/boot/admin/service/impl/AuditExecutorServiceImpl.java index 055bd3e..6dfe641 100644 --- a/src/main/java/com/youlai/boot/admin/service/impl/AuditExecutorServiceImpl.java +++ b/src/main/java/com/youlai/boot/admin/service/impl/AuditExecutorServiceImpl.java @@ -1,9 +1,11 @@ package com.youlai.boot.admin.service.impl; import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; import com.aliyun.green20220302.models.ImageModerationResponse; import com.aliyun.green20220302.models.TextModerationResponse; import com.aliyun.green20220302.models.VideoModerationResponse; +import com.aliyun.green20220302.models.VideoModerationResultResponse; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.youlai.boot.admin.constant.AuditConstants; import com.youlai.boot.admin.model.dto.AuditContentDTO; @@ -48,44 +50,104 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { // 1) 查询对应模块的审核配置 MiniContentAuditConfig config = findAuditConfig(moduleCode); if (config == null || isAuditDisabled(config)) { - // 无配置 或 审核开关已关闭 → 跳过审核,业务层直接放行 return null; } - // 2) 创建审核汇总记录(MiniContentAudit 表) - MiniContentAudit audit = contentAuditService.createAudit(moduleCode, bizId, config.getAuditType()); + String auditType = config.getAuditType(); + + // 2) 创建审核汇总记录 + MiniContentAudit audit = contentAuditService.createAudit(moduleCode, bizId, auditType); Long auditId = audit.getId(); - // 风险策略取自配置,默认为 medium - String riskStrategy = config.getRiskStrategy() != null ? config.getRiskStrategy() : "medium"; - // 3) 拆解待审核内容,批量创建审核任务明细(MiniContentAuditTask 表) - contentAuditTaskService.batchCreateTasks(auditId, config.getAuditType(), + // 3) 拆解待审核内容,批量创建审核任务明细 + contentAuditTaskService.batchCreateTasks(auditId, auditType, content.getTexts(), content.getImages(), content.getVideos()); - // 4) 读取已创建的所有任务,逐一调用阿里云内容审核API + // 4) 根据审核类型执行不同策略 + if (AuditConstants.AUDIT_TYPE_MANUAL.equals(auditType)) { + return executeManualAudit(auditId); + } + if (AuditConstants.AUDIT_TYPE_MIXED.equals(auditType)) { + return executeMixedAudit(auditId); + } + + // machine(默认):调 AI 并按策略裁决 + String strictness = config.getRiskStrategy() != null ? config.getRiskStrategy() : AuditConstants.STRATEGY_NORMAL; List tasks = contentAuditTaskService.listTasksByAuditId(auditId); for (MiniContentAuditTask task : tasks) { - executeSingleTaskAudit(task, riskStrategy); + executeSingleTaskAudit(task, strictness); } - - // 5) 重新加载任务(机审结果已回填),汇总判定 audit 的最终状态 List updatedTasks = contentAuditTaskService.listTasksByAuditId(auditId); return aggregateTaskResultsAndUpdateAudit(auditId, updatedTasks); } - // ================================================================ - // 配置查询 - // ================================================================ + /** + * manual 审核:不调 AI,所有任务直接标记为 manual,audit 状态设为 manual_review + */ + private Map executeManualAudit(Long auditId) { + contentAuditTaskService.lambdaUpdate() + .eq(MiniContentAuditTask::getContentAuditId, auditId) + .set(MiniContentAuditTask::getStatus, AuditConstants.TASK_TO_MANUAL) + .update(); + contentAuditService.updateAuditStatus(auditId, AuditConstants.AUDIT_MANUAL_REVIEW, null); + Map result = new HashMap<>(); + result.put("status", AuditConstants.AUDIT_MANUAL_REVIEW); + result.put("auditId", auditId); + return result; + } + + /** + * mixed 审核:调 AI 仅记录 riskLevel + machineResult,不应用策略裁决,交由人工判定 + */ + private Map executeMixedAudit(Long auditId) { + List tasks = contentAuditTaskService.listTasksByAuditId(auditId); + for (MiniContentAuditTask task : tasks) { + try { + recordMachineResultOnly(task); + } catch (Exception e) { + log.error("mixed审核AI分析失败, taskId={}", task.getId(), e); + } + } + contentAuditService.updateAuditStatus(auditId, AuditConstants.AUDIT_MANUAL_REVIEW, null); + Map result = new HashMap<>(); + result.put("status", AuditConstants.AUDIT_MANUAL_REVIEW); + result.put("auditId", auditId); + return result; + } + + /** + * 仅记录机审结果,不判定(mixed 模式用) + */ + private void recordMachineResultOnly(MiniContentAuditTask task) { + String contentType = task.getContentType(); + String contentValue = task.getContentValue(); + + switch (contentType) { + case "text" -> { + TextModerationResponse r = aliyunContentAuditUtil.textModeration(contentValue); + String riskLevel = extractRiskLevelFromResponse(r); + contentAuditTaskService.updateTaskMachineResult( + task.getId(), JSON.toJSONString(r), riskLevel, null, null); + } + case "image" -> { + ImageModerationResponse r = aliyunContentAuditUtil.imageModeration(contentValue); + String riskLevel = extractRiskLevelFromResponse(r); + contentAuditTaskService.updateTaskMachineResult( + task.getId(), JSON.toJSONString(r), riskLevel, null, null); + } + case "video" -> handleVideoAudit(task, contentValue); + default -> log.warn("未知内容类型: {}", contentType); + } + } /** * 查找审核配置。 - * LambdaQueryWrapper 构建条件:module_code = ? AND deleted = false(未逻辑删除) */ private MiniContentAuditConfig findAuditConfig(String moduleCode) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(MiniContentAuditConfig::getModuleCode, moduleCode); queryWrapper.eq(MiniContentAuditConfig::getDeleted, false); - // MyBatis-Plus getOne:根据 Wrapper 条件返回单条记录,多于1条时抛异常 + queryWrapper.last("LIMIT 1"); return contentAuditConfigService.getOne(queryWrapper); } @@ -98,11 +160,8 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { return Boolean.TRUE.equals(auditEnable); } - // ================================================================ - // 单条任务机审 - // ================================================================ - /** + * 单条任务机审 * 对一条审核任务调用阿里云内容安全API,并将结果回填到任务记录。 * text/image 同步返回 suggestion,video 异步返回 taskId 后续轮询。 * 依赖方法说明: @@ -110,42 +169,43 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { * - AliyunContentAuditUtil.imageModeration(String) → 阿里云图片审核(同步) * - AliyunContentAuditUtil.videoModeration(String) → 阿里云视频审核(异步,返回taskId) */ - private void executeSingleTaskAudit(MiniContentAuditTask task, String riskStrategy) { + private void executeSingleTaskAudit(MiniContentAuditTask task, String strictness) { String contentType = task.getContentType(); String contentValue = task.getContentValue(); try { switch (contentType) { - case "text" -> handleTextAudit(task, contentValue, riskStrategy); - case "image" -> handleImageAudit(task, contentValue, riskStrategy); + case "text" -> handleTextAudit(task, contentValue, strictness); + case "image" -> handleImageAudit(task, contentValue, strictness); case "video" -> handleVideoAudit(task, contentValue); default -> log.warn("未知内容类型: {}", contentType); } } catch (Exception e) { log.error("机审调用失败, taskId={}, contentType={}", task.getId(), contentType, e); + // 机审异常时直接将任务标记为转人工,避免卡在 reviewing 状态 + contentAuditTaskService.updateTaskMachineResult( + task.getId(), null, null, null, AuditConstants.TASK_TO_MANUAL); } } /** * 文本审核(同步)。 - * TextModerationResponse.getBody().getData() 包含 suggestion,值为 pass/review/block */ - private void handleTextAudit(MiniContentAuditTask task, String textContent, String riskStrategy) { + private void handleTextAudit(MiniContentAuditTask task, String textContent, String strictness) { TextModerationResponse response = aliyunContentAuditUtil.textModeration(textContent); - String suggestion = extractSuggestionFromResponse(response); + String riskLevel = extractRiskLevelFromResponse(response); String machineResultJson = JSON.toJSONString(response); - applyAuditResultToTask(task, suggestion, machineResultJson, riskStrategy); + applyAuditResultToTask(task, riskLevel, machineResultJson, strictness); } /** * 图片审核(同步)。 - * ImageModerationResponse.getBody().getData() 包含 suggestion,值为 pass/review/block */ - private void handleImageAudit(MiniContentAuditTask task, String imageUrl, String riskStrategy) { + private void handleImageAudit(MiniContentAuditTask task, String imageUrl, String strictness) { ImageModerationResponse response = aliyunContentAuditUtil.imageModeration(imageUrl); - String suggestion = extractSuggestionFromResponse(response); + String riskLevel = extractRiskLevelFromResponse(response); String machineResultJson = JSON.toJSONString(response); - applyAuditResultToTask(task, suggestion, machineResultJson, riskStrategy); + applyAuditResultToTask(task, riskLevel, machineResultJson, strictness); } /** @@ -164,7 +224,6 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { String asyncTaskId = extractVideoTaskIdFromResponse(response); if (asyncTaskId != null) { task.setTaskId(asyncTaskId); - // MyBatis-Plus updateById:按主键id更新实体,此处只更新 task_id 字段 contentAuditTaskService.updateById(task); } } @@ -185,62 +244,53 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { } /** - * 将机审建议应用到任务记录:suggestion → risk_level → 按策略判定 result → 任务最终 status + * 将机审结果应用到任务记录:riskLevel + strictness → result → 任务最终 status */ - private void applyAuditResultToTask(MiniContentAuditTask task, String suggestion, String machineResultJson, String riskStrategy) { - if (suggestion == null) { + private void applyAuditResultToTask(MiniContentAuditTask task, String riskLevel, String machineResultJson, String strictness) { + if (riskLevel == null) { return; } - // 1. suggestion → risk_level(none/medium/high) - String riskLevel = mapSuggestionToRiskLevel(suggestion); + // 1. riskLevel + strictness → result(passed / failed) + String result = applyStrategy(riskLevel, strictness); - // 2. risk_level + 策略 → result(passed / failed / to_manual) - String result = applyRiskStrategy(suggestion, riskStrategy); - - // 3. result 决定任务最终状态 - boolean needManual = AuditConstants.TASK_TO_MANUAL.equals(result); - String taskStatus = needManual ? AuditConstants.TASK_TO_MANUAL : AuditConstants.TASK_SUCCESS; + // 2. result 决定任务最终状态 + String taskStatus = AuditConstants.TASK_SUCCESS.equals(result) + ? AuditConstants.TASK_SUCCESS : AuditConstants.TASK_TO_MANUAL; // 回填任务表:machine_result(JSON) / risk_level / result / status - contentAuditTaskService.updateTaskMachineResult( - task.getId(), machineResultJson, riskLevel, result, taskStatus); + contentAuditTaskService.updateTaskMachineResult(task.getId(), machineResultJson, riskLevel, result, taskStatus); } // ================================================================ - // 从阿里云响应中提取 suggestion + // 从阿里云响应中提取 riskLevel // ================================================================ /** - * 从阿里云审核响应中提取 suggestion 值(pass/review/block)。 - * 使用反射调用 getBody() → getData() → getSuggestion(),兼容 Text 和 Image 两种 Response。 - *

- * 如果 Data 为 List 类型,取第一个元素的 getSuggestion()。 + * 从阿里云文本/图片审核响应中提取 riskLevel(none/medium/high)。 + * 使用反射调用 getBody() → getData() → getRiskLevel()。 + * 如果 Data 为 List 类型,取第一个元素。 */ - private String extractSuggestionFromResponse(Object response) { + private String extractRiskLevelFromResponse(Object response) { if (response == null) { return null; } try { - // 调用 response.getBody() Object body = invokeGetter(response, "getBody"); if (body == null) { return null; } - // 调用 body.getData() Object data = invokeGetter(body, "getData"); if (data == null) { return null; } - // 如果 Data 是 List,取第一个元素 if (data instanceof List dataList && !dataList.isEmpty()) { data = dataList.get(0); } - // 调用 data.getSuggestion() - Object suggestion = invokeGetter(data, "getSuggestion"); - return suggestion != null ? suggestion.toString() : null; + Object riskLevel = invokeGetter(data, "getRiskLevel"); + return riskLevel != null ? riskLevel.toString() : null; } catch (Exception e) { - log.error("提取suggestion失败", e); + log.error("提取riskLevel失败", e); return null; } } @@ -258,61 +308,30 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { // ================================================================ /** - * 将阿里云机审 suggestion 映射为风险等级。 - * pass → none(无风险) / review → medium(中等风险) / block → high(高风险) - */ - private String mapSuggestionToRiskLevel(String suggestion) { - if (AuditConstants.SUGGESTION_PASS.equals(suggestion)) { - return "none"; - } - if (AuditConstants.SUGGESTION_REVIEW.equals(suggestion)) { - return "medium"; - } - if (AuditConstants.SUGGESTION_BLOCK.equals(suggestion)) { - return "high"; - } - return "none"; - } - - /** - * 根据 risk_strategy 配置,将机审 suggestion 映射为任务 result。 + * TODO 根据 strictness 策略,将 riskLevel 映射为任务 result。 *

-     *   none 策略:   pass→passed, 其余→failed       (机审直接决定,无人工环节)
-     *   medium策略:  pass→passed, review→to_manual, block→failed
-     *   high策略:    pass→passed, 其余→to_manual     (中高风险一律转人工)
+     *   auto:     NONE→passed, MEDIUM→failed,     HIGH→failed
+     *   normal:   NONE→passed, MEDIUM→manual,  HIGH→failed
+     *   cautious: NONE→passed, MEDIUM→manual,  HIGH→manual
      * 
- * - * @param suggestion 阿里云机审建议:pass / review / block - * @param strategy 审核配置中的 risk_strategy:none / medium / high - * @return passed / failed / to_manual */ - private String applyRiskStrategy(String suggestion, String strategy) { - boolean isPassSuggestion = AuditConstants.SUGGESTION_PASS.equals(suggestion); - - if ("none".equals(strategy)) { - // none策略:pass→通过,其余→不通过 - if (isPassSuggestion) { - return AuditConstants.RESULT_PASSED; - } - return AuditConstants.RESULT_FAILED; - } - - if ("high".equals(strategy)) { - // high策略:pass→通过,其余→转人工 - if (isPassSuggestion) { - return AuditConstants.RESULT_PASSED; - } - return AuditConstants.TASK_TO_MANUAL; + private String applyStrategy(String riskLevel, String strictness) { + if (AuditConstants.RISK_NONE.equals(riskLevel)) { + return AuditConstants.RESULT_PASSED; } - - // medium策略(默认):pass→通过,review→转人工,block→不通过 - if (AuditConstants.SUGGESTION_REVIEW.equals(suggestion)) { - return AuditConstants.TASK_TO_MANUAL; + if (AuditConstants.STRATEGY_CAUTIOUS.equals(strictness)) { + // cautious: 非 NONE 只更新审核状态 → 一律转人工 + return null; } - if (AuditConstants.SUGGESTION_BLOCK.equals(suggestion)) { + if (AuditConstants.STRATEGY_AUTO.equals(strictness)) { + // auto: 非 NONE 一律不通过 return AuditConstants.RESULT_FAILED; } - return AuditConstants.RESULT_PASSED; + // normal(默认):MEDIUM→ 审核状态转人工,HIGH→不通过 + if (AuditConstants.RISK_MEDIUM.equals(riskLevel)) { + return null; + } + return AuditConstants.RESULT_FAILED; } // ================================================================ @@ -331,7 +350,7 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { *

* MiniContentAudit 字段含义: * - status: reviewing(机审中) / passed(通过) / failed(不通过) / manual_review(待人工) / appealing(申诉中) - * - final_result: passed / failed / manual_review + * - final_result: passed / failed(仅终态;manual_review 时留空,等待人工判定) */ private Map aggregateTaskResultsAndUpdateAudit(Long auditId, List tasks) { boolean hasFailedTask = false; @@ -346,8 +365,7 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { allTasksPassed = false; break; // 已有 failed,无需继续遍历 } - if (AuditConstants.TASK_TO_MANUAL.equals(taskResult) - || AuditConstants.TASK_TO_MANUAL.equals(task.getStatus())) { + if (AuditConstants.TASK_TO_MANUAL.equals(task.getStatus())) { hasManualTask = true; allTasksPassed = false; } else if (!AuditConstants.RESULT_PASSED.equals(taskResult)) { @@ -373,7 +391,7 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { finalResultValue = "failed"; } else if (hasManualTask) { auditStatus = AuditConstants.AUDIT_MANUAL_REVIEW; - finalResultValue = "manual_review"; + finalResultValue = null; // 转人工不是终态,final_result 留空等待人工判定 } else if (allTasksPassed && !tasks.isEmpty()) { auditStatus = AuditConstants.AUDIT_PASSED; finalResultValue = "passed"; @@ -383,9 +401,8 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { finalResultValue = null; } - // 汇总结果已确定则更新 audit 表 - if (finalResultValue != null) { - // ContentAuditService.updateAuditStatus:按 auditId 更新 status 和 final_result + // 非 reviewing 状态下更新 audit(manual_review 虽无 finalResult 也要更新 status) + if (!AuditConstants.AUDIT_REVIEWING.equals(auditStatus)) { contentAuditService.updateAuditStatus(auditId, auditStatus, finalResultValue); } @@ -395,4 +412,153 @@ public class AuditExecutorServiceImpl implements AuditExecutorService { resultMap.put("auditId", auditId); return resultMap; } + + // ================================================================ + // 视频异步审核结果轮询 + // ================================================================ + + /** + * 轮询所有待处理的视频审核异步结果,更新任务和汇总状态。 + * 供定时任务 {@code VideoAuditPollJob} 调用。 + * + * @return 本次成功处理的视频任务数量 + */ + @Override + public int pollVideoAuditResults() { + List pendingTasks = contentAuditTaskService.getPendingVideoTasks(); + if (pendingTasks.isEmpty()) { + return 0; + } + + log.info("开始轮询视频审核任务, 总数={}", pendingTasks.size()); + Set affectedAuditIds = new HashSet<>(); + int processedCount = 0; + + for (MiniContentAuditTask task : pendingTasks) { + try { + if (processSingleVideoTask(task)) { + affectedAuditIds.add(task.getContentAuditId()); + processedCount++; + } + } catch (Exception e) { + log.error("轮询视频任务异常, taskId={}", task.getId(), e); + } + } + + // 仅 machine 类型需要重新汇总,mixed 的 audit 已为 manual_review 不参与汇总 + for (Long auditId : affectedAuditIds) { + try { + MiniContentAudit audit = contentAuditService.getById(auditId); + if (audit != null && AuditConstants.AUDIT_TYPE_MACHINE.equals(audit.getAuditType())) { + List tasks = contentAuditTaskService.listTasksByAuditId(auditId); + aggregateTaskResultsAndUpdateAudit(auditId, tasks); + } + } catch (Exception e) { + log.error("重新汇总审核失败, auditId={}", auditId, e); + } + } + + log.info("视频审核轮询完成, 已处理={}, 受影响审核={}", processedCount, affectedAuditIds.size()); + return processedCount; + } + + /** + * 处理单条视频审核任务的异步结果。返回 true 表示结果已回填完成。 + */ + private boolean processSingleVideoTask(MiniContentAuditTask task) { + String asyncTaskId = task.getTaskId(); + VideoModerationResultResponse response = aliyunContentAuditUtil.videoModerationResult(asyncTaskId); + + if (response == null || response.getBody() == null) { + log.info("视频审核结果查询返回null, taskId={}", task.getId()); + return false; + } + + Integer code = response.getBody().getCode(); + if (code == null || code != 200) { + log.info("视频审核结果查询失败, taskId={}, code={}", task.getId(), code); + return false; + } + + Object data = response.getBody().getData(); + if (data == null) { + log.info("视频审核未完成(无data), taskId={}", task.getId()); + return false; + } + + String dataJson = JSON.toJSONString(data); + if (!isVideoAuditCompleted(dataJson)) { + log.debug("视频审核仍在处理中, taskId={}", task.getId()); + return false; + } + + // 从阿里云响应中直接提取 riskLevel(none/medium/high) + String riskLevel = extractVideoRiskLevel(dataJson); + if (riskLevel == null) { + log.warn("视频审核结果中未找到riskLevel, taskId={}", task.getId()); + return false; + } + + // 查找关联的审核记录 + MiniContentAudit audit = contentAuditService.getById(task.getContentAuditId()); + if (audit == null) { + log.warn("未找到关联审核记录, taskId={}", task.getId()); + return false; + } + + // mixed 类型:记录机审结果,更新审核状态 manual,不裁决 + if (AuditConstants.AUDIT_TYPE_MIXED.equals(audit.getAuditType())) { + contentAuditTaskService.updateTaskMachineResult( + task.getId(), dataJson, riskLevel, null, "manual"); + log.info("视频任务AI分析完成(mixed), taskId={}, riskLevel={}", task.getId(), riskLevel); + return true; + } + + // machine 类型:按策略裁决 + String strictness = AuditConstants.STRATEGY_NORMAL; + MiniContentAuditConfig config = findAuditConfig(audit.getModuleCode()); + if (config != null && config.getRiskStrategy() != null) { + strictness = config.getRiskStrategy(); + } + + String result = applyStrategy(riskLevel, strictness); + String taskStatus = AuditConstants.TASK_SUCCESS.equals(result) + ? AuditConstants.TASK_SUCCESS : AuditConstants.TASK_TO_MANUAL; + + contentAuditTaskService.updateTaskMachineResult( + task.getId(), dataJson, riskLevel, result, taskStatus); + + log.info("视频任务审核完成, taskId={}, riskLevel={}, strictness={}, result={}", + task.getId(), riskLevel, strictness, result); + return true; + } + + /** + * 判断视频异步审核是否已完成。 + * 阿里云视频审核完成后 data 中会出现 RiskLevel 字段(none/medium/high), + * 未完成时该字段不存在。 + */ + private boolean isVideoAuditCompleted(String dataJson) { + try { + JSONObject json = JSON.parseObject(dataJson); + return json.containsKey("riskLevel"); + } catch (Exception e) { + log.error("解析视频审核完成状态失败", e); + return false; + } + } + + /** + * 从视频审核结果JSON中直接提取 riskLevel(none/medium/high)。 + * 阿里云视频审核完成后 data 中包含小写 riskLevel 字段。 + */ + private String extractVideoRiskLevel(String dataJson) { + try { + JSONObject json = JSON.parseObject(dataJson); + return json.getString("riskLevel"); + } catch (Exception e) { + log.error("解析视频审核riskLevel失败", e); + return null; + } + } } diff --git a/src/main/java/com/youlai/boot/admin/service/impl/ContentAuditTaskServiceImpl.java b/src/main/java/com/youlai/boot/admin/service/impl/ContentAuditTaskServiceImpl.java index 1b6c3c8..99a0cdb 100644 --- a/src/main/java/com/youlai/boot/admin/service/impl/ContentAuditTaskServiceImpl.java +++ b/src/main/java/com/youlai/boot/admin/service/impl/ContentAuditTaskServiceImpl.java @@ -50,7 +50,7 @@ public class ContentAuditTaskServiceImpl extends ServiceImpl() .eq(MiniContentAuditTask::getContentAuditId, auditId)); } + + @Override + public List getPendingVideoTasks() { + return this.list(new LambdaQueryWrapper() + .eq(MiniContentAuditTask::getContentType, "video") + .eq(MiniContentAuditTask::getStatus, AuditConstants.TASK_REVIEWING) + .isNotNull(MiniContentAuditTask::getTaskId) + .ne(MiniContentAuditTask::getTaskId, "") + .and(w -> w.isNull(MiniContentAuditTask::getMachineResult) + .or().eq(MiniContentAuditTask::getMachineResult, ""))); + } } diff --git a/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java index 48d70aa..7344c03 100644 --- a/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java +++ b/src/main/java/com/youlai/boot/mini/service/impl/UserPostServiceImpl.java @@ -9,6 +9,13 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.admin.constant.AuditConstants; +import com.youlai.boot.admin.model.dto.AuditContentDTO; +import com.youlai.boot.admin.model.entity.MiniContentAudit; +import com.youlai.boot.admin.model.entity.MiniContentAuditTask; +import com.youlai.boot.admin.service.AuditExecutorService; +import com.youlai.boot.admin.service.ContentAuditService; +import com.youlai.boot.admin.service.ContentAuditTaskService; import com.youlai.boot.common.exception.MsgException; import com.youlai.boot.common.util.FileUtils; import com.youlai.boot.common.util.JavaVCUtils; @@ -71,6 +78,9 @@ public class UserPostServiceImpl extends ServiceImpl auditResult = auditExecutorService.executeAudit("user_post", postUuid, auditContent); + if (auditResult != null) { + log.info("用户作品审核任务已创建, postUuid={}, auditResult={}", postUuid, auditResult); + } + } catch (Exception e) { + log.error("创建用户作品审核任务失败, 降级为人工审核, postUuid={}", postUuid, e); + createManualReviewFallback(postUuid, formData); + } + + return postUuid; + } + + /** + * 从表单数据构建审核内容DTO + */ + private AuditContentDTO buildAuditContent(UserPostForm formData) { + AuditContentDTO content = new AuditContentDTO(); + + List texts = new ArrayList<>(); + if (formData.getTitle() != null) { + texts.add(formData.getTitle()); + } + if (formData.getContent() != null) { + texts.add(formData.getContent()); + } + content.setTexts(texts); + + List images = new ArrayList<>(); + List videos = new ArrayList<>(); + if (formData.getMediaUrlList() != null) { + for (String url : formData.getMediaUrlList()) { + String lowerUrl = url.toLowerCase(); + if (lowerUrl.matches(".*\\.(mp4|mov|avi|wmv|flv|mkv|webm)(\\?.*)?$")) { + videos.add(url); + } else { + images.add(url); + } + } + } + content.setImages(images); + content.setVideos(videos); + + return content; + } + + /** + * 审核异常降级:直接创建人工审核记录及对应任务,确保不丢审 + */ + private void createManualReviewFallback(String postUuid, UserPostForm formData) { + try { + MiniContentAudit audit = new MiniContentAudit(); + audit.setUuid(UUID.randomUUID().toString()); + audit.setModuleCode("user_post"); + audit.setBizId(postUuid); + audit.setAuditType("manual"); // 失败时 全转人工 + audit.setStatus(AuditConstants.AUDIT_MANUAL_REVIEW); + audit.setCreateBy(SecurityUtils.getUserId()); + audit.setCreateTime(new Date()); + audit.setCreateTimestamp(System.currentTimeMillis()); + contentAuditService.save(audit); + + AuditContentDTO auditContent = buildAuditContent(formData); + contentAuditTaskService.batchCreateTasks(audit.getId(), "manual", + auditContent.getTexts(), auditContent.getImages(), auditContent.getVideos()); + + // 将任务状态统一更新为 to_manual,使人工审核员能看到 + contentAuditTaskService.lambdaUpdate() + .eq(MiniContentAuditTask::getContentAuditId, audit.getId()) + .set(MiniContentAuditTask::getStatus, AuditConstants.TASK_TO_MANUAL) + .update(); + + log.info("降级人工审核记录已创建, postUuid={}, auditId={}", postUuid, audit.getId()); + } catch (Exception fallbackEx) { + log.error("降级人工审核记录创建也失败了, postUuid={}, 作品将处于漏审状态!", postUuid, fallbackEx); + } } @Override diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c656941..f9fb69b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -245,3 +245,4 @@ audit: region-id: cn-shanghai #备用节点 green-cip.cn-beijing.aliyuncs.com endpoint: green-cip.cn-shanghai.aliyuncs.com + timeout-minutes: 30