01MVP 标识01MVP

content-moderation 内容审核

图片和文本内容审核,确保用户生成内容符合平台规范

概述

@mono/content-moderation 是项目的内容审核包,提供图片和文本内容审核功能。它为平台提供了一层内容安全保护,确保用户上传的内容符合社区规范和法律法规要求。

为什么需要内容审核?

在任何允许用户生成内容(UGC)的平台上,内容审核都是必不可少的:

法律合规

  • 法律要求:中国《网络安全法》要求平���对用户发布的信息进行审核
  • 平台责任:平台需要对违法违规内容承担连带责任
  • 用户保护:保护未成年人和其他用户免受不良内容影响

社区健康

  • 用户体验:过滤违规内容,维护良好的社区氛围
  • 品牌形象:避免违规内容损害平台品牌
  • 运营效率:自动化审核减少人工审核成本

业务风险

  • 法律风险:违规内容可能导致平台被处罚或关停
  • 用户流失:不良内容会导致用户流失
  • 商业损失:广告主不愿在违规内容旁投放广告

核心功能

  • 图片审核 - 检测图片中的违规内容
  • 多种模式 - 支持头像和内容两种审核模式
  • 灵活集成 - 可轻松集成第三方审核服务
  • 类型安全 - 完整的 TypeScript 支持

快速开始

基础用法

import { requestImageModeration } from "@mono/content-moderation";

// 审核图片
const result = await requestImageModeration(
  "https://example.com/image.jpg",
  "content"
);

if (result.ok) {
  console.log("审核通过");
} else {
  console.log("审核失败:", result.reason);
}

审核模式

包支持两种审核模式:

// 头像模式 - 更严格
const avatarResult = await requestImageModeration(
  "https://example.com/avatar.jpg",
  "avatar"
);

// 内容模式 - 相对宽松
const contentResult = await requestImageModeration(
  "https://example.com/photo.jpg",
  "content"
);

实战案例

案例 1: 用户头像上传

完整的头像上传流程,包括审核和错误处理。

// app/api/avatar/upload/route.ts
import { requestImageModeration } from "@mono/content-moderation";
import { uploadFileToS3, deleteFileFromS3 } from "@mono/storage";

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get("avatar") as File;

  // 验证文件
  if (!file.type.startsWith("image/")) {
    return Response.json(
      { error: "只支持图���格式" },
      { status: 400 }
    );
  }

  // 上传到临时位置
  const tempPath = `temp/avatars/${Date.now()}-${file.name}`;
  const buffer = Buffer.from(await file.arrayBuffer());

  await uploadFileToS3(tempPath, {
    bucket: process.env.S3_BUCKET!,
    body: buffer,
    contentType: file.type,
  });

  const tempUrl = `${process.env.S3_PUBLIC_ENDPOINT}/${tempPath}`;

  // 审核图片
  try {
    const moderation = await requestImageModeration(tempUrl, "avatar");

    if (!moderation.ok) {
      // 删除违规图片
      await deleteFileFromS3(tempPath, {
        bucket: process.env.S3_BUCKET!
      });

      return Response.json(
        { error: `头像审核失败: ${moderation.reason}` },
        { status: 400 }
      );
    }

    // 移动到正式位置
    const userId = "user-123"; // 从 session 获取
    const finalPath = `avatars/${userId}.jpg`;

    // ... 移动文件逻辑

    return Response.json({ url: finalUrl });
  } catch (error) {
    // 清理临时文件
    await deleteFileFromS3(tempPath, {
      bucket: process.env.S3_BUCKET!
    });
    throw error;
  }
}

案例 2: 社区内容发布

在用户发布内容时审核所有图片。

// app/api/posts/create/route.ts
import { requestImageModeration } from "@mono/content-moderation";
import { prisma } from "@/lib/prisma";

