13.2 客服、社区与舆情的分工边界

客服、社区与舆情的分工边界不是一张组织架构图,而是一张可执行的路由表:一条事件进来,按类型与严重度决定谁负责、SLA 多久、什么时候必须同步给投放与变现;本节只做分工声明与接口,不展开客服社区舆情本身,并配可跑的 Python 分流仿真与 TypeScript 内核对拍。

站内游戏产业专题 有 81 篇,把客服体系怎么搭、社区怎么运营、舆情怎么处置这件事本身讲透了——话术模板、社区活动、危机公关的流程都在那里。本节不重复它们,只做一件那 81 篇不做的事:分工声明 + 与投放变现的接口。也就是说,本节不教你「客服话术怎么写」,只回答「一条事件进来,它归谁、多久必须响应、什么时候必须同步给投放和变现」。站内《上线变现与长线运营》 则负责「活动与版本节奏」;本节与两者都不重叠,它是一张路由表,不是一本运营手册。

分工边界的本质:一条路由,不是一张组织图

大多数团队把「分工」画成组织架构图:谁向谁汇报、谁管哪块。但架构图在事件进来那一刻帮不上忙——玩家半夜发来一条「充值没到账」,架构图不会告诉你该由谁在几分钟内接住。真正有用的分工,是一条路由规则:

给定事件(eventType, severity) →
    命中路由表里最特异的一条(eventType 精确 + 严重度门槛),
    得到 owner(谁负责)+ slaMinutes(多久内首次响应)
    + notifyUa / notifyMonetize(是否必须同步投放/变现)
    无命中 → 回落默认(support,24 小时)

路由表把「谁负责」从人际默契变成可枚举的数据:事件漏接时能定位是规则没覆盖还是人没执行,这正是本节与 game/industry 那 81 篇的分工。

六个 owner,各有边界

owner负责范围不碰什么
support 客服一对一玩家问题:付费失败、退款、崩溃反馈不定义舆情口径
community 社区运营站内/社区内容与氛围:内容违规不做一对一工单
pr 舆情公开平台口碑:差评、负面舆情不直接回复单个玩家
ua 投放 / monetize 变现广告素材、付费链路的升级问题只接同步,不接玩家
compliance 合规作弊、内容违规的升级判定不做日常响应

注意 ua 与 monetize——它们不是一线响应方,而是「必须被同步」的一方。这就是与投放变现的接口:广告投诉升级了,投放要知道(可能是素材违规);付费失败升级了,变现要知道(可能是支付通道问题)。路由表里 notifyUa / notifyMonetize 两个布尔字段,就是把这层接口写进数据。

严重度门槛:同一类事件,severity 不同则归属不同

同一类事件,严重度不同,该找的人也不同。一条「广告投诉」若是零星抱怨,客服按标准流程回;若是大面积集中投诉,投放要去查素材、变现要评估频次——所以路由规则带一个严重度门槛(minSeverity),并用特异性打分消解冲突:

score(rule) = (eventType 精确 ? 4 : 0) + 严重度值(minSeverity: low=0 / med=1 / high=2)
resolve(eventType, severity) = 命中规则里 score 最大者(并列取规则表靠前者)

minSeverity 越高代表规则越特异,所以「高风险专用规则」会压过「通用规则」——这正是我们想要的:严重度越高,事件越往专业角色(投放/变现/合规)升级,而不是永远卡在客服。

S 层仿真:把事件矩阵全部路由一遍

