10.2 内容安全与审核红线

把内容安全做成可配置的分级处置:一条带类别、等级与词库的 D 层红线规则表,一段对文案、游戏名、活动标题做命中扫描并给出 pass / review / block 三档处置的 S 层脚本,以及一份运行时同款的 P 层过滤内核与六条断言,让「拦截还是人工复核」由规则而非心情决定。

10.1 讲的是「主体有没有资格上线」,本节讲**「内容能不能过审」。游戏名、买量文案、活动标题、关卡名、玩家可自定义的昵称与聊天——每一处文本都可能踩到平台红线。本节只做一件事:把「什么算红线、命中后怎么办」变成一张可配置的规则表加一段可跑的扫描脚本**,让处置结果可复现、可断言。真实平台的审核红线、真实审核结果与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。

先说分工。站内那篇小游戏开发综述 里有审核合规口径的综述段,列出「哪些内容平台不让过」;本节不重复它,而是把同一类口径拆成类别 + 等级 + 词库三件套,并用脚本把「扫描 → 命中 → 分级处置」跑成一条流水线。规则表是容器,词库是内容,脚本负责让两者一致。

红线不是「敏感词表」,而是分级处置

最常见的错误做法是维护一张巨大的敏感词表,命中就拦。这有两个问题。第一,红线是分等级的:涉政、涉黄、涉暴、赌博、侵权属于硬红线,命中即拦(block);迷信、诱导分享、虚假宣传属于灰线,命中应降级为人工复核(review),而不是直接拦——把「分享给好友领皮肤」这种常见运营话术一刀切拦掉,会误伤正常活动。第二,处置要与来源挂钩:同样的词出现在玩家昵称里和出现在官方活动标题里,风险不同,处理节奏也不同。所以本节的规则不是「一张词表」,而是「每条规则带类别与等级」,再由内核把一组命中归并成一个处置档位。

处置档位只有三档,与 10.1 的清单一样追求可判定:pass(可发布)、review(需人工复核)、block(拦截)。归并规则很硬:任一 block 命中即 block,否则任一 review 命中即 review,都没有才是 pass。这条「取最高」的规则让结果与命中顺序无关,可复现。

规则表与待检文案(D 层)

下面这段是 D 层静态配置:八类红线规则(每类带等级与示例词库)与六段待检文案。类别、等级与词库都是示例归纳;真实平台审核红线与最终判定需专业法务判断,命令行不可复现(X)。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "内容安全红线规则表(D 层)。类别、处置等级与词库均为示例归纳,用于离线可复现扫描;真实平台审核红线、真实审核结果与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。命中判定为子串匹配(大小写归一),仅演示分级处置口径。",
  "severityLevels": ["pass", "review", "block"],
  "rules": [
    {"id": "r-political", "category": "涉政", "severity": "block", "patterns": ["领导人", "敏感事件", "政治口号"]},
    {"id": "r-porn", "category": "涉黄", "severity": "block", "patterns": ["色情", "裸聊", "成人视频"]},
    {"id": "r-violence", "category": "涉暴", "severity": "block", "patterns": ["血腥", "砍杀真人", "恐怖袭击"]},
    {"id": "r-gambling", "category": "赌博", "severity": "block", "patterns": ["现金红包", "押注", "赌局", "真钱"]},
    {"id": "r-ip", "category": "侵权", "severity": "block", "patterns": ["宝可梦", "马里奥", "迪士尼"]},
    {"id": "r-superstition", "category": "迷信", "severity": "review", "patterns": ["转运", "开光", "算命"]},
    {"id": "r-induce-share", "category": "诱导分享", "severity": "review", "patterns": ["分享给", "转发到", "邀请好友才能", "关注公众号"]},
    {"id": "r-false-ad", "category": "虚假宣传", "severity": "review", "patterns": ["稳赚不赔", "一键暴富", "百分百中奖", "全网最低"]}
  ],
  "texts": [
    {"id": "t-name", "kind": "游戏名", "text": "合成小岛:一键暴富,稳赚不赔"},
    {"id": "t-adcopy", "kind": "买量文案", "text": "点击分享给3位好友,即可领取限定皮肤"},
    {"id": "t-level", "kind": "关卡名", "text": "珊瑚礁合成挑战 · 第 12 关"},
    {"id": "t-ip", "kind": "素材标题", "text": "官方授权宝可梦合成玩法"},
    {"id": "t-event", "kind": "活动文案", "text": "幸运大转盘,赢取现金红包和转运符"},
    {"id": "t-clean", "kind": "新手引导", "text": "拖动两个相同岛屿即可合成更大的岛屿"}
  ]
}

用 S 层脚本把六段文案扫成处置分布

下面这段是 S 层仿真脚本,读上面那份配置,对每段文案做大小写归一的子串匹配,记录命中项与首次出现位置,再按「取最高」归并出处置档位,汇总成「按档位 / 按类别」的分布,产出 JSON + CSV + SVG。它离线跑,不依赖任何平台接口,因此同一份输入永远得到同一份输出——这正是把审核判断变成工程流程的前提。