export async function POST(request: Request) {
  const { content, imageUrls } = await request.json();

  // 验证输入
  if (!content || !Array.isArray(imageUrls)) {
    return Response.json(
      { error: "无效的请求参数" },
      { status: 400 }
    );
  }

  // 并发审核所有图片
  const moderationResults = await Promise.all(
    imageUrls.map(url => requestImageModeration(url, "content"))
  );

  // 检查是否有图片未通过审核
  const failedIndex = moderationResults.findIndex(r => !r.ok);

  if (failedIndex !== -1) {
    return Response.json(
      {
        error: "图片审核失败",
        failedImage: imageUrls[failedIndex],
        reason: moderationResults[failedIndex].reason,
      },
      { status: 400 }
    );
  }

  // 所��图片通过审核,创建帖子
  const post = await prisma.post.create({
    data: {
      content,
      images: imageUrls,
      userId: "user-123", // 从 session 获取
    },
  });

  return Response.json(post);
}

案例 3: 批量图片审核

批量审核多张图片,并返回详细结果。

// lib/moderation.ts
import { requestImageModeration } from "@mono/content-moderation";

interface BatchModerationResult {
  url: string;
  passed: boolean;
  reason?: string;
}

export async function batchModerateImages(
  imageUrls: string[],
  mode: "avatar" | "content" = "content"
): Promise<BatchModerationResult[]> {
  // 使用 Promise.allSettled 确保所有请求都完成
  const results = await Promise.allSettled(
    imageUrls.map(url => requestImageModeration(url, mode))
  );

  return results.map((result, index) => {
    if (result.status === "rejected") {
      return {
        url: imageUrls[index],
        passed: false,
        reason: result.reason?.message || "审核请求失败",
      };
    }

    return {
      url: imageUrls[index],
      passed: result.value.ok,
      reason: result.value.reason,
    };
  });
}

// 使用示例
const imageUrls = [
  "https://example.com/image1.jpg",
  "https://example.com/image2.jpg",
  "https://example.com/image3.jpg",
];

const results = await batchModerateImages(imageUrls);

// 检查结果
const allPassed = results.every(r => r.passed);
const failedImages = results.filter(r => !r.passed);

if (!allPassed) {
  console.log("以下图片未通过审核:");
  failedImages.forEach(img => {
    console.log(`- ${img.url}: ${img.reason}`);
  });
}

案例 4: 审核结果缓存

缓存审核结果,避免重复审核相同图片,节省成本。

// lib/cached-moderation.ts
import { requestImageModeration, type ImageModerationResult } from "@mono/content-moderation";
import { createMemoryCache } from "@mono/cache";

// 创建缓存实例
const moderationCache = createMemoryCache();

export async function cachedImageModeration(
  imageUrl: string,
  mode: "avatar" | "content" = "content"
): Promise<ImageModerationResult> {
  const cacheKey = `moderation:${mode}:${imageUrl}`;

  // 检查缓存
  const cached = moderationCache.get<ImageModerationResult>(cacheKey);
  if (cached) {
    console.log("使用缓存的审核结果");
    return cached;
  }

  // 执行审核
  const result = await requestImageModeration(imageUrl, mode);

  // 缓存结果(24 小时)
  // 注意:只缓存通过的结果,失败的结果可能需要重新审核
  if (result.ok) {
    moderationCache.set(cacheKey, result, 24 * 60 * 60);
  }

  return result;
}

// 清除特定图片的缓存
export function clearModerationCache(imageUrl: string) {
  moderationCache.delete(`moderation:avatar:${imageUrl}`);
  moderationCache.delete(`moderation:content:${imageUrl}`);
}

案例 5: Server Action 集成

在 Next.js Server Action 中使用内容审核。

// app/actions/upload-image.ts
"use server";

import { requestImageModeration } from "@mono/content-moderation";
import { uploadFileToS3, deleteFileFromS3 } from "@mono/storage";
import { revalidatePath } from "next/cache";