下面这段是 S 层仿真脚本 sim/o13_routing_matrix.py。它读 config/o13_routing.json,把「事件类型 × 严重度」的 21 个组合全部解析一遍,统计覆盖率、默认兜底、SLA 上界,以及「必须同步投放/变现」的接口面,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o13_routing_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o13_routing_matrix.py --config config/ --out out/
"""客服/社区/舆情事件分流路由矩阵仿真:读 config/o13_routing.json,把「事件类型 × 严重度」
在路由表上全部解析一遍,统计覆盖率、默认兜底、SLA 上界与「同步投放/变现」的接口面。

  打分(rule) = (eventType 精确 ? 4 : 0) + minSeverity 值(low=0/med=1/high=2)
  resolve(eventType, severity) = 命中里分最高者(并列取靠前者);无命中 → default(support)
口径:真实组织分工、真实 SLA、真实值班安排需真实业务环境,命令行不可复现(X)。
产出 out/o13_routing_matrix.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations

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

SEV_VALUE = {"low": 0, "med": 1, "high": 2}
FIELDS = ("eventType", "severity", "owner", "slaMinutes", "notifyUa", "notifyMonetize",
          "ruleId", "source")


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


def score_rule(r: dict, event_type: str) -> int:
    """特异性打分:eventType 精确 +4;minSeverity 越高越特异(low=0/med=1/high=2)。"""
    return (0 if r["eventType"] == "*" else 4) + SEV_VALUE[r["minSeverity"]]


def resolve(cfg: dict, event_type: str, severity: str) -> dict:
    sv = SEV_VALUE[severity]
    best, best_score = None, -1
    for r in cfg["routes"]:
        if r["eventType"] != "*" and r["eventType"] != event_type:
            continue
        if SEV_VALUE[r["minSeverity"]] > sv:
            continue
        s = score_rule(r, event_type)
        if s > best_score:
            best, best_score = r, s
    if best is None:
        d = cfg["default"]
        return {"eventType": event_type, "severity": severity, "owner": d["owner"],
                "slaMinutes": d["slaMinutes"], "notifyUa": d["notifyUa"],
                "notifyMonetize": d["notifyMonetize"], "ruleId": "", "source": "default"}
    return {"eventType": event_type, "severity": severity, "owner": best["owner"],
            "slaMinutes": best["slaMinutes"], "notifyUa": best["notifyUa"],
            "notifyMonetize": best["notifyMonetize"], "ruleId": best["id"], "source": "remote"}


def draw_svg(path: str, rows: list, event_types: list, sev_count: int) -> int:
    """纯 Python SVG:每个事件类型一根横条 = 远端命中组合数 / 总组合数。"""
    W, H, PAD, ROW = 720, 74 + 40 * len(event_types), 170, 40
    total = sev_count  # 每个事件类型按严重度展开的组合数
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
                                "fill": "#111827"}).text = "事件分流:各类型远端命中 vs 默认兜底"
    span = W - PAD - 90
    for i, et in enumerate(event_types):
        remote = sum(1 for r in rows if r["eventType"] == et["id"] and r["source"] == "remote")
        y = 52 + i * ROW
        w = span * remote // total
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                    "fill": "#374151"}).text = et["name"]
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
                                    "height": "18", "rx": "3", "fill": "#16a34a"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 20), "font-size": "11",
                                    "fill": "#6b7280"}).text = f"远端 {remote} / {total}"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


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)
    rows = [resolve(cfg, et["id"], sev) for et in cfg["eventTypes"] for sev in cfg["severities"]]
    remote = sum(1 for r in rows if r["source"] == "remote")
    notify_ua = sum(1 for r in rows if r["notifyUa"])
    notify_mon = sum(1 for r in rows if r["notifyMonetize"])
    distinct_owners = sorted({r["owner"] for r in rows})
    max_sla = max(r["slaMinutes"] for r in rows)

    digest = (f"combos={len(rows)},remote={remote},default={len(rows) - remote},"
              f"maxSlaMin={max_sla},notifyUa={notify_ua},notifyMonetize={notify_mon},"
              f"distinctOwners={len(distinct_owners)}")

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o13_routing_matrix.svg"), rows, cfg["eventTypes"],
                   len(cfg["severities"]))
    with open(os.path.join(a.out, "o13_routing_matrix.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(FIELDS)
        for r in rows:
            w.writerow([r[k] for k in FIELDS])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "combos": len(rows), "remote": remote, "default": len(rows) - remote,
           "coveragePermille": remote * 1000 // len(rows), "maxSlaMin": max_sla,
           "notifyUa": notify_ua, "notifyMonetize": notify_mon, "distinctOwners": distinct_owners,
           "digest": digest, "matrix": rows, "csv": os.path.join(a.out, "o13_routing_matrix.csv"),
           "svg": os.path.join(a.out, "o13_routing_matrix.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o13_routing_matrix.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "combos": len(rows), "remote": remote,
                      "default": len(rows) - remote, "coveragePermille": rep["coveragePermille"],
                      "maxSlaMin": max_sla, "notifyUa": notify_ua, "notifyMonetize": notify_mon,
                      "distinctOwners": distinct_owners}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

路由表放在 config/o13_routing.json。真实组织分工、真实 SLA、真实值班安排属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "客服/社区/舆情事件分流路由表。口径是「行业惯例」的建模假设,不是任何团队的真实值班表。每条 route 匹配 eventType + 严重度门槛(minSeverity);特异性打分 = eventType 精确 4 分 + 严重度值(low=0/med=1/high=2),取分最高者、并列取靠前者;无命中回落 default(owner=support)。slaMinutes=首次响应时限(分钟);notifyUa/notifyMonetize=是否同步投放/变现(与投放变现的接口)。真实组织分工、真实 SLA、真实值班安排需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "owners": ["support", "community", "pr", "ua", "monetize", "compliance"],
  "severities": ["low", "med", "high"],
  "eventTypes": [
    { "id": "pay_fail", "name": "付费失败/掉单" },
    { "id": "ad_complain", "name": "广告投诉" },
    { "id": "refund", "name": "退款请求" },
    { "id": "cheat", "name": "作弊/刷量举报" },
    { "id": "content", "name": "内容违规" },
    { "id": "review_bomb", "name": "差评/舆情" },
    { "id": "crash", "name": "崩溃反馈" }
  ],
  "default": { "owner": "support", "slaMinutes": 1440, "notifyUa": false, "notifyMonetize": false },
  "routes": [
    { "id": "pay-any", "eventType": "pay_fail", "minSeverity": "low", "owner": "support", "slaMinutes": 120, "notifyUa": false, "notifyMonetize": true },
    { "id": "pay-high", "eventType": "pay_fail", "minSeverity": "high", "owner": "monetize", "slaMinutes": 30, "notifyUa": false, "notifyMonetize": true },
    { "id": "ad-any", "eventType": "ad_complain", "minSeverity": "low", "owner": "support", "slaMinutes": 240, "notifyUa": true, "notifyMonetize": false },
    { "id": "ad-high", "eventType": "ad_complain", "minSeverity": "high", "owner": "ua", "slaMinutes": 60, "notifyUa": true, "notifyMonetize": true },
    { "id": "refund-any", "eventType": "refund", "minSeverity": "low", "owner": "support", "slaMinutes": 1440, "notifyUa": false, "notifyMonetize": true },
    { "id": "cheat-any", "eventType": "cheat", "minSeverity": "med", "owner": "compliance", "slaMinutes": 480, "notifyUa": true, "notifyMonetize": true },
    { "id": "content-any", "eventType": "content", "minSeverity": "low", "owner": "community", "slaMinutes": 240, "notifyUa": false, "notifyMonetize": false },
    { "id": "content-high", "eventType": "content", "minSeverity": "high", "owner": "compliance", "slaMinutes": 60, "notifyUa": false, "notifyMonetize": false },
    { "id": "bomb-any", "eventType": "review_bomb", "minSeverity": "low", "owner": "pr", "slaMinutes": 360, "notifyUa": true, "notifyMonetize": true },
    { "id": "bomb-high", "eventType": "review_bomb", "minSeverity": "high", "owner": "pr", "slaMinutes": 120, "notifyUa": true, "notifyMonetize": true }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o13_routing_matrix.py --config config/ --out out/,路由矩阵的结果是(同机相对量级,绝对数值不可移植):21 个组合里 17 个命中远端规则、4 个落默认兜底(覆盖率 809‰);maxSlaMin=1440(退款默认 24 小时最宽);distinctOwners=6。几个典型解析结果:

事件类型严重度归属SLA(分)同步投放同步变现
付费失败 pay_failhighmonetize30否是
广告投诉 ad_complainhighua60是是
作弊举报 cheatlowsupport(兜底)1440否否

这张表把分工讲成了可验证的规则。三个观察:其一,pay_fail 与 ad_complain 在 low 时都归客服,high 时才升级给变现/投放——同类型事件的归属随严重度移动,这是路由表的精髓。其二,notifyMonetize=12 / notifyUa=8 说明有 12 个组合必须同步变现、8 个必须同步投放——接口不是「有需要再说」,而是规则里写死的。其三,cheat 的 low 严重度与 crash 全部落兜底:前者因为门槛设为 med(零星举报不值得惊动合规),后者因为压根没配规则。兜底不是漏洞,是显式声明「这类先归客服」。

P 层内核:跨语言对拍,证明路由表没写错

一张表看不出「是路由对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o13/routing.ts 复算同一套路由逻辑:同样的特异性打分、同样的门槛过滤、同样的兜底。它读同一份 config/o13_routing.json,产出与 Python 逐字符一致的摘要。

// file: core/src/o13/routing.ts
// tier: P
// verify: node --test 'core/test/o13_*.test.ts'
//
// 事件分流路由内核:把「事件类型 × 严重度」解析成「谁负责 + SLA + 是否同步投放/变现」。
// 与 sim/o13_routing_matrix.py 逐位等价的 TypeScript 对偶实现;读同一份 config/o13_routing.json。
// 全整数、无浮点、无 Math.random。

export interface Route {
  readonly id: string; readonly eventType: string; readonly minSeverity: string;
  readonly owner: string; readonly slaMinutes: number;
  readonly notifyUa: boolean; readonly notifyMonetize: boolean;
}
export interface RoutingConfig {
  readonly version: number; readonly game: string;
  readonly owners: readonly string[]; readonly severities: readonly string[];
  readonly eventTypes: readonly { readonly id: string; readonly name: string }[];
  readonly default: { readonly owner: string; readonly slaMinutes: number;
    readonly notifyUa: boolean; readonly notifyMonetize: boolean };
  readonly routes: readonly Route[];
}
export interface RoutedEvent {
  readonly eventType: string; readonly severity: string; readonly owner: string;
  readonly slaMinutes: number; readonly notifyUa: boolean; readonly notifyMonetize: boolean;
  readonly ruleId: string; readonly source: string;
}

export const SEV_VALUE: Record<string, number> = { low: 0, med: 1, high: 2 };

/** 特异性打分:eventType 精确 +4;minSeverity 越高越特异(low=0/med=1/high=2)。 */
export function scoreRule(rule: Route): number {
  return (rule.eventType === "*" ? 0 : 4) + SEV_VALUE[rule.minSeverity];
}

/** 解析一条事件该路由给谁;无命中回落 default(owner = support)。 */
export function resolveEvent(cfg: RoutingConfig, eventType: string, severity: string): RoutedEvent {
  const sv = SEV_VALUE[severity];
  let best: Route | null = null;
  let bestScore = -1;
  for (const r of cfg.routes) {
    if (r.eventType !== "*" && r.eventType !== eventType) continue;
    if (SEV_VALUE[r.minSeverity] > sv) continue;
    const s = scoreRule(r);
    if (s > bestScore) { best = r; bestScore = s; }
  }
  if (best === null) {
    const d = cfg.default;
    return { eventType, severity, owner: d.owner, slaMinutes: d.slaMinutes,
      notifyUa: d.notifyUa, notifyMonetize: d.notifyMonetize, ruleId: "", source: "default" };
  }
  return { eventType, severity, owner: best.owner, slaMinutes: best.slaMinutes,
    notifyUa: best.notifyUa, notifyMonetize: best.notifyMonetize, ruleId: best.id, source: "remote" };
}

/** 枚举 eventType × severity 的完整路由矩阵。 */
export function enumerateMatrix(cfg: RoutingConfig): RoutedEvent[] {
  const rows: RoutedEvent[] = [];
  for (const et of cfg.eventTypes) {
    for (const sev of cfg.severities) rows.push(resolveEvent(cfg, et.id, sev));
  }
  return rows;
}

/** 跨语言对拍摘要:与 sim/o13_routing_matrix.py 的 digest 逐字符一致。 */
export function digest(rows: readonly RoutedEvent[]): string {
  const remote = rows.filter((r) => r.source === "remote").length;
  const notifyUa = rows.filter((r) => r.notifyUa).length;
  const notifyMon = rows.filter((r) => r.notifyMonetize).length;
  const distinctOwners = new Set(rows.map((r) => r.owner)).size;
  const maxSla = Math.max(0, ...rows.map((r) => r.slaMinutes));
  return `combos=${rows.length},remote=${remote},default=${rows.length - remote},`
    + `maxSlaMin=${maxSla},notifyUa=${notifyUa},notifyMonetize=${notifyMon},`
    + `distinctOwners=${distinctOwners}`;
}

配套测试 core/test/o13_routing.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验严重度门槛、默认兜底与同步接口。在本机跑 node --test 'core/test/o13_*.test.ts',6 个用例全过;两套独立实现给出同一个摘要:

ℹ tests 6   ℹ pass 6   ℹ fail 0
digest = combos=21,remote=17,default=4,maxSlaMin=1440,notifyUa=8,notifyMonetize=12,distinctOwners=6

只要有一边的打分权重、门槛方向、兜底或并列取法写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。

X 层:真实分工与 SLA 不可复现

边界必须划清。上面算的是规则侧:给定路由表,事件归属与 SLA 是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的组织分工与人员配置、真实的 SLA 达成率与升级执行(规则写了 30 分钟,实际响应可能几小时;该同步投放/变现时是否真同步了)。

工程上正确的做法是:把可复现的路由规则与不可复现的执行现实分开——规则层用来回答「这条事件该归谁、多久响应、同步给谁」,执行层用来在复盘时反标定(把实际响应时间与漏接事件填回表,暴露哪条规则是摆设)。分工边界最危险的错觉,是把「规则写清楚了」当成「事件不会漏」。

自检表

检查项合格标准
归属确定每个事件类型 × 严重度都有明确 owner,无「看情况」
接口写死必须同步投放/变现的组合在规则里标出,不靠群里 @

路由表管的是「事件进来时」的分派。但事件要有人接、有人做,还得有团队本身——自己人怎么排、外包怎么用、远程怎么协作。下一节我们处理这件事里最容易被低估的部分:外包与远程怎么跟上投放的节奏。

阅读导航:上一节:13.1 运营 SOP 与例会机制 · 下一节:13.3 团队、外包与远程协作 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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