Vue3 + Spring Boot + 智能媒体管理:AI 图片审核平台的全栈实战

简介: UGC 平台日增 10 万张图片,5 人审核团队每天只能审 2 万张,违规漏检率 8%,审核标准因人而异——接入阿里云智能媒体管理 + 绿网内容安全后,审核速度提升 50 倍,违规漏检率从 8% 降到 0.3%,机审+人审复核流水线让审核标准统一可控。本文从内容审核五大痛点出发,详解绿网内容安全配置、OSS 事件触发 + MNS 回调、Spring Boot 后端集成(自动审核提交/回调解析/人工复审/OCR 人脸/统计报表)、Vue3 前端实战(上传组件/审核看板/审核详情/统计报表),包含自定义模型训练、视频审核等进阶方案,5 个生产踩坑案例和审核选型决策树,帮助企业构建生产级 AI 图片

摘要:UGC 平台日增 10 万张图片,5 人审核团队每天只能审 2 万张,违规漏检率 8%,审核标准因人而异——接入阿里云智能媒体管理 + 绿网内容安全后,审核速度提升 50 倍,违规漏检率从 8% 降到 0.3%,机审+人审复核流水线让审核标准统一可控。本文从内容审核五大痛点出发,详解绿网内容安全配置、OSS 事件触发 + MNS 回调、Spring Boot 后端集成(自动审核提交/回调解析/人工复审/OCR 人脸/统计报表)、Vue3 前端实战(上传组件/审核看板/审核详情/统计报表),包含自定义模型训练、视频审核等进阶方案,5 个生产踩坑案例和审核选型决策树,帮助企业构建生产级 AI 图片审核平台。

1. 场景:5 人审核团队每天只能审 2 万张图片

2025 年 Q3,我负责的一个 UGC 社区平台用户量突破 500 万,日均新增图片 10 万张。上线初期只配了 5 个审核员,每人每天极限审核 4000 张,5 人合计 2 万张——意味着每天有 8 万张图片处于"未审核"状态,违规内容在平台上裸奔。

更严峻的是数据:

指标 改造前 改造后 提升幅度
日审核量 2 万张(5 人) 100 万张(AI + 2 人复核) ⬆️ 50 倍
违规漏检率 8% 0.3% ⬇️ 96.3%
审核时效 平均 6 小时 平均 3 秒 ⬇️ 99.9%
审核成本/月 ¥4.5 万(5 人人力) ¥0.8 万(API + 2 人) ⬇️ 82%
审核一致性 因人而异 标准统一(AI + 规则) ✅ 标准化

改造方案的核心思路:阿里云绿网内容安全做机审,OSS 事件触发自动提交审核,MNS 回调接收审核结果,Spring Boot 处理业务逻辑,Vue3 构建审核看板——机审通过的直接放行,机审不确定的进人工复审队列,机审拒绝的标记违规。

AI 图片审核平台效率对比

下面从内容审核的痛点说起,逐层展开全栈实战。

2. 内容审核的 5 大痛点

很多产品经理对"内容审核"的认知停留在"加个审核按钮",实际上当图片量级突破万级/日,每个环节都暴露出致命问题。

痛点一:人工审核慢如蜗牛,内容裸奔是常态

5 个审核员每天看 2 万张图片,已经是在极限状态——每张图片平均只有 8 秒的判断时间,8 秒内要判断色情/暴恐/广告/涉政等多种违规类型。疲劳审阅到下午 3 点,漏检率直线上升。而每天还有 8 万张图片在排队等待审核,违规内容可能已经在线上曝光数小时。

痛点二:漏检率高,人工疲劳导致标准漂移

同一张"擦边"图片,上午审核可能被判为违规,下午就放行了。不同审核员的标准更是天差地别——有人严格有人宽松,导致平台调性不统一。实测漏检率 8%,意味着每 100 张违规图片有 8 张能通过审核。

痛点三:审核成本随业务量线性增长

图片量从 2 万涨到 10 万,审核员就得从 5 人扩到 25 人。人力成本线性增长,但预算不跟量级走。更要命的是审核员离职率高——每天看违规图片对心理健康影响很大,团队平均在职周期不到 6 个月。

痛点四:审核标准因人而异,无法量化

"这张图算不算低俗?"——这个问题在审核团队里每天要争论几十次。没有统一的判断标准,审核结果全凭个人经验。更麻烦的是合规要求在变化,上周还合规的内容这周可能就不行了,但审核员未必及时知晓。

痛点五:无法 7×24 小时覆盖,夜间是审核盲区

审核员也要下班,夜间和节假日的内容基本处于无人审核状态。偏偏违规内容的高发时段就在深夜——发帖者知道审核员不在,趁虚而入。凌晨 2 点到早上 8 点,平台完全处于审核真空期。

一句话总结:内容审核面临"人工慢、漏检高、成本贵、标准乱、不全天"五重困境,AI 审核是规模化内容平台的唯一出路。

3. 架构设计

3.1 整体架构

基于阿里云智能媒体管理 + 绿网内容安全的 AI 图片审核架构,核心思路是"机审为主、人审为辅、回调驱动"——OSS 上传事件触发审核,绿网执行 AI 审核后通过 MNS 回调通知结果,不确定的自动进人工复审队列。

cloud-native_mermaid_1

3.2 审核核心流程

审核流程采用"机审→判定→人审复核"三级模式,最大化 AI 效率的同时保留人工兜底:

cloud-native_mermaid_2

4. 阿里云智能媒体管理配置

4.1 绿网内容安全开通

登录阿里云控制台,搜索"内容安全"进入绿网服务。首次使用需要开通服务并完成实名认证。

为什么选绿网而不是自建审核模型:绿网基于阿里安全数十亿样本训练,覆盖色情/暴恐/涉政/广告/辱骂/违禁品/二维码/不良场景 8 大类,且持续更新模型,省去自建模型的训练成本和迭代周期。

开通步骤:

  1. 进入内容安全控制台,点击"立即开通"
  2. 选择计费方式:按量付费(适合量级不稳定)或资源包(量大更划算)
  3. 开通图片审核能力(同步检测 + 异步检测两种模式)
  4. 配置 AccessKey,建议使用 RAM 子账号,仅授予 AliyunGreenFullAccess 权限

4.2 审核规则配置

绿网支持灵活的审核规则配置,这是实际生产中最关键的一步——规则太严误杀率高,规则太松漏检率高。

审核场景配置:

# 绿网审核规则配置建议
scenes:
  - porn:        # 色情检测(必开,核心场景)
      enabled: true
      confidence_threshold: 90  # 置信度阈值,超过则判定违规

  - terrorism:   # 暴恐检测(必开,合规要求)
      enabled: true
      confidence_threshold: 85

  - ad:          # 广告检测(按需,社区平台建议开启)
      enabled: true
      confidence_threshold: 90

  - politics:    # 涉政检测(必开,合规红线)
      enabled: true
      confidence_threshold: 80

  - qrcode:      # 二维码检测(按需,防引流)
      enabled: true
      confidence_threshold: 95

  - live:        # 不良场景检测
      enabled: true
      confidence_threshold: 90

自定义词库配置:对于业务特有的违规内容(如竞品名称、特定广告语),可以创建自定义词库配合 OCR 检测使用。在内容安全控制台 → 设置 → 自定义词库中添加,支持关键词和正则两种匹配模式。

审核结果判定策略:

判定 置信度范围 处理策略
通过 0-60 直接放行
不确定 60-90 进人工复审队列
拒绝 90-100 直接拦截+通知用户

实战建议:初始阶段将"不确定"区间设宽一些(55-90),宁可多让人工复审,也不要误杀。运行 1-2 周后根据数据调整阈值。

4.3 OSS 事件触发 + MNS 回调通知

这是整个架构最优雅的部分——图片上传到 OSS 后自动触发审核,无需业务代码手动调用审核接口。

OSS 事件通知配置:

  1. 在 OSS 控制台 → Bucket → 事件通知 → 创建规则
  2. 事件类型选择 ObjectCreated:PutObject 和 ObjectCreated:PostObject
  3. 目的地选择 MNS 主题(需提前创建)
  4. 前缀过滤:images/(只审核 images 目录下的文件)
  5. 后缀过滤:.jpg,.jpeg,.png,.gif,.webp,.bmp

MNS 回调配置:

  1. 创建 MNS 主题:image-moderation-callback
  2. 创建 MNS 队列:moderation-result-queue
  3. 订阅关系:主题 → 队列
  4. 配置 HTTP 推送 endpoint:https://your-domain.com/api/moderation/callback

为什么用 MNS 而不是直接 HTTP 回调:MNS 提供消息持久化和重试机制,避免回调丢失。生产环境中,审核结果回调偶尔会因网络抖动而丢失,MNS 队列可以保证至少投递一次,回调接口做幂等即可。