export async function uploadAndModerateImage(formData: FormData) {
  const file = formData.get("image") as File;

  if (!file) {
    return { error: "未选择文件" };
  }

  // 验证文件类型
  const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
  if (!allowedTypes.includes(file.type)) {
    return { error: "只支持 JPG、PNG、WebP 格式" };
  }

  // 验证文件大小(最大 10MB)
  if (file.size > 10 * 1024 * 1024) {
    return { error: "文件大小不能超过 10MB" };
  }

  try {
    // 上传文件
    const path = `uploads/${Date.now()}-${file.name}`;
    const buffer = Buffer.from(await file.arrayBuffer());

    await uploadFileToS3(path, {
      bucket: process.env.S3_BUCKET!,
      body: buffer,
      contentType: file.type,
    });

    const url = `${process.env.S3_PUBLIC_ENDPOINT}/${path}`;

    // 审核图片
    const moderation = await requestImageModeration(url, "content");

    if (!moderation.ok) {
      // 删除违规图片
      await deleteFileFromS3(path, {
        bucket: process.env.S3_BUCKET!
      });

      return {
        error: `图片审核失败: ${moderation.reason || "内容不符合规范"}`
      };
    }

    // 重新验证相关页面
    revalidatePath("/gallery");

    return { url };
  } catch (error) {
    console.error("Upload failed:", error);
    return { error: "上传失败,请稍后重试" };
  }
}

客户端组件:

// components/ImageUpload.tsx
"use client";

import { uploadAndModerateImage } from "@/app/actions/upload-image";
import { useState } from "react";

export function ImageUpload() {
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [imageUrl, setImageUrl] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setUploading(true);
    setError(null);

    const formData = new FormData(e.currentTarget);
    const result = await uploadAndModerateImage(formData);

    if (result.error) {
      setError(result.error);
    } else {
      setImageUrl(result.url!);
      alert("上传成功!");
    }

    setUploading(false);
  }

  return (
    <div className="space-y-4">
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="image" className="block text-sm font-medium">
            选择图片
          </label>
          <input
            type="file"
            id="image"
            name="image"
            accept="image/jpeg,image/png,image/webp"
            required
            className="mt-1 block w-full"
          />
        </div>

        <button
          type="submit"
          disabled={uploading}
          className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          {uploading ? "上传中..." : "上传图片"}
        </button>

        {error && (
          <div className="p-3 bg-red-50 text-red-700 rounded">
            {error}
          </div>
        )}
      </form>

      {imageUrl && (
        <div>
          <p className="text-sm text-gray-600 mb-2">上传成功:</p>
          <img
            src={imageUrl}
            alt="Uploaded"
            className="max-w-md rounded shadow"
          />
        </div>
      )}
    </div>
  );
}

案例 6: 异步审核

对于非关键内容,可以先允许上传,异步审核后处理。

// lib/async-moderation.ts
import { requestImageModeration } from "@mono/content-moderation";
import { prisma } from "@/lib/prisma";

export async function asyncModeratePost(postId: string) {
  const post = await prisma.post.findUnique({
    where: { id: postId },
    select: { images: true },
  });

  if (!post || !post.images.length) {
    return;
  }

  // 审核所有图片
  const results = await Promise.all(
    post.images.map(url => requestImageModeration(url, "content"))
  );

  // 检查是否有违规图片
  const hasViolation = results.some(r => !r.ok);

  if (hasViolation) {
    // 标记帖子为违规
    await prisma.post.update({
      where: { id: postId },
      data: {
        status: "hidden",
        moderationReason: "图片包含违规内容",
        moderatedAt: new Date(),
      },
    });

    // 通知用户
    // await notifyUser(post.userId, "您的帖子因包含违规内容已被隐藏");
  } else {
    // 标记为已审核
    await prisma.post.update({
      where: { id: postId },
      data: {
        status: "published",
        moderatedAt: new Date(),
      },
    });
  }
}