#!/usr/bin/env python3
# file: sim/l10_redline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l10_redline.py --config config/ --out out/
"""内容安全红线扫描:把规则表应用到一批文案,给出分级处置(整数、可复算)。

读 config/l10_redline.json:规则(类别 / 等级 / 词库)+ 待检文案。
1) 命中:对每条规则的每个词做大小写归一子串匹配,记录首次出现位置;
2) 处置:任一 block 命中 -> block;否则任一 review 命中 -> review;否则 pass;
3) 汇总:按类别、按处置、按文案类型统计命中与拦截率。
规则与词库均为示例;真实平台审核红线与最终合规判定需真实业务环境与专业法务判断,
命令行不可复现(X)。产出 out/l10_redline.json + out/l10_redline.csv + out/l10_redline.svg。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET

RANK = {"pass": 0, "review": 1, "block": 2}
DECISION_COLOR = {"pass": "#10b981", "review": "#f59e0b", "block": "#dc2626"}


def load(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def scan_text(text: str, rules: list[dict]) -> list[dict]:
    hay = text.lower()
    hits = []
    for rule in rules:
        for p in rule["patterns"]:
            idx = hay.find(p.lower())
            if idx >= 0:
                hits.append({"ruleId": rule["id"], "category": rule["category"],
                             "severity": rule["severity"], "pattern": p, "index": idx})
    hits.sort(key=lambda h: (h["index"], h["ruleId"]))
    return hits


def decide(hits: list[dict]) -> str:
    top = "pass"
    for h in hits:
        if RANK[h["severity"]] > RANK[top]:
            top = h["severity"]
    return top


def categories(hits: list[dict]) -> list[str]:
    seen: list[str] = []
    for h in hits:
        if h["category"] not in seen:
            seen.append(h["category"])
    return seen


def txt(parent: ET.Element, x: float, y: float, s: str, size: int = 12,
        fill: str = "#374151", anchor: str | None = None) -> None:
    a = {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}
    if anchor:
        a["text-anchor"] = anchor
    ET.SubElement(parent, "text", a).text = s


def svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每段文案一根横条,长度 = 命中数,颜色 = 处置。"""
    W, PAD, ROW = 720, 210, 40
    H = 70 + ROW * len(rows)
    r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                           "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    txt(r, 16, 26, "《合成小岛》文案红线扫描(命中数 / 处置,示例词库)", 15, "#111827")
    bmax = W - PAD - 110
    hi = max((len(x["hits"]) for x in rows), default=1) or 1
    for i, row in enumerate(rows):
        y = 70 + i * ROW
        txt(r, 16, y + 20, f'{row["kind"]}')
        w = bmax * len(row["hits"]) // hi
        ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
                                  "height": "20", "rx": "4",
                                  "fill": DECISION_COLOR[row["decision"]]})
        txt(r, PAD + w + 8, y + 21, f'{len(row["hits"])} · {row["decision"]}', 12, "#6b7280")
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config, "l10_redline")
    rules = cfg["rules"]
    rows = []
    for t in cfg["texts"]:
        hits = scan_text(t["text"], rules)
        rows.append({"id": t["id"], "kind": t["kind"], "text": t["text"], "hits": hits,
                     "hitCount": len(hits), "decision": decide(hits),
                     "categories": categories(hits)})
    by_decision = {d: sum(1 for r in rows if r["decision"] == d) for d in RANK}
    by_category: dict[str, int] = {}
    for r in rows:
        for c in r["categories"]:
            by_category[c] = by_category.get(c, 0) + 1
    blocked = by_decision["block"]
    review = by_decision["review"]
    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "l10_redline.svg"), rows)
    report = {"source": "sim/l10_redline.py", "tier": "S", "game": cfg["game"],
              "observedAt": cfg["observedAt"], "note": cfg["note"],
              "ruleCount": len(rules), "textCount": len(rows), "byDecision": by_decision,
              "blockRatePermille": blocked * 1000 // len(rows),
              "reviewRatePermille": review * 1000 // len(rows),
              "byCategory": by_category, "rows": rows, "svgBytes": sb}
    with open(os.path.join(a.out, "l10_redline.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "l10_redline.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "kind", "hitCount", "decision", "categories", "patterns"])
        for r in rows:
            w.writerow([r["id"], r["kind"], r["hitCount"], r["decision"],
                        "|".join(r["categories"]), "|".join(h["pattern"] for h in r["hits"])])
    for r in rows:
        pats = ",".join(h["pattern"] for h in r["hits"]) or "-"
        print(f'{r["kind"]:<6} {r["decision"]:<6} 命中{r["hitCount"]} [{pats}]')
    print(f'byDecision={by_decision} blockRate={report["blockRatePermille"]}permille '
          f'reviewRate={report["reviewRatePermille"]}permille')
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机跑出来的输出(同机相对量级,绝对数值不可移植):

游戏名    review 命中2 [一键暴富,稳赚不赔]
买量文案   review 命中1 [分享给]
关卡名    pass   命中0 [-]
素材标题   block  命中1 [宝可梦]
活动文案   block  命中2 [现金红包,转运]
新手引导   pass   命中0 [-]
byDecision={'pass': 2, 'review': 2, 'block': 2} blockRate=333permille reviewRate=333permille