4.4 自定义审核流设计

机审→人审→终审的三级审核流,是经过实战验证的最佳实践:

cloud-native_mermaid_3

实际生产中,90%+ 的图片在机审阶段就能确定结果,进人工复审的只有 5-8%,需要终审的不到 1%。这意味着 2 个审核员就能处理日增 10 万张图片的复审量。


5. 后端 Spring Boot 实战

5.1 项目结构与依赖

为什么这样分层:审核是典型的业务编排场景——OSS 上传、绿网审核、MNS 回调、人工操作各环节需要统一的状态机管理,Controller-Service-Mapper 分层能让每个环节的代码职责清晰。

moderation-platform/
├── src/main/java/com/moderation/
│   ├── controller/
│   │   ├── UploadController.java
│   │   ├── ModerationCallbackController.java
│   │   ├── ReviewController.java
│   │   ├── OcrFaceController.java
│   │   └── StatisticsController.java
│   ├── service/
│   │   ├── OssUploadService.java
│   │   ├── GreenModerationService.java
│   │   ├── CallbackProcessService.java
│   │   ├── ManualReviewService.java
│   │   ├── OcrFaceService.java
│   │   └── ModerationStatisticsService.java
│   ├── model/
│   │   ├── entity/
│   │   │   ├── ModerationRecord.java
│   │   │   └── ReviewTask.java
│   │   ├── dto/
│   │   │   ├── ModerationResult.java
│   │   │   └── ReviewAction.java
│   │   └── enums/
│   │       ├── ModerationStatus.java
│   │       └── RiskLevel.java
│   └── config/
│       ├── OssConfig.java
│       ├── GreenConfig.java
│       └── MnsConfig.java
└── src/main/resources/
    └── application.yml

核心依赖:

<!-- 阿里云 OSS SDK -->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>

<!-- 阿里云绿网内容安全 SDK -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>green20220302</artifactId>
    <version>2.19.0</version>
</dependency>

<!-- 阿里云 MNS SDK -->
<dependency>
    <groupId>com.aliyun.mns</groupId>
    <artifactId>aliyun-sdk-mns</artifactId>
    <version>1.1.9.2</version>
</dependency>

<!-- 阿里云智能媒体管理 SDK(OCR+人脸) -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>imm20200930</artifactId>
    <version>3.0.0</version>
</dependency>

配置文件:

# application.yml
aliyun:
  oss:
    endpoint: oss-cn-hangzhou.aliyuncs.com
    bucket-name: ugc-images-prod
    access-key-id: ${
   OSS_AK}
    access-key-secret: ${
   OSS_SK}
    callback-url: https://api.example.com/api/moderation/callback

  green:
    endpoint: green.cn-hangzhou.aliyuncs.com
    access-key-id: ${
   GREEN_AK}
    access-key-secret: ${
   GREEN_SK}
    scenes: porn,terrorism,ad,politics,qrcode,live
    review-threshold: 60
    block-threshold: 90

  mns:
    endpoint: https://123456789.mns.cn-hangzhou.aliyuncs.com
    topic-name: image-moderation-callback
    queue-name: moderation-result-queue
    access-key-id: ${
   MNS_AK}
    access-key-secret: ${
   MNS_SK}

  imm:
    endpoint: imm.cn-hangzhou.aliyuncs.com
    access-key-id: ${
   IMM_AK}
    access-key-secret: ${
   IMM_SK}

5.2 OSS 上传 + 审核触发

图片上传到 OSS 后自动触发绿网审核,业务代码只需负责上传和状态记录。

为什么用 STS 临时凭证而非直接使用 AK/SK:前端直传 OSS 时不能暴露主账号 AK/SK,STS 临时凭证可限制上传路径、文件大小和有效期,即使泄露影响范围也有限。

@Service
@Slf4j
public class OssUploadService {
   

    @Value("${aliyun.oss.endpoint}")
    private String endpoint;

    @Value("${aliyun.oss.bucket-name}")
    private String bucketName;

    @Autowired
    private OSS ossClient;

    @Autowired
    private GreenModerationService greenModerationService;

    @Autowired
    private ModerationRecordMapper recordMapper;

    /**
     * 生成 STS 临时上传凭证
     * 限制:只能上传到 images/ 目录,单文件最大 10MB,有效期 15 分钟
     */
    public StsTokenVO generateStsToken(Long userId) {
   
        DefaultAcsClient stsClient = new DefaultAcsClient(
            DefaultProfile.getProfile("", stsAk, stsSk));
        AssumeRoleRequest request = new AssumeRoleRequest();
        request.setRoleArn(stsRoleArn);
        request.setRoleSessionName("upload-" + userId);
        request.setDurationSeconds(900L); // 15 分钟
        // 限制上传路径和文件大小
        String policy = String.format(
            "{\"Version\":\"1\",\"Statement\":[{"
            + "\"Effect\":\"Allow\",\"Action\":[\"oss:PutObject\"],"
            + "\"Resource\":[\"acs:oss:*:*:%s/images/*\"],"
            + "\"Condition\":{\"NumericLessThan\":{\"oss:ContentLength\":10485760}}}]}"
            , bucketName);
        request.setPolicy(policy);
        AssumeRoleResponse response = stsClient.getAcsResponse(request);
        return StsTokenVO.builder()
            .accessKeyId(response.getCredentials().getAccessKeyId())
            .accessKeySecret(response.getCredentials().getAccessKeySecret())
            .securityToken(response.getCredentials().getSecurityToken())
            .expiration(response.getCredentials().getExpiration())
            .endpoint(endpoint)
            .bucket(bucketName)
            .build();
    }

    /**
     * 图片上传后提交审核
     * OSS 事件触发自动审核,此处做双保险:手动补充提交
     */
    @Async
    public void submitModerationAfterUpload(String ossKey, Long userId) {
   
        // 创建审核记录
        ModerationRecord record = new ModerationRecord();
        record.setOssKey(ossKey);
        record.setUserId(userId);
        record.setStatus(ModerationStatus.PENDING);
        record.setCreateTime(LocalDateTime.now());
        recordMapper.insert(record);

        // 提交绿网异步审核
        try {
   
            String taskId = greenModerationService.submitImageModeration(
                ossKey, record.getId());
            record.setGreenTaskId(taskId);
            record.setStatus(ModerationStatus.MACHINE_REVIEWING);
            recordMapper.updateById(record);
            log.info("图片审核已提交, recordId={}, taskId={}", record.getId(), taskId);
        } catch (Exception e) {
   
            log.error("审核提交失败, ossKey={}", ossKey, e);
            record.setStatus(ModerationStatus.SUBMIT_FAILED);
            recordMapper.updateById(record);
        }
    }
}

5.3 绿网审核提交服务

为什么用异步审核而非同步审核:同步审核单张图片需要 1-3 秒,批量上传场景下会严重阻塞。异步审核提交后秒级返回 taskId,通过 MNS 回调获取结果,更适合高吞吐场景。

@Service
@Slf4j
public class GreenModerationService {
   

    @Value("${aliyun.green.endpoint}")
    private String endpoint;

    @Value("${aliyun.green.scenes}")
    private String scenes;

    @Value("${aliyun.oss.bucket-name}")
    private String bucketName;

    private Client greenClient;

    @PostConstruct
    public void init() throws Exception {
   
        Config config = new Config()
            .setAccessKeyId(greenAk)
            .setAccessKeySecret(greenSk)
            .setEndpoint(endpoint);
        greenClient = new Client(config);
    }

    /**
     * 提交图片异步审核
     * 支持多场景同时检测,返回审核任务 ID
     */
    public String submitImageModeration(String ossKey, Long recordId) throws Exception {
   
        String imageUrl = String.format("oss://%s/%s", bucketName, ossKey);

        ImageModerationRequest request = new ImageModerationRequest()
            .setService("imageModeration")
            .setServiceParameters(
                new ImageModerationRequest.ServiceParameters()
                    .setImageUrl(imageUrl)
                    .setDataId(String.valueOf(recordId)));

        RuntimeOptions runtime = new RuntimeOptions()
            .setReadTimeout(10000)
            .setConnectTimeout(5000);

        ImageModerationResponse response = greenClient
            .imageModerationWithOptions(request, runtime);

        if (response.getBody().getCode() != 200) {
   
            throw new RuntimeException("绿网审核提交失败: "
                + response.getBody().getMessage());
        }

        return response.getBody().getData().getTaskId();
    }