// 在创建帖子后调用
export async function createPostWithAsyncModeration(data: PostData) {
  const post = await prisma.post.create({ data });

  // 异步审核(不阻塞响应)
  asyncModeratePost(post.id).catch(error => {
    console.error("Async moderation failed:", error);
  });

  return post;
}

集成第三方审核服务

当前实现是一个占位符,返回 { ok: true }。要集成真实的审核服务,需要修改 packages/content-moderation/src/client.ts

集成腾讯云内容审核

// packages/content-moderation/src/client.ts
import { moderateImage } from "@mono/tencent-cloud";

export async function requestImageModeration(
  imageUrl: string,
  mode: ImageModerationMode = "content",
): Promise<ImageModerationResult> {
  if (!imageUrl || !/^https?:\/\//i.test(imageUrl)) {
    throw new Error("Invalid image url");
  }

  try {
    // 调用腾讯云图片审核
    const result = await moderateImage(imageUrl);

    if (result.Suggestion === "Pass") {
      return { ok: true };
    }

    // 提取违规原因
    const reasons = result.Label || "内容不符合规范";
    return { ok: false, reason: reasons };
  } catch (error) {
    console.error("Image moderation failed:", error);

    // 降级策略:审核服务失败时允许通过
    // 生产环境可能需要更保守的策略
    return { ok: true };
  }
}

集成阿里云内容安全

import AlibabaCloud from "@alicloud/pop-core";

const client = new AlibabaCloud({
  accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID!,
  accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET!,
  endpoint: "https://green.cn-shanghai.aliyuncs.com",
  apiVersion: "2018-05-09",
});

export async function requestImageModeration(
  imageUrl: string,
  mode: ImageModerationMode = "content",
): Promise<ImageModerationResult> {
  if (!imageUrl || !/^https?:\/\//i.test(imageUrl)) {
    throw new Error("Invalid image url");
  }

  const scenes = mode === "avatar"
    ? ["porn", "terrorism"]
    : ["porn", "terrorism", "ad", "live"];

  try {
    const result = await client.request(
      "ImageSyncScan",
      {
        RegionId: "cn-shanghai",
        ImageUrl: imageUrl,
        Scenes: scenes,
      },
      { method: "POST" }
    );

    const suggestion = result.Data[0].Results[0].Suggestion;

    if (suggestion === "pass") {
      return { ok: true };
    }

    return {
      ok: false,
      reason: result.Data[0].Results[0].Label
    };
  } catch (error) {
    console.error("Image moderation failed:", error);
    return { ok: true }; // 降级策略
  }
}

审核策略

头像审核

头像审核通常更严格,建议检测:

  • ✅ 色情内容
  • ✅ 暴力血腥
  • ✅ 违法违规
  • ✅ 政治敏感
  • ✅ 恶心内容
const result = await requestImageModeration(avatarUrl, "avatar");

内容审核

内容审核可以相对宽松:

  • ✅ 色情内容
  • ✅ 暴力血腥
  • ✅ 违法违规
  • ⚠️ 广告营销(可选)
  • ⚠️ 二维码(可选)
const result = await requestImageModeration(imageUrl, "content");

降级策略

当审核服务不可用时,需要选择合适的降级策略:

乐观策略(默认通过)

适用于:社区内容、用户评论等非关键场景

try {
  return await requestImageModeration(url, mode);
} catch (error) {
  console.error("Moderation service unavailable:", error);
  // 允许上传,后续人工审核
  return { ok: true };
}

保守策略(默认拒绝)

适用于:用户头像、公开展示的内容等关键场景

try {
  return await requestImageModeration(url, mode);
} catch (error) {
  console.error("Moderation service unavailable:", error);
  // 拒绝上传,提示用户稍后重试
  return { ok: false, reason: "审核服务暂时不可用,请稍后重试" };
}

API 参考