六段文案的处置分布是 pass 2 / review 2 / block 2。素材标题与活动文案因涉侵权词与赌博词被判 block,游戏名与买量文案因虚假宣传词与诱导分享词被判 review。注意活动文案里「转运」单独看只是迷信(review),但和「现金红包」同段出现时被 block 覆盖——取最高的归并规则让两个风险叠加成一个更严的处置,符合直觉。

P 层:与运行时同款的过滤内核

扫描脚本服务的是离线批检(发布前扫一遍所有文案);玩家实时输入(昵称、聊天)则要一段能嵌进运行时的内核。下面这段是 P 层过滤内核,与 S 层脚本用同一套「类别 + 等级 + 取最高」语义,但只做纯函数:给定文本与规则配置,返回命中项、处置档位与去重类别。一份规则,两处消费——离线批检与运行时拦截不会跑出两套口径。

// file: core/src/l10/filter.ts
// tier: P
// verify: node --test 'core/test/l10_*.test.ts'
//
// 内容安全过滤内核:把一批「红线规则」应用到一段文本,产出命中项并给出分级处置。
// 处置等级:任一 block 命中 -> block;否则任一 review 命中 -> review;否则 pass。
// 规则可配置(类别 / 等级 / 子串词库),内核不碰任何平台 API,可离线、可单测、可播种。
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器),无 Math.random。

export type Severity = "pass" | "review" | "block";

export interface RedlineRule {
  readonly id: string;
  readonly category: string;
  readonly severity: "review" | "block";
  readonly patterns: readonly string[];
}

export interface RedlineConfig {
  readonly rules: readonly RedlineRule[];
}

export interface Hit {
  readonly ruleId: string;
  readonly category: string;
  readonly severity: "review" | "block";
  readonly pattern: string;
  readonly index: number;
}

export interface ScanResult {
  readonly text: string;
  readonly hits: readonly Hit[];
  readonly decision: Severity;
  readonly categories: readonly string[];
}

const RANK: Record<Severity, number> = { pass: 0, review: 1, block: 2 };

/** 大小写归一的子串扫描:对每条规则的每个词,找首次出现位置(找不到为 -1)。 */
export function scanText(text: string, cfg: RedlineConfig): Hit[] {
  const hay = text.toLowerCase();
  const hits: Hit[] = [];
  for (const rule of cfg.rules) {
    for (const p of rule.patterns) {
      const idx = hay.indexOf(p.toLowerCase());
      if (idx >= 0) {
        hits.push({ ruleId: rule.id, category: rule.category, severity: rule.severity,
          pattern: p, index: idx });
      }
    }
  }
  hits.sort((a, b) => (a.index - b.index) || (a.ruleId < b.ruleId ? -1 : 1));
  return hits;
}

/** 取最高处置等级:block > review > pass。 */
export function decide(hits: readonly Hit[]): Severity {
  let top: Severity = "pass";
  for (const h of hits) if (RANK[h.severity] > RANK[top]) top = h.severity;
  return top;
}

/** 去重后的命中类别,按首次出现顺序。 */
export function hitCategories(hits: readonly Hit[]): string[] {
  const seen: string[] = [];
  for (const h of hits) if (!seen.includes(h.category)) seen.push(h.category);
  return seen;
}

/** 完整扫描:命中 + 处置 + 类别。 */
export function scan(text: string, cfg: RedlineConfig): ScanResult {
  const hits = scanText(text, cfg);
  return { text, hits, decision: decide(hits), categories: hitCategories(hits) };
}

/** 批量扫描,返回每段文本的结果。 */
export function scanAll(texts: readonly string[], cfg: RedlineConfig): ScanResult[] {
  return texts.map((t) => scan(t, cfg));
}

/** 是否放行:只有 pass 才允许直接发布;review 需人工复核;block 直接拦截。 */
export function isPublishable(decision: Severity): boolean {
  return decision === "pass";
}

配套的 P 层测试覆盖六条边界——干净文本 pass、block 优先于 review、仅 review 降级复核、多类别去重且按出现顺序、空命中为 pass 且命中位置升序、批量扫描处置分布与 S 层一致。node --test 'core/test/l10_*.test.ts' 六条全部通过,证明运行时内核与离线脚本口径一致。

真实审核红线不可复现(X)

上面八类规则是公开可观察机制的归纳,观察时点 2026-10。真实平台上,某个词到底算不算违规、审核结果是过还是拒、申诉能不能翻案,都属于不可复现的真实业务过程,命令行跑不出来,标 X。本节给的是一套分级处置的骨架:真实法务或平台方给出的红线填进 rules,severity 决定拦截还是复核,内核保证「取最高」的归并可复现。任何具体审核结论都必须以平台实际反馈与专业法务判断为准,脚本只保证处置逻辑自洽、离线可断言。

阅读导航:上一节:10.1 资质、备案与平台规则 · 下一节:10.3 未成年人保护与防沉迷 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制