    /**
     * 批量提交审核(适用于存量图片补审场景)
     * 单次最多提交 100 张,绿网会自动并发处理
     */
    public List<String> batchSubmitModeration(List<String> ossKeys) {
   
        List<String> taskIds = new ArrayList<>();
        List<List<String>> batches = Lists.partition(ossKeys, 100);

        for (List<String> batch : batches) {
   
            for (String ossKey : batch) {
   
                try {
   
                    String taskId = submitImageModeration(ossKey, null);
                    taskIds.add(taskId);
                } catch (Exception e) {
   
                    log.error("批量提交审核失败, ossKey={}", ossKey, e);
                }
            }
        }
        return taskIds;
    }
}

5.4 审核回调处理

MNS 回调是整个审核流程的核心枢纽——绿网审核完成后通过 MNS 推送结果,Spring Boot 消费消息并更新数据库状态。

为什么回调接口必须做幂等:MNS 保证"至少投递一次",网络超时可能导致同一消息被投递多次。如果回调接口不做幂等,重复处理会导致状态混乱。

@RestController
@RequestMapping("/api/moderation")
@Slf4j
public class ModerationCallbackController {
   

    @Autowired
    private CallbackProcessService callbackProcessService;

    /**
     * 绿网审核结果回调
     * MNS 推送的审核结果通过此接口接收
     * 幂等设计:基于 taskId + scene 去重
     */
    @PostMapping("/callback")
    public ResponseEntity<String> handleCallback(
            @RequestBody String callbackBody) {
   
        try {
   
            log.info("收到审核回调: {}", callbackBody);
            callbackProcessService.processCallback(callbackBody);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
   
            log.error("回调处理异常", e);
            // 返回 200 让 MNS 不再重试,业务异常自己兜底
            return ResponseEntity.ok("error_handled");
        }
    }
}
@Service
@Slf4j
public class CallbackProcessService {
   

    @Autowired
    private ModerationRecordMapper recordMapper;

    @Autowired
    private ManualReviewService manualReviewService;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Value("${aliyun.green.review-threshold}")
    private int reviewThreshold;

    @Value("${aliyun.green.block-threshold}")
    private int blockThreshold;

    /**
     * 解析并处理审核回调
     * 幂等逻辑:同一 taskId + scene 只处理一次
     */
    @Transactional
    public void processCallback(String callbackBody) {
   
        JSONObject body = JSON.parseObject(callbackBody);
        JSONArray results = body.getJSONArray("results");

        String taskId = body.getString("taskId");
        String dataId = body.getString("dataId");

        // 幂等校验:Redis SETNX 保证同一回调只处理一次
        String idempotentKey = "moderation:callback:" + taskId;
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey, "1", Duration.ofHours(24));
        if (Boolean.FALSE.equals(isNew)) {
   
            log.info("回调已处理,跳过: taskId={}", taskId);
            return;
        }

        // 查找审核记录
        ModerationRecord record = recordMapper.selectByGreenTaskId(taskId);
        if (record == null) {
   
            log.warn("未找到审核记录: taskId={}", taskId);
            return;
        }

        // 解析审核结果:取最高风险等级
        String finalLabel = "pass";
        int maxConfidence = 0;
        StringBuilder detailBuilder = new StringBuilder();

        for (int i = 0; i < results.size(); i++) {
   
            JSONObject result = results.getJSONObject(i);
            String scene = result.getString("scene");
            String suggestion = result.getString("suggestion");
            int confidence = result.getInteger("confidence");
            String label = result.getString("label");

            detailBuilder.append(String.format(
                "%s:%s(%d%%) ", scene, label, confidence));

            if ("block".equals(suggestion) && confidence > maxConfidence) {
   
                maxConfidence = confidence;
                finalLabel = label;
            } else if ("review".equals(suggestion)
                    && confidence > maxConfidence
                    && maxConfidence < blockThreshold) {
   
                maxConfidence = confidence;
                finalLabel = label;
            }
        }

        // 根据置信度判定最终状态
        ModerationStatus newStatus;
        if (maxConfidence >= blockThreshold) {
   
            newStatus = ModerationStatus.BLOCKED;
        } else if (maxConfidence >= reviewThreshold) {
   
            newStatus = ModerationStatus.REVIEW_REQUIRED;
            // 推入人工复审队列
            manualReviewService.pushToReviewQueue(record.getId());
        } else {
   
            newStatus = ModerationStatus.PASSED;
        }

        // 更新审核记录
        record.setStatus(newStatus);
        record.setGreenResult(finalLabel);
        record.setConfidence(maxConfidence);
        record.setDetail(detailBuilder.toString().trim());
        record.setCallbackTime(LocalDateTime.now());
        recordMapper.updateById(record);

        log.info("审核结果更新: recordId={}, status={}, label={}, confidence={}%",
            record.getId(), newStatus, finalLabel, maxConfidence);
    }
}

5.5 人工复审服务

机审不确定的图片自动进入人工复审队列,审核员在 Vue3 看板上操作。

为什么用 Redis Sorted Set 做复审队列:按优先级排序——涉政类最高优先级,广告类最低。同时支持按审核员领取任务,避免多人同时审同一张图。

@Service
@Slf4j
public class ManualReviewService {
   

    private static final String REVIEW_QUEUE_KEY = "moderation:review:queue";
    private static final String REVIEW_LOCK_PREFIX = "moderation:review:lock:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private ModerationRecordMapper recordMapper;

    @Autowired
    private ReviewTaskMapper reviewTaskMapper;

    /**
     * 推入人工复审队列
     * 按违规类型设置不同优先级:涉政 > 暴恐 > 色情 > 广告
     */
    public void pushToReviewQueue(Long recordId) {
   
        ModerationRecord record = recordMapper.selectById(recordId);
        if (record == null) return;

        // 优先级分数:涉政 1000,暴恐 800,色情 600,广告 400
        int priority = getPriority(record.getGreenResult());
        double score = priority + System.currentTimeMillis() / 100000.0;

        redisTemplate.opsForZSet().add(REVIEW_QUEUE_KEY,
            String.valueOf(recordId), score);

        // 创建复审任务
        ReviewTask task = new ReviewTask();
        task.setRecordId(recordId);
        task.setStatus(ReviewStatus.PENDING);
        task.setPriority(priority);
        task.setCreateTime(LocalDateTime.now());
        reviewTaskMapper.insert(task);

        log.info("推入复审队列: recordId={}, priority={}", recordId, priority);
    }

    private int getPriority(String label) {
   
        if (label == null) return 500;
        if (label.contains("politics")) return 1000;
        if (label.contains("terrorism")) return 800;
        if (label.contains("porn")) return 600;
        if (label.contains("ad")) return 400;
        return 500;
    }

    /**
     * 审核员领取复审任务
     * 使用 Redis 锁保证同一任务不会被多人同时领取
     */
    public ReviewTask claimTask(Long reviewerId) {
   
        // 从优先级最高的开始取
        Set<String> topTasks = redisTemplate.opsForZSet()
            .reverseRange(REVIEW_QUEUE_KEY, 0, 10);

        if (topTasks == null || topTasks.isEmpty()) {
   
            return null;
        }

        for (String recordIdStr : topTasks) {
   
            String lockKey = REVIEW_LOCK_PREFIX + recordIdStr;
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, String.valueOf(reviewerId),
                    Duration.ofMinutes(30));

            if (Boolean.TRUE.equals(locked)) {
   
                Long recordId = Long.valueOf(recordIdStr);
                // 从队列中移除
                redisTemplate.opsForZSet()
                    .remove(REVIEW_QUEUE_KEY, recordIdStr);

                // 更新复审任务
                ReviewTask task = reviewTaskMapper
                    .selectByRecordId(recordId);
                if (task != null) {
   
                    task.setReviewerId(reviewerId);
                    task.setStatus(ReviewStatus.IN_PROGRESS);
                    task.setClaimTime(LocalDateTime.now());
                    reviewTaskMapper.updateById(task);
                }
                return task;
            }
        }
        return null;
    }

    /**
     * 审核员提交复审结果
     */
    @Transactional
    public void submitReview(Long recordId, Long reviewerId,
            ReviewAction action, String comment) {
   
        // 更新审核记录
        ModerationRecord record = recordMapper.selectById(recordId);
        if (record == null) {
   
            throw new BusinessException("审核记录不存在");
        }

        switch (action) {
   
            case PASS:
                record.setStatus(ModerationStatus.PASSED);
                break;
            case REJECT:
                record.setStatus(ModerationStatus.BLOCKED);
                break;
            case ESCALATE:
                record.setStatus(ModerationStatus.ESCALATED);
                break;
            default:
                throw new BusinessException("无效的审核操作");
        }

        record.setReviewerId(reviewerId);
        record.setReviewComment(comment);
        record.setReviewTime(LocalDateTime.now());
        recordMapper.updateById(record);

        // 更新复审任务
        ReviewTask task = reviewTaskMapper.selectByRecordId(recordId);
        if (task != null) {
   
            task.setStatus(action == ReviewAction.ESCALATE
                ? ReviewStatus.ESCALATED : ReviewStatus.COMPLETED);
            task.setReviewAction(action.name());
            task.setComment(comment);
            task.setCompleteTime(LocalDateTime.now());
            reviewTaskMapper.updateById(task);
        }

        // 释放锁
        redisTemplate.delete(REVIEW_LOCK_PREFIX + recordId);

        log.info("复审完成: recordId={}, action={}, reviewerId={}",
            recordId, action, reviewerId);
    }
}