requestImageModeration(imageUrl, mode?)

请求图片内容审核。

参数:

  • imageUrl (string): 图片 URL,必须是完整的 http/https URL
  • mode (ImageModerationMode, 可选): 审核模式,默认 "content"
    • "avatar" - 头像审核模式(更严格)
    • "content" - 内容审核模式

返回: Promise<ImageModerationResult>

interface ImageModerationResult {
  ok: boolean;      // 是否通过审核
  reason?: string;  // 未通过时的原因
}

抛出异常:

  • 如果 imageUrl 为空或格式无效,抛出 Error

示例:

// 基础用法
const result = await requestImageModeration("https://example.com/image.jpg");

// 指定审核模式
const result = await requestImageModeration(
  "https://example.com/avatar.jpg",
  "avatar"
);

// 错误处理
try {
  const result = await requestImageModeration(imageUrl);
  if (!result.ok) {
    console.log("审核失败:", result.reason);
  }
} catch (error) {
  console.error("请求失败:", error.message);
}

最佳实践

1. 先上传后审核

// ✅ 推荐:先上传到临时位置
const tempUrl = await uploadToTemp(file);
const result = await requestImageModeration(tempUrl);
if (result.ok) {
  await moveToFinal(tempUrl);
} else {
  await deleteTemp(tempUrl);
}

// ❌ 避免:审核通过后再上传(用户体验差)
const result = await requestImageModeration(localFileUrl);
if (result.ok) {
  await upload(file);
}

2. 批量审核优化

// ✅ 并发审核
const results = await Promise.all(
  urls.map(url => requestImageModeration(url))
);

// ❌ 串行审核(慢)
for (const url of urls) {
  await requestImageModeration(url);
}

3. 缓存审核结果

// 避免重复审核相同图片
const cacheKey = `moderation:${imageUrl}`;
const cached = cache.get(cacheKey);

if (cached) {
  return cached;
}

const result = await requestImageModeration(imageUrl);
cache.set(cacheKey, result, 24 * 60 * 60); // 缓存 24 小时

4. 监控和日志

const startTime = Date.now();

try {
  const result = await requestImageModeration(imageUrl, mode);
  const duration = Date.now() - startTime;

  // 记录审核结果
  await db.moderationLog.create({
    data: { imageUrl, mode, passed: result.ok, duration },
  });

  return result;
} catch (error) {
  console.error("Moderation failed:", { imageUrl, mode, error });
  throw error;
}

5. 用户友好的错误提示

const result = await requestImageModeration(imageUrl);

if (!result.ok) {
  // ❌ 不友好
  return { error: result.reason };

  // ✅ 友好
  return {
    error: "图片包含不适当内容,请更换后重试",
    details: result.reason, // 可选,用于调试
  };
}

故障排查

URL 格式错误

// ❌ 错误
await requestImageModeration("/uploads/image.jpg");
await requestImageModeration("image.jpg");

// ✅ 正确
await requestImageModeration("https://example.com/uploads/image.jpg");

审核服务超时

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
  const result = await requestImageModeration(url);
  clearTimeout(timeoutId);
  return result;
} catch (error) {
  if (error.name === "AbortError") {
    return { ok: false, reason: "审核超时" };
  }
  throw error;
}

审核结果不准确

如果审核结果不符合预期:

  1. 检查审核模式是否正确(avatar vs content)
  2. 确认第三方服务配置正确
  3. 查看审核服务的详细日志
  4. 考虑调整审核策略或阈值

环境变量

如果集成第三方审核服务,需要配置相应的环境变量:

# 腾讯云内容审核
TENCENT_SECRET_ID=your-secret-id
TENCENT_SECRET_KEY=your-secret-key

# 阿里云内容安全
ALIYUN_ACCESS_KEY_ID=your-access-key-id
ALIYUN_ACCESS_KEY_SECRET=your-access-key-secret

相关资源