From efff642504747329437221bcb47daa0fa6347f2c Mon Sep 17 00:00:00 2001
From: glx <783262171@qq.com>
Date: Tue, 2 Jun 2026 17:57:47 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=80=9A=E7=94=A8=E5=AE=A1?=
=?UTF-8?q?=E6=A0=B8=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../boot/admin/constant/AuditConstants.java | 28 +-
.../boot/admin/job/AuditTimeoutJob.java | 75 ++++
.../boot/admin/job/VideoAuditPollJob.java | 36 ++
.../admin/model/entity/MiniContentAudit.java | 2 +-
.../model/entity/MiniContentAuditConfig.java | 2 +-
.../model/entity/MiniContentAuditTask.java | 2 +-
.../admin/service/AuditExecutorService.java | 7 +
.../service/ContentAuditTaskService.java | 3 +
.../impl/AuditExecutorServiceImpl.java | 392 +++++++++++++-----
.../impl/ContentAuditTaskServiceImpl.java | 15 +-
.../service/impl/UserPostServiceImpl.java | 90 +++-
src/main/resources/application-dev.yml | 1 +
12 files changed, 527 insertions(+), 126 deletions(-)
create mode 100644 src/main/java/com/youlai/boot/admin/job/AuditTimeoutJob.java
create mode 100644 src/main/java/com/youlai/boot/admin/job/VideoAuditPollJob.java
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