5.6 OCR 文字识别 + 人脸检测

图片审核不仅要看画面内容,还要识别图片中的文字(防广告/违禁语)和人脸(防涉政人物/未成年人出镜)。

为什么用智能媒体管理(IMM)而不是视觉智能:IMM 集成了 OCR、人脸检测、标签识别等多种能力,且可以直接处理 OSS 上的图片,无需下载到本地再上传,省带宽省时间。

@Service
@Slf4j
public class OcrFaceService {
   

    private com.aliyun.imm20200930.Client immClient;

    @Value("${aliyun.oss.bucket-name}")
    private String bucketName;

    @PostConstruct
    public void init() throws Exception {
   
        Config config = new Config()
            .setAccessKeyId(immAk)
            .setAccessKeySecret(immSk)
            .setEndpoint(immEndpoint);
        immClient = new com.aliyun.imm20200930.Client(config);
    }

    /**
     * OCR 文字识别
     * 直接处理 OSS 图片,返回识别到的文字内容
     */
    public OcrResult detectText(String ossKey) {
   
        try {
   
            GetImageOcrRequest request = new GetImageOcrRequest()
                .setProjectName("image-moderation")
                .setSourceURI("oss://" + bucketName + "/" + ossKey);

            GetImageOcrResponse response = immClient.getImageOcr(request);

            List<TextBlock> textBlocks = new ArrayList<>();
            if (response.getBody() != null
                    && response.getBody().getData() != null) {
   
                for (GetImageOcrResponseBody.Data.Blocks block
                        : response.getBody().getData().getBlocks()) {
   
                    textBlocks.add(TextBlock.builder()
                        .text(block.getText())
                        .confidence(block.getConfidence())
                        .position(block.getPosition())
                        .build());
                }
            }

            return OcrResult.builder()
                .ossKey(ossKey)
                .textBlocks(textBlocks)
                .fullText(textBlocks.stream()
                    .map(TextBlock::getText)
                    .collect(Collectors.joining(" ")))
                .build();
        } catch (Exception e) {
   
            log.error("OCR 识别失败: ossKey={}", ossKey, e);
            return OcrResult.builder().ossKey(ossKey).build();
        }
    }

    /**
     * 人脸检测
     * 识别人脸位置、数量,可用于未成年人出镜检测
     */
    public FaceResult detectFaces(String ossKey) {
   
        try {
   
            DetectImageFacesRequest request = new DetectImageFacesRequest()
                .setProjectName("image-moderation")
                .setSourceURI("oss://" + bucketName + "/" + ossKey);

            DetectImageFacesResponse response =
                immClient.detectImageFaces(request);

            List<FaceInfo> faces = new ArrayList<>();
            if (response.getBody() != null
                    && response.getBody().getData() != null) {
   
                for (DetectImageFacesResponseBody.Data.Faces face
                        : response.getBody().getData().getFaces()) {
   
                    faces.add(FaceInfo.builder()
                        .faceId(face.getFaceId())
                        .age(face.getAge())
                        .gender(face.getGender())
                        .emotion(face.getEmotion())
                        .boundary(face.getBoundary())
                        .build());
                }
            }

            return FaceResult.builder()
                .ossKey(ossKey)
                .faceCount(faces.size())
                .faces(faces)
                .build();
        } catch (Exception e) {
   
            log.error("人脸检测失败: ossKey={}", ossKey, e);
            return FaceResult.builder().ossKey(ossKey).faceCount(0).build();
        }
    }

    /**
     * 综合审核:OCR + 人脸 + 绿网结果联合判定
     * OCR 识别到违禁词 → 升级为拒绝
     * 识别到未成年人人脸 → 标记需要特殊处理
     */
    public EnhancedModerationResult enhancedModeration(
            String ossKey, ModerationRecord greenResult) {
   
        OcrResult ocr = detectText(ossKey);
        FaceResult face = detectFaces(ossKey);

        EnhancedModerationResult result = new EnhancedModerationResult();
        result.setOcrResult(ocr);
        result.setFaceResult(face);

        // OCR 违禁词检测
        if (ocr.getFullText() != null) {
   
            boolean hasBannedWord = checkBannedWords(ocr.getFullText());
            if (hasBannedWord) {
   
                result.setOcrViolation(true);
                // 如果绿网判定通过但 OCR 发现违禁词,升级为需要复审
                if (greenResult.getStatus() == ModerationStatus.PASSED) {
   
                    result.setSuggestedAction(ReviewAction.REJECT);
                }
            }
        }

        // 未成年人出镜检测
        boolean hasMinor = face.getFaces().stream()
            .anyMatch(f -> f.getAge() != null && f.getAge() < 18);
        result.setMinorDetected(hasMinor);

        return result;
    }

    private boolean checkBannedWords(String text) {
   
        // 对接自定义词库进行违禁词匹配
        List<String> bannedWords = loadBannedWords();
        String lowerText = text.toLowerCase();
        return bannedWords.stream()
            .anyMatch(word -> lowerText.contains(word.toLowerCase()));
    }
}

5.7 审核统计与报表

运营和合规团队需要审核数据看板,实时掌握平台内容健康状况。

@Service
@Slf4j
public class ModerationStatisticsService {
   

    @Autowired
    private ModerationRecordMapper recordMapper;

    /**
     * 获取审核概览统计
     * 支持按时间范围、审核类型筛选
     */
    public ModerationOverviewVO getOverview(LocalDate startDate,
            LocalDate endDate) {
   
        LocalDateTime start = startDate.atStartOfDay();
        LocalDateTime end = endDate.plusDays(1).atStartOfDay();

        // 基础统计
        long total = recordMapper.countByTimeRange(start, end);
        long passed = recordMapper.countByStatusAndTimeRange(
            ModerationStatus.PASSED, start, end);
        long blocked = recordMapper.countByStatusAndTimeRange(
            ModerationStatus.BLOCKED, start, end);
        long reviewed = recordMapper.countByStatusAndTimeRange(
            ModerationStatus.REVIEW_REQUIRED, start, end);

        // 违规类型分布
        List<Map<String, Object>> violationDistribution =
            recordMapper.countGroupByLabel(start, end);

        // 平均处理时长
        Double avgMachineTime = recordMapper.avgMachineProcessTime(start, end);
        Double avgManualTime = recordMapper.avgManualProcessTime(start, end);

        return ModerationOverviewVO.builder()
            .totalCount(total)
            .passedCount(passed)
            .blockedCount(blocked)
            .reviewCount(reviewed)
            .passRate(total > 0 ? (double) passed / total * 100 : 0)
            .blockRate(total > 0 ? (double) blocked / total * 100 : 0)
            .violationDistribution(violationDistribution)
            .avgMachineTimeMs(avgMachineTime)
            .avgManualTimeSec(avgManualTime)
            .build();
    }

    /**
     * 审核趋势数据(按天聚合)
     * 用于前端折线图展示
     */
    public List<DailyStatsVO> getDailyTrend(LocalDate startDate,
            LocalDate endDate) {
   
        return recordMapper.selectDailyTrend(startDate, endDate);
    }

    /**
     * 审核员绩效统计
     */
    public List<ReviewerStatsVO> getReviewerStats(LocalDate startDate,
            LocalDate endDate) {
   
        return recordMapper.selectReviewerStats(startDate, endDate);
    }
}

6. 前端 Vue3 实战

6.1 项目结构与依赖

moderation-dashboard/
├── src/
│   ├── views/
│   │   ├── upload/
│   │   │   └── ImageUpload.vue
│   │   ├── review/
│   │   │   ├── ReviewBoard.vue
│   │   │   └── ReviewDetail.vue
│   │   └── statistics/
│   │       └── ModerationStats.vue
│   ├── components/
│   │   ├── ImageCard.vue
│   │   ├── ViolationTag.vue
│   │   └── ReviewActionPanel.vue
│   ├── composables/
│   │   ├── useUpload.ts
│   │   ├── useReview.ts
│   │   └── useStatistics.ts
│   ├── api/
│   │   └── moderation.ts
│   └── stores/
│       └── moderation.ts

核心依赖:

{
   
  "dependencies": {
   
    "vue": "^3.4.0",
    "element-plus": "^2.9.0",
    "echarts": "^5.5.0",
    "axios": "^1.7.0",
    "pinia": "^2.2.0",
    "ali-oss": "^6.21.0",
    "vuedraggable": "^4.1.0"
  }
}

6.2 图片上传组件

支持拖拽上传、批量上传、预览和审核状态实时展示。

为什么用 STS 直传 OSS 而非经过后端中转:10MB 图片经过后端中转不仅浪费服务器带宽,还增加 2 倍延迟。STS 直传让浏览器直接上传到 OSS,后端只负责颁发凭证和记录状态。

<!-- src/views/upload/ImageUpload.vue -->
<template>
  <div class="image-upload-container">
    <!-- 拖拽上传区域 -->
    <el-upload
      ref="uploadRef"
      action=""
      :auto-upload="false"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      :http-request="customUpload"
      accept="image/jpeg,image/png,image/gif,image/webp"
      multiple
      drag
    >
      <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
      <div class="el-upload__text">
        拖拽图片到此处,或 <em>点击上传</em>
      </div>
      <template #tip>
        <div class="el-upload__tip">
          支持 JPG/PNG/GIF/WebP,单文件最大 10MB,批量最多 50 张
        </div>
      </template>
    </el-upload>

    <!-- 上传列表:展示预览+审核状态 -->
    <div class="upload-list" v-if="uploadList.length">
      <div
        v-for="item in uploadList"
        :key="item.uid"
        class="upload-item"
      >
        <el-image
          :src="item.previewUrl"
          fit="cover"
          class="upload-preview"
          :preview-src-list="[item.previewUrl]"
        />
        <div class="upload-info">
          <span class="file-name">{
  { item.fileName }}</span>
          <el-tag
            :type="getStatusTagType(item.moderationStatus)"
            size="small"
          >
            {
  { getStatusLabel(item.moderationStatus) }}
          </el-tag>
        </div>
        <!-- 审核进度 -->
        <el-progress
          v-if="item.moderationStatus === 'MACHINE_REVIEWING'"
          :percentage="100"
          :indeterminate="true"
          :show-text="false"
          :stroke-width="3"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import OSS from 'ali-oss'
import { getStsToken, notifyUploadComplete } from '@/api/moderation'
import { useUpload } from '@/composables/useUpload'

const uploadRef = ref()
const uploadList = ref<UploadItem[]>([])
const { pollModerationStatus } = useUpload()

interface UploadItem {
  uid: number
  fileName: string
  file: File
  previewUrl: string
  ossKey: string
  moderationStatus: string
}

// 文件变更处理
const handleFileChange = (uploadFile: any) => {
  const item: UploadItem = {
    uid: uploadFile.uid,
    fileName: uploadFile.name,
    file: uploadFile.raw,
    previewUrl: URL.createObjectURL(uploadFile.raw),
    ossKey: '',
    moderationStatus: 'PENDING_UPLOAD'
  }
  uploadList.value.push(item)
}

// 上传前校验
const beforeUpload = (rawFile: File) => {
  const isValidType = ['image/jpeg', 'image/png',
    'image/gif', 'image/webp'].includes(rawFile.type)
  const isLt10M = rawFile.size / 1024 / 1024 < 10

  if (!isValidType) {
    ElMessage.error('仅支持 JPG/PNG/GIF/WebP 格式')
    return false
  }
  if (!isLt10M) {
    ElMessage.error('图片大小不能超过 10MB')
    return false
  }
  return true
}

// STS 直传 OSS
const customUpload = async (options: any) => {
  const { file, onProgress, onSuccess, onError } = options
  const item = uploadList.value.find(i => i.fileName === file.name)

  try {
    // 1. 获取 STS 临时凭证
    const stsRes = await getStsToken()
    const ossClient = new OSS({
      region: stsRes.region,
      accessKeyId: stsRes.accessKeyId,
      accessKeySecret: stsRes.accessKeySecret,
      stsToken: stsRes.securityToken,
      bucket: stsRes.bucket
    })

    // 2. 直传 OSS
    const ossKey = `images/${Date.now()}_${file.name}`
    const result = await ossClient.multipartUpload(ossKey, file, {
      progress: (p: number) => onProgress({ percent: p * 100 })
    })

    if (item) {
      item.ossKey = ossKey
      item.moderationStatus = 'MACHINE_REVIEWING'
    }

    // 3. 通知后端上传完成
    await notifyUploadComplete({ ossKey, fileName: file.name })

    // 4. 轮询审核状态
    if (item) {
      pollModerationStatus(item.ossKey, (status: string) => {
        item.moderationStatus = status
      })
    }

    onSuccess(result)
  } catch (err: any) {
    if (item) item.moderationStatus = 'UPLOAD_FAILED'
    onError(err)
    ElMessage.error('上传失败: ' + err.message)
  }
}

// 审核状态标签映射
const getStatusTagType = (status: string) => {
  const map: Record<string, string> = {
    'PENDING_UPLOAD': 'info',
    'MACHINE_REVIEWING': 'warning',
    'PASSED': 'success',
    'BLOCKED': 'danger',
    'REVIEW_REQUIRED': 'warning',
    'UPLOAD_FAILED': 'danger'
  }
  return map[status] || 'info'
}

const getStatusLabel = (status: string) => {
  const map: Record<string, string> = {
    'PENDING_UPLOAD': '待上传',
    'MACHINE_REVIEWING': 'AI 审核中',
    'PASSED': '已通过',
    'BLOCKED': '已拒绝',
    'REVIEW_REQUIRED': '待人工复审',
    'UPLOAD_FAILED': '上传失败'
  }
  return map[status] || status
}

onMounted(() => {
  // 可选:加载之前上传但未完成的图片
})
</script>

<style scoped>
.image-upload-container {
  padding: 20px;
}
.upload-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 12px;
  margin-top: 16px;
}
.upload-item {
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  padding: 8px;
  position: relative;
}
.upload-preview {
  width: 100%;
  height: 120px;
  border-radius: 4px;
}
.upload-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 8px;
}
.file-name {
  font-size: 12px;
  color: #606266;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100px;
}
</style>

6.3 审核看板

4 列看板式布局:待审核 → 已通过 → 已拒绝 → 待复审,支持拖拽流转。

为什么用看板而非列表:审核员需要快速浏览大量图片,看板视图一眼能看到各状态的数量分布,拖拽操作比点击按钮更高效。实测审核效率比列表模式提升 30%。

<!-- src/views/review/ReviewBoard.vue -->
<template>
  <div class="review-board">
    <!-- 顶部统计条 -->
    <div class="board-header">
      <el-statistic title="待审核" :value="stats.pending" />
      <el-statistic title="已通过" :value="stats.passed" />
      <el-statistic title="已拒绝" :value="stats.blocked" />
      <el-statistic title="待复审" :value="stats.reviewRequired" />
      <el-button type="primary" @click="claimAndReview">
        领取审核任务
      </el-button>
    </div>

    <!-- 4 列看板 -->
    <div class="board-columns">
      <div
        v-for="col in columns"
        :key="col.status"
        class="board-column"
        :class="col.colorClass"
      >
        <div class="column-header">
          <span class="column-title">{
  { col.title }}</span>
          <el-badge :value="getColumnCount(col.status)" />
        </div>

        <draggable
          :list="getColumnItems(col.status)"
          :group="{ name: 'review', pull: true, put: true }"
          item-key="id"
          class="column-body"
          @change="(e: any) => handleDragChange(e, col.status)"
        >
          <template #item="{ element }">
            <ImageCard
              :record="element"
              @click="openDetail(element)"
            />
          </template>
        </draggable>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import draggable from 'vuedraggable'
import ImageCard from '@/components/ImageCard.vue'
import {
  getReviewBoardData,
  claimTask,
  submitReview
} from '@/api/moderation'

interface BoardItem {
  id: number
  ossKey: string
  thumbnailUrl: string
  moderationStatus: string
  greenResult: string
  confidence: number
  uploadTime: string
}

const columns = [
  { status: 'REVIEW_REQUIRED', title: '待审核', colorClass: 'col-pending' },
  { status: 'PASSED', title: '已通过', colorClass: 'col-passed' },
  { status: 'BLOCKED', title: '已拒绝', colorClass: 'col-blocked' },
  { status: 'ESCALATED', title: '待终审', colorClass: 'col-escalated' }
]

const boardData = reactive<Record<string, BoardItem[]>>({
  REVIEW_REQUIRED: [],
  PASSED: [],
  BLOCKED: [],
  ESCALATED: []
})

const stats = reactive({
  pending: 0,
  passed: 0,
  blocked: 0,
  reviewRequired: 0
})

const getColumnItems = (status: string) => boardData[status] || []
const getColumnCount = (status: string) => (boardData[status] || []).length

// 拖拽变更处理
const handleDragChange = async (event: any, newStatus: string) => {
  if (event.added) {
    const item = event.added.element
    try {
      await submitReview({
        recordId: item.id,
        action: statusToAction(newStatus),
        comment: ''
      })
      item.moderationStatus = newStatus
    } catch (err) {
      // 拖拽失败,回滚
      ElMessage.error('操作失败,请重试')
      await loadBoardData()
    }
  }
}

// 领取审核任务
const claimAndReview = async () => {
  try {
    const task = await claimTask()
    if (task) {
      boardData.REVIEW_REQUIRED.push(task)
      ElMessage.success('已领取 1 个审核任务')
    } else {
      ElMessage.info('当前无待审核任务')
    }
  } catch (err) {
    ElMessage.error('领取失败')
  }
}

// 加载看板数据
const loadBoardData = async () => {
  const data = await getReviewBoardData()
  Object.assign(boardData, data.items)
  Object.assign(stats, data.stats)
}

const statusToAction = (status: string) => {
  const map: Record<string, string> = {
    'PASSED': 'PASS',
    'BLOCKED': 'REJECT',
    'ESCALATED': 'ESCALATE'
  }
  return map[status] || 'PASS'
}

onMounted(() => {
  loadBoardData()
  // 每 30 秒刷新一次看板
  setInterval(loadBoardData, 30000)
})
</script>

<style scoped>
.review-board {
  padding: 20px;
  height: calc(100vh - 60px);
}
.board-header {
  display: flex;
  gap: 32px;
  align-items: center;
  padding: 16px;
  background: #fff;
  border-radius: 8px;
  margin-bottom: 16px;
}
.board-columns {
  display: flex;
  gap: 16px;
  height: calc(100% - 100px);
  overflow: hidden;
}
.board-column {
  flex: 1;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.col-pending { background: #fdf6ec; }
.col-passed { background: #f0f9eb; }
.col-blocked { background: #fef0f0; }
.col-escalated { background: #f4f4f5; }
.column-header {
  padding: 12px 16px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: 600;
}
.column-body {
  flex: 1;
  overflow-y: auto;
  padding: 8px;
}
</style>

6.4 审核详情

大图展示 + AI 标注(违规区域框)+ OCR 文字 + 人脸框 + 人工标注面板。

为什么详情页要同时展示 AI 和人工视角:审核员需要理解 AI 为什么判定违规,才能做出正确的复审决策。AI 标注的违规区域、OCR 识别的文字、人脸检测的结果,都是审核员的重要参考。

<!-- src/views/review/ReviewDetail.vue -->
<template>
  <el-drawer
    v-model="visible"
    title="审核详情"
    size="70%"
    :before-close="handleClose"
  >
    <div class="detail-container">
      <!-- 左侧:大图 + AI 标注 -->
      <div class="image-section">
        <div class="image-wrapper" ref="imageWrapperRef">
          <img
            :src="record.imageUrl"
            class="detail-image"
            @load="onImageLoad"
            ref="imageRef"
          />
          <!-- AI 违规区域标注 -->
          <div
            v-for="(box, idx) in violationBoxes"
            :key="idx"
            class="violation-box"
            :style="getBoxStyle(box)"
          >
            <span class="box-label">{
  { box.label }}</span>
          </div>
          <!-- 人脸框 -->
          <div
            v-for="(face, idx) in faceBoxes"
            :key="'face-' + idx"
            class="face-box"
            :style="getBoxStyle(face.boundary)"
          >
            <span class="box-label face-label">
              {
  { face.gender }} {
  { face.age }}岁
            </span>
          </div>
        </div>

        <!-- OCR 识别文字 -->
        <div class="ocr-section" v-if="ocrText">
          <h4>OCR 识别文字</h4>
          <p class="ocr-text">{
  { ocrText }}</p>
        </div>
      </div>

      <!-- 右侧:审核信息 + 操作面板 -->
      <div class="info-section">
        <!-- AI 审核结果 -->
        <el-descriptions title="AI 审核结果" :column="1" border>
          <el-descriptions-item label="审核场景">
            <ViolationTag :label="record.greenResult" />
          </el-descriptions-item>
          <el-descriptions-item label="置信度">
            <el-progress
              :percentage="record.confidence"
              :color="getConfidenceColor(record.confidence)"
            />
          </el-descriptions-item>
          <el-descriptions-item label="详细结果">
            {
  { record.detail }}
          </el-descriptions-item>
        </el-descriptions>

        <!-- 人工复审操作 -->
        <div class="review-actions" v-if="canReview">
          <h4>人工复审</h4>
          <el-input
            v-model="reviewComment"
            type="textarea"
            placeholder="审核备注(可选)"
            :rows="3}
          />
          <div class="action-buttons">
            <el-button type="success" @click="handleAction('PASS')">
              通过
            </el-button>
            <el-button type="danger" @click="handleAction('REJECT')">
              拒绝
            </el-button>
            <el-button type="warning" @click="handleAction('ESCALATE')">
              升级终审
            </el-button>
          </div>
        </div>

        <!-- 审核历史 -->
        <div class="review-history" v-if="history.length">
          <h4>审核历史</h4>
          <el-timeline>
            <el-timeline-item
              v-for="(item, idx) in history"
              :key="idx"
              :timestamp="item.time"
              :type="item.type"
            >
              {
  { item.content }}
            </el-timeline-item>
          </el-timeline>
        </div>
      </div>
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import ViolationTag from '@/components/ViolationTag.vue'
import { submitReview } from '@/api/moderation'

const props = defineProps<{
  record: any
  visible: boolean
}>()

const emit = defineEmits(['update:visible', 'reviewed'])

const reviewComment = ref('')
const violationBoxes = ref<any[]>([])
const faceBoxes = ref<any[]>([])
const ocrText = ref('')
const imageRef = ref<HTMLImageElement>()
const history = ref<any[]>([])

const canReview = computed(() =>
  ['REVIEW_REQUIRED', 'ESCALATED'].includes(
    props.record?.moderationStatus)
)

// 图片加载完成后解析标注数据
const onImageLoad = () => {
  // 解析 AI 标注的违规区域
  if (props.record.violationRegions) {
    violationBoxes.value = props.record.violationRegions
  }
  if (props.record.faceResult?.faces) {
    faceBoxes.value = props.record.faceResult.faces
  }
  if (props.record.ocrResult?.fullText) {
    ocrText.value = props.record.ocrResult.fullText
  }
}

// 将比例坐标转为像素坐标
const getBoxStyle = (box: any) => {
  if (!imageRef.value || !box) return {}
  const imgW = imageRef.value.clientWidth
  const imgH = imageRef.value.clientHeight
  return {
    left: `${box.left * imgW / 100}px`,
    top: `${box.top * imgH / 100}px`,
    width: `${box.width * imgW / 100}px`,
    height: `${box.height * imgH / 100}px`
  }
}

const getConfidenceColor = (confidence: number) => {
  if (confidence >= 90) return '#f56c6c'
  if (confidence >= 60) return '#e6a23c'
  return '#67c23a'
}

const handleAction = async (action: string) => {
  await submitReview({
    recordId: props.record.id,
    action,
    comment: reviewComment.value
  })
  emit('reviewed')
  handleClose()
}

const handleClose = () => {
  emit('update:visible', false)
}
</script>

<style scoped>
.detail-container {
  display: flex;
  gap: 20px;
  height: 100%;
}
.image-section {
  flex: 3;
}
.image-wrapper {
  position: relative;
  display: inline-block;
}
.detail-image {
  max-width: 100%;
  max-height: 500px;
  border-radius: 8px;
}
.violation-box {
  position: absolute;
  border: 2px solid #f56c6c;
  border-radius: 4px;
  background: rgba(245, 108, 108, 0.1);
}
.face-box {
  position: absolute;
  border: 2px solid #409eff;
  border-radius: 4px;
  background: rgba(64, 158, 255, 0.1);
}
.box-label {
  position: absolute;
  top: -20px;
  left: 0;
  font-size: 11px;
  padding: 1px 6px;
  border-radius: 3px;
  white-space: nowrap;
}
.violation-box .box-label {
  background: #f56c6c;
  color: #fff;
}
.face-label {
  background: #409eff;
  color: #fff;
}
.info-section {
  flex: 2;
  overflow-y: auto;
}
.review-actions {
  margin-top: 20px;
  padding: 16px;
  background: #fafafa;
  border-radius: 8px;
}
.action-buttons {
  display: flex;
  gap: 12px;
  margin-top: 12px;
}
.ocr-section {
  margin-top: 16px;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 8px;
}
.ocr-text {
  font-family: monospace;
  font-size: 13px;
  line-height: 1.6;
}
</style>

6.5 统计报表

审核量/违规率/处理时长的趋势图,帮助运营和合规团队掌握内容健康状况。

为什么用 ECharts 而非 Element Plus 图表:ECharts 的交互能力(tooltip、缩放、数据缩略轴)远超 Element Plus 内置图表,审核数据需要下钻到每天/每小时的粒度,ECharts 是更合适的选择。

<!-- src/views/statistics/ModerationStats.vue -->
<template>
  <div class="moderation-stats">
    <!-- 概览卡片 -->
    <el-row :gutter="16" class="overview-cards">
      <el-col :span="6" v-for="card in overviewCards" :key="card.key">
        <el-card shadow="hover">
          <el-statistic
            :title="card.title"
            :value="card.value"
            :suffix="card.suffix"
            :value-style="{ color: card.color }"
          />
        </el-card>
      </el-col>
    </el-row>

    <!-- 审核趋势图 -->
    <el-card shadow="hover" class="chart-card">
      <template #header>
        <span>审核趋势(近 30 天)</span>
        <el-radio-group v-model="trendType" size="small">
          <el-radio-button label="count">审核量</el-radio-button>
          <el-radio-button label="rate">违规率</el-radio-button>
          <el-radio-button label="time">处理时长</el-radio-button>
        </el-radio-group>
      </template>
      <div ref="trendChartRef" class="trend-chart"></div>
    </el-card>

    <!-- 违规类型分布 -->
    <el-row :gutter="16">
      <el-col :span="12">
        <el-card shadow="hover" class="chart-card">
          <template #header>违规类型分布</template>
          <div ref="pieChartRef" class="pie-chart"></div>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card shadow="hover" class="chart-card">
          <template #header>审核员绩效</template>
          <el-table :data="reviewerStats" stripe>
            <el-table-column prop="reviewerName" label="审核员" />
            <el-table-column prop="reviewCount" label="审核量" />
            <el-table-column prop="avgTime" label="平均耗时" />
            <el-table-column prop="accuracy" label="准确率" />
          </el-table>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
import { getOverview, getDailyTrend, getReviewerStats }
  from '@/api/moderation'

const trendType = ref('count')
const overviewCards = ref<any[]>([])
const reviewerStats = ref<any[]>([])

const trendChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null

// 加载概览数据
const loadOverview = async () => {
  const data = await getOverview({
    startDate: getDateNDaysAgo(30),
    endDate: getToday()
  })
  overviewCards.value = [
    { key: 'total', title: '总审核量', value: data.totalCount,
      suffix: '张', color: '#409eff' },
    { key: 'pass', title: '通过率', value: data.passRate.toFixed(1),
      suffix: '%', color: '#67c23a' },
    { key: 'block', title: '违规率', value: data.blockRate.toFixed(2),
      suffix: '%', color: '#f56c6c' },
    { key: 'avgTime', title: '机审平均耗时', value: data.avgMachineTimeMs,
      suffix: 'ms', color: '#e6a23c' }
  ]
}

// 渲染趋势图
const renderTrendChart = async () => {
  const data = await getDailyTrend({
    startDate: getDateNDaysAgo(30),
    endDate: getToday()
  })

  if (!trendChartRef.value) return
  if (!trendChart) {
    trendChart = echarts.init(trendChartRef.value)
  }

  const option: echarts.EChartsOption = {
    tooltip: { trigger: 'axis' },
    legend: { data: getLegendByType(trendType.value) },
    xAxis: {
      type: 'category',
      data: data.map((d: any) => d.date)
    },
    yAxis: { type: 'value' },
    series: getSeriesByType(trendType.value, data),
    dataZoom: [
      { type: 'inside', start: 0, end: 100 }
    ]
  }

  trendChart.setOption(option, true)
}

const getSeriesByType = (type: string, data: any[]) => {
  if (type === 'count') {
    return [
      { name: '总审核量', type: 'bar', data: data.map(d => d.totalCount) },
      { name: '违规量', type: 'bar', data: data.map(d => d.blockedCount) }
    ]
  }
  if (type === 'rate') {
    return [
      { name: '违规率', type: 'line',
        data: data.map(d => d.blockRate), smooth: true }
    ]
  }
  return [
    { name: '机审耗时(ms)', type: 'line',
      data: data.map(d => d.avgMachineTime), smooth: true },
    { name: '人审耗时(s)', type: 'line',
      data: data.map(d => d.avgManualTime), smooth: true }
  ]
}

watch(trendType, () => renderTrendChart())

onMounted(async () => {
  await loadOverview()
  await renderTrendChart()
  const data = await getReviewerStats({
    startDate: getDateNDaysAgo(30),
    endDate: getToday()
  })
  reviewerStats.value = data
})

onUnmounted(() => {
  trendChart?.dispose()
  pieChart?.dispose()
})

function getDateNDaysAgo(n: number) {
  return new Date(Date.now() - n * 86400000)
    .toISOString().split('T')[0]
}
function getToday() {
  return new Date().toISOString().split('T')[0]
}
function getLegendByType(type: string) {
  if (type === 'count') return ['总审核量', '违规量']
  if (type === 'rate') return ['违规率']
  return ['机审耗时(ms)', '人审耗时(s)']
}
</script>

<style scoped>
.moderation-stats { padding: 20px; }
.overview-cards { margin-bottom: 20px; }
.chart-card { margin-bottom: 16px; }
.trend-chart { height: 360px; }
.pie-chart { height: 300px; }
</style>

7. 进阶:自定义模型训练 + 视频审核 + 直播审核

7.1 自定义模型训练

绿网的通用模型覆盖了色情/暴恐/涉政等常见类别,但业务特有的违规内容需要自定义模型。比如某二手交易平台需要识别"假货/仿品",某社交平台需要识别特定竞品的 Logo。

阿里云机器学习 PAI + 绿网自定义库方案:

  1. 收集违规样本:从人工复审记录中导出正样本(至少 500 张),负样本从通过记录中随机抽取
  2. 使用 PAI-VisualDL 训练图片分类模型:选择 ResNet50 预训练模型,Fine-tune 5-10 个 Epoch
  3. 导出模型并部署为 API 端点
  4. 在后端审核流程中增加自定义模型检测环节:绿网通用审核 → 自定义模型审核 → 结果合并

实测数据:某平台的"竞品 Logo 识别"自定义模型,准确率从绿网通用模型的 62% 提升到 94%,误判率从 18% 降到 3%。

7.2 视频审核

图片审核解决后,UGC 视频的审核是下一个挑战。阿里云绿网支持视频审核,核心流程:

  • 视频截帧:每隔 1 秒截取 1 帧关键画面(可配置间隔)
  • 逐帧审核:每帧图片走绿网图片审核流程
  • 语音审核:视频音轨提取后走文本/语音审核
  • 结果汇总:任一帧或语音片段违规,整个视频标记为违规

视频审核的耗时和成本远高于图片,建议的优化策略:

  • 先用低频截帧(5 秒/帧)快速初筛,通过后再用高频截帧(1 秒/帧)精细审核
  • 视频时长超过 5 分钟的,只审核前 3 分钟 + 随机抽审中间片段
  • 结合视频元数据(标题/标签/描述)做预筛选,高置信度合规的视频跳过审核

7.3 直播审核

直播场景的特殊性在于实时性——违规内容出现后必须在秒级内拦截。绿网直播审核方案:

  • 截流审核:从直播流中每隔 5 秒截取一帧,实时提交绿网审核
  • 告警机制:检测到违规画面后立即推送告警给运营(钉钉/企微/短信)
  • 自动处置:连续 3 帧检测到违规后自动断流(需配置确认,避免误断)
  • 回溯审核:直播结束后对完整录像做离线审核,补漏实时审核可能遗漏的内容

8. 量化对比:人工 vs AI 审核

维度 纯人工审核 AI + 人工复审 提升幅度
日审核量 2 万张(5 人) 100 万张(AI + 2 人) ⬆️ 50 倍
违规漏检率 8%(疲劳后升至 15%) 0.3%(AI 0.5% + 人工兜底) ⬇️ 96.3%
平均审核时效 6 小时 3 秒 ⬇️ 99.9%
7×24 覆盖 ❌(夜间空白) ✅(AI 全天候) 从无到有
月审核成本 ¥4.5 万(5 人人力) ¥0.8 万(API + 2 人) ⬇️ 82%
审核标准一致性 因人而异 标准统一 ✅ 量化可控

成本拆解(AI + 人工复审模式,月度):

项目 费用
绿网图片审核 API(100 万张/月) ¥3,000(¥0.003/张)
OSS 存储 + 事件通知 ¥500
MNS 消息服务 ¥50
IMM OCR + 人脸检测(10 万次/月) ¥800
2 名审核员工资 ¥3,500
合计 ¥7,850

对比纯人工 5 人团队的 ¥4.5 万/月,节省 82%,同时审核量和质量大幅提升。


9. 踩坑实录

坑 1:MNS 回调消息丢失——审核结果凭空消失

现象:图片上传后状态一直停留在"AI 审核中",但绿网控制台显示审核已完成。

原因:MNS 回调接口返回了非 200 状态码(业务代码抛了异常),MNS 认为投递失败进入重试队列,但重试 6 次后丢弃消息。

修复:

@PostMapping("/callback")
public ResponseEntity<String> handleCallback(@RequestBody String body) {
   
    try {
   
        callbackProcessService.processCallback(body);
    } catch (Exception e) {
   
        log.error("回调处理异常", e);
        // 关键:即使业务异常也返回 200,避免 MNS 无限重试
        // 业务异常通过定时任务补偿
    }
    return ResponseEntity.ok("success");
}

同时增加定时补偿任务,每 5 分钟扫描"机审中超过 10 分钟"的记录,主动查询绿网结果。

坑 2:误判申诉流程缺失——用户投诉无门

现象:用户的旅行照片被 AI 误判为"色情"而删除,用户投诉无门,流失率飙升。

原因:只做了审核拦截,没做申诉通道。AI 审核对沙滩照、艺术照的误判率约 2-3%,必须有申诉机制。

修复:在 Vue3 前端增加"申诉"按钮,后端增加申诉工作流:

@Service
public class AppealService {
   
    /**
     * 提交申诉
     * 申诉后自动进入高级审核员队列
     */
    @Transactional
    public void submitAppeal(Long recordId, Long userId, String reason) {
   
        // 校验:同一记录 24 小时内只能申诉一次
        String appealKey = "appeal:limit:" + recordId;
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent(appealKey, "1", Duration.ofHours(24));
        if (Boolean.FALSE.equals(isNew)) {
   
            throw new BusinessException("请勿重复申诉");
        }

        AppealRecord appeal = new AppealRecord();
        appeal.setRecordId(recordId);
        appeal.setUserId(userId);
        appeal.setReason(reason);
        appeal.setStatus(AppealStatus.PENDING);
        appealMapper.insert(appeal);

        // 重新推入高级审核员队列
        manualReviewService.pushToReviewQueue(recordId);
    }
}

坑 3:OCR 识别率低——图片中文字识别不准

现象:广告图片中的联系方式(微信号、手机号)OCR 识别不出来,违规广告漏审。

原因:绿网 OCR 的默认识别精度在图片背景复杂、文字倾斜/模糊时识别率较低。实测简单文字 95%+,复杂背景 60-70%。

修复:采用 IMM 的增强 OCR + 前处理优化:

  1. 图片前处理:对 OSS 图片添加图片处理参数,增强对比度、去噪
  2. 使用 IMM 的 GetImageOcr 接口替代绿网内置 OCR,精度更高
  3. 对识别结果做正则增强匹配(手机号 11 位、微信号 6-20 位字母数字)
# OSS 图片处理参数增强 OCR 效果
原图: oss://bucket/images/xxx.jpg
增强: oss://bucket/images/xxx.jpg?x-oss-process=image/contrast,50/sharpen,100

增强后 OCR 识别率从 65% 提升到 88%。

坑 4:大图审核超时——10MB+ 图片审核失败

现象:高清拍摄的照片(10-20MB)提交审核后超时,绿网返回 ReadTimeout。

原因:绿网图片审核有大小限制,单张图片建议不超过 5MB。超过 5MB 的图片建议先用 OSS 图片处理压缩后再提交审核。

修复:在上传时自动压缩大图,压缩版提交审核,原图保留:

/**
 * 大图压缩后提交审核
 * OSS 图片处理:限制长边 2000px,质量 85%
 */
public String submitWithCompress(String ossKey, Long recordId)
        throws Exception {
   
    String compressKey = ossKey.replace("images/", "images/compress/");

    // 使用 OSS 图片处理参数生成压缩 URL
    String compressUrl = String.format(
        "oss://%s/%s?x-oss-process=image/resize,l_2000/quality,q_85",
        bucketName, ossKey);

    // 提交压缩后的图片给绿网审核
    ImageModerationRequest request = new ImageModerationRequest()
        .setService("imageModeration")
        .setServiceParameters(
            new ImageModerationRequest.ServiceParameters()
                .setImageUrl(compressUrl)
                .setDataId(String.valueOf(recordId)));

    return greenClient.imageModerationWithOptions(
        request, new RuntimeOptions()
            .setReadTimeout(30000)
            .setConnectTimeout(10000))
        .getBody().getData().getTaskId();
}

坑 5:MNS 消息积压——审核高峰期回调延迟

现象:每天 20:00-22:00 用户上传高峰期,MNS 回调延迟 5-10 分钟,审核状态更新不及时。

原因:MNS 队列默认吞吐量配置较低,高峰期消息积压。且回调接口串行处理,QPS 上不去。

修复:

  1. MNS 队列扩容:调整 BatchSize 和 VisibilityTimeout 参数
  2. 回调接口异步化:收到消息后丢到线程池异步处理,立即返回 200
@Service
public class CallbackProcessService {
   

    private final ExecutorService callbackExecutor =
        new ThreadPoolExecutor(
            10, 50, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(1000),
            new ThreadPoolExecutor.CallerRunsPolicy());

    @Async("callbackExecutor")
    public void processCallbackAsync(String callbackBody) {
   
        processCallback(callbackBody);
    }
}
  1. 增加监控告警:MNS 队列深度超过 1000 时触发钉钉告警,人工介入

10. 最佳实践

审核方案选型决策树

cloud-native_mermaid_4

审核平台上线检查清单

上线前逐项确认,避免遗漏关键环节:

阿里云配置:

  • [ ] 绿网内容安全已开通,图片审核能力已启用
  • [ ] 审核场景配置完成(色情/暴恐/涉政/广告/二维码)
  • [ ] OSS 事件通知规则已创建,指向 MNS 主题
  • [ ] MNS 主题和队列已创建,HTTP 推送 endpoint 已配置
  • [ ] IMM 项目已创建,OCR 和人脸检测功能已启用
  • [ ] RAM 子账号已创建,仅授予必要权限(最小权限原则)
  • [ ] 自定义词库已配置(业务特有违禁词)

后端服务:

  • [ ] 回调接口幂等设计已实现(Redis SETNX)
  • [ ] 定时补偿任务已部署(兜底回调丢失)
  • [ ] 大图压缩策略已配置(5MB 以上自动压缩)
  • [ ] 申诉工作流已实现(误判可申诉)
  • [ ] 审核状态机完整(6 个状态流转无死锁)
  • [ ] MNS 消息消费异常重试已配置

前端页面:

  • [ ] 上传组件支持拖拽和批量上传
  • [ ] 审核状态实时轮询或 WebSocket 推送
  • [ ] 审核看板 4 列拖拽已测试
  • [ ] 审核详情页 AI 标注正确渲染
  • [ ] 统计报表数据准确
  • [ ] 申诉入口已上线

监控告警:

  • [ ] 审核回调延迟超过 5 分钟告警
  • [ ] MNS 队列积压超过 1000 条告警
  • [ ] 审核失败率超过 1% 告警
  • [ ] 人工复审积压超过 500 条告警
  • [ ] 绿网 API 调用异常告警

📜 真实性声明

本文所有内容均基于作者在 2025 年 Q3 期间参与的 UGC 社区平台项目中的真实经验。所有案例、数据、代码均来自生产环境,经过实践验证。为保护商业机密,部分敏感信息已做脱敏处理,但技术细节保持完整和真实。

如有任何疑问,欢迎在评论区交流讨论。

相关文章
|
4天前
|
云安全 人工智能 运维
当攻击全面Agent化,你的安全防御能否跟上AI Speed?
多起客户侧AK泄露事件应急的真实观察
1233 3
|
27天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
9725 32
|
13天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
8天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
12天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
880 1
|
5天前
|
Web App开发 人工智能 JavaScript
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
REA(Reverse Engineer Anything)是近期爆火的开源AI逆向工具,4月发布,1个月内斩获15万+ Star。它让AI Agent无需源码即可分析二进制、APK、JS/Electron、HAR包等17类目标,自动反编译、生成可运行代码,并强制返回带证据与局限的结论。全本地执行,MIT协议,支持Hopper/Ghidra/IDA等引擎,一行命令即可上手。
570 1
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
|
8天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型全解析:核心原理拆解,性能实战测评与落地部署教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为System One Model(系统一模型),对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
574 0
|
9天前
|
存储 人工智能 自然语言处理
千问办公官网入口链接在哪?2026年最新QwenWork测评、千问办公怎么样?一文看懂
千问办公(QwenWork)是阿里云推出的AI智能办公平台,提供网页端和阿里云官网双入口。支持PPT/Word/Excel生成、多模态处理、网页全栈搭建、钉钉深度集成等6大核心能力。个人版免费可用,高级版198元/月;企业版198元/人/月。新用户注册即赠2000积分。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
1156 0