分销系统最容易出事的不是技术,是传销红线:我把 15 条护栏写进了代码里

1 阅读13分钟

代码写错了,顶多返工一周。

分销系统写错了,是罚款,是刑责。

这不是吓唬人。《禁止传销条例》的刑事门槛是"30 人以上且 3 层以上";2026 年 5 月的修订征求意见稿把组织策划的罚款上限提到 500 万元,主管人员 3 年行业禁入。而二级分销(层级 ≤ 2)明确属于合法经营。

所以整套系统真正难的技术活不是架构,是让"层级不超过 2"这条规则无法被绕过。

难点在于:这条限制一旦只是配置项,它就是可改的。客户说"我就要三级,出事我负责"→ 销售为了成单给改了;运营误操作 → 三级跑起来了;二开同学不知道这条规则 → 顺手加了个"团队人数奖励"。

三种情况,殊途同归。

我的解法是:把合规做成架构,而不是配置。P0 级护栏直接做成 codeFreeze: true —— 代码冻结,运行时无法通过普通配置绕过,任何越界调用在领域层直接抛异常。

这篇讲 15 条护栏怎么落地、哪些改动会判定为高危,以及怎么在不破坏基线的前提下扩展自己的校验。

一、为什么要有「代码冻结」这种设计

先看法律依据(项目里锚定的 CB-LEGAL-2026-DIST-001):

  • 《禁止传销条例》:传销的刑事门槛是「30 人以上且 3 层以上」
  • 2026 年 5 月修订征求意见稿:组织策划罚款上限 500 万元,主管人员 3 年行业禁入
  • 二级分销(层级 ≤ 2)明确属于合法经营

结论:只要死死卡住「层级 ≤ 2」和「不许按人头计酬」,就落在合法安全港内。

问题在于:如果这条限制只是配置项,那它是可以被改的。

所以项目把 P0 级护栏做成 codeFreeze: true —— 代码冻结:运行时无法通过普通配置绕过,任何越界调用在领域层直接抛异常。

📌 这是这套系统最有价值的设计思想:合规不是配置,是架构。

这个区别可以用一个具体场景说明。假设护栏是配置项(下面是反例示意,项目里没有这段代码):

// ❌ 反例:假设护栏是配置驱动的
const MAX_LEVEL = config.maxDistributionLevel || 2;   // 配置文件里写 3 就变 3

那么它有 4 条泄露路径:改配置文件、读环境变量覆盖、加一个后台管理页能改它、某次 deploy 用了旧的环境变量。每一条路径都不需要恶意,只需要一次手滑。

而代码冻结的写法是(真实代码,domain/compliance-guardrails.js):

// ✅ 出厂常量,没有任何配置来源
export const MAX_DISTRIBUTION_LEVEL = 2;

要突破它,只能改源码。而改源码会过 code review、会留下 git 记录、会在测试里挂掉——compliance-guardrails.test.js 里有一行 assert.equal(MAX_DISTRIBUTION_LEVEL, 2);,常量被人改成 3,测试当场红。

二、15 项护栏清单

backend/src/domain/compliance-guardrails.js:

export const GUARDRAILS = [
  { id: 'G01', name: '分润层级出厂默认≤2级硬编码',        level: 'P0', codeFreeze: true,  ref: '护栏①' },
  { id: 'G02', name: '人头/邀请数计酬入口全链路屏蔽',      level: 'P0', codeFreeze: true,  ref: '护栏②' },
  { id: 'G03', name: '单级返利异常预警',                  level: 'P1', codeFreeze: false, ref: '护栏③' },
  { id: 'G04', name: '税务代扣/委托代征对接',              level: 'P1', codeFreeze: false, ref: '护栏④' },
  { id: 'G05', name: '累计120万市场主体登记提示',          level: 'P2', codeFreeze: false, ref: '护栏⑤' },
  { id: 'G06', name: '分级告知同意(注册/绑定/触达/画像)', level: 'P0', codeFreeze: true,  ref: '护栏⑥' },
  { id: 'G07', name: '算法推荐开关可关',                  level: 'P1', codeFreeze: false, ref: '护栏⑦' },
  { id: 'G08', name: '三级数据权限矩阵',                  level: 'P1', codeFreeze: false, ref: '护栏⑧' },
  { id: 'G09', name: '合规审计日志不可篡改',              level: 'P0', codeFreeze: true,  ref: '护栏⑨' },
  { id: 'G10', name: '品牌素材敏感词/绝对化用语过滤',      level: 'P0', codeFreeze: true,  ref: '护栏⑩' },
  { id: 'G11', name: '品类级限制与资质核验',              level: 'P1', codeFreeze: false, ref: '护栏⑪' },
  { id: 'G12', name: '违规举报+品牌商封禁',              level: 'P1', codeFreeze: false, ref: '护栏⑫' },
  { id: 'G13', name: '反传销合规承诺函上传',              level: 'P0', codeFreeze: true,  ref: '护栏⑬' },
  { id: 'G14', name: '数据出境安全评估(涉跨境触发)',      level: 'P0', codeFreeze: true,  ref: '护栏⑭' },
  { id: 'G15', name: '招募文案合规校验',                  level: 'P1', codeFreeze: false, ref: '护栏⑮' },
];

export const P0_GUARDRAIDS = GUARDRAILS.filter(g => g.level === 'P0').map(g => g.id);
export const CODE_FREEZE_GUARDRAIDS = GUARDRAILS.filter(g => g.codeFreeze).map(g => g.id);

小提示:仓库里这两个导出常量名是 *_GUARDRAIDS(GUARDRAILS 少个 L),而数组本身叫 GUARDRAILS。server.js 和测试文件都按这个拼写 import,抄代码时别自己"纠正"它,否则直接报 SyntaxError。

7 项 P0,全部代码冻结:G01 / G02 / G06 / G09 / G10 / G13 / G14。

级别数量含义
P07上线门禁,代码冻结,不可配置绕过
P17应实现,可配置
P21建议项

注意这个分级不是按"重要程度"排的,是按能不能被改排的。P0 是"碰了就是刑事风险",P1 是"碰了是经营风险",P2 是"建议做"。

所以 G07(算法推荐开关可关)、G12(违规举报+品牌商封禁)这些明显有业务价值的护栏是 P1 —— 它们可以做成配置,因为改错了顶多是功能缺陷。而 G01、G02 这种一旦突破就定性为传销的,只能是 P0。

三、冻结是怎么实现的

3.1 常量 + 断言函数

// 护栏①:分润层级上限,出厂常量锁定
export const MAX_DISTRIBUTION_LEVEL = 2;

// 护栏②:计酬基数仅限真实交易;人头因子禁止出现在任何配置
export const ALLOWED_COMPENSATION_BASES = ['real_order_amount', 'paid_amount'];
export const PROHIBITED_COMPENSATION_FACTORS = [
  'headcount', 'invite_count', 'team_size', 'recruit_count',
];

export class ComplianceViolation extends Error {
  constructor(guardrailId, message) {
    super(`[合规拦截 ${guardrailId}] ${message}`);
    this.name = 'ComplianceViolation';
    this.guardrailId = guardrailId;
    this.guardrail = guardrailId;   // 统一字段名,兼容两种访问方式
    this.status = 422;
  }
}

export function assertWithinDistributionLevel(level) {
  if (level > MAX_DISTRIBUTION_LEVEL) {
    throw new ComplianceViolation('G01',
      `分销层级 ${level} 超过出厂上限 ${MAX_DISTRIBUTION_LEVEL},已硬编码阻断`);
  }
}

export function assertCompensationBase(base) {
  if (!ALLOWED_COMPENSATION_BASES.includes(base)) {
    throw new ComplianceViolation('G02',
      `计酬基数「${base}」不在允许范围(仅真实交易),已屏蔽`);
  }
}

export function assertNoProhibitedFactor(factors = []) {
  for (const f of factors) {
    if (PROHIBITED_COMPENSATION_FACTORS.includes(f)) {
      throw new ComplianceViolation('G02',
        `计酬因子「${f}」为人头类因子,已全链路屏蔽`);
    }
  }
}

G02 的实现特别值得说:它不只是校验"当前配置合不合法",而是用白名单 + 黑名单双向夹逼——ALLOWED_COMPENSATION_BASES 规定只能用真实交易额,PROHIBITED_COMPENSATION_FACTORS 又明确拉黑 4 种人头类因子。

为什么两个都要?因为白名单挡不住"新增的白名单项"。如果只靠黑名单,有人加一个 new_agent_count 就漏了;如果只靠白名单,有人往白名单里塞一个 invite_count 也漏了。双向夹逼意味着:加一个新计酬基数要过白名单(需要改源码),加人头因子过黑名单(也需要改源码)。

而 this.guardrailId 和 this.guardrail 两个字段并存,是为了兼容两种访问方式——上层 handleError() 读一个,外部集成读另一个。这看着是冗余,但在一个要给不同团队接的系统里,省掉的是联调时来回改字段名的沟通成本。

3.2 在领域层调用(不是路由层)

护栏的调用点在 domain/,不在 handler、更不在路由:

// domain/commission-engine.js —— 创建分润规则时
assertCompensationBase(rule.base);
assertNoProhibitedFactor(rule.factors || []);
if (rule.levels > MAX_DISTRIBUTION_LEVEL) {
  throw new ComplianceViolation('G01',
    `分润规则配置 ${rule.levels} 级,超过出厂上限 ${MAX_DISTRIBUTION_LEVEL}`);
}
// domain/agent-hierarchy.js —— 绑定代理上下级时
if (childId === parentId) {
  throw new ComplianceViolation('G01', '不能将代理绑定到自己');
}
if (hasParent(childId, relations)) {
  throw new ComplianceViolation('G01',
    `代理 ${childId} 已存在上级关系,禁止改绑以规避层级限制`);
}
if (wouldBe > MAX_DISTRIBUTION_LEVEL) {
  throw new ComplianceViolation('G01',
    `绑定将导致层级达到 ${wouldBe} 级,超过出厂上限 ${MAX_DISTRIBUTION_LEVEL}`);
}
assertWithinDistributionLevel(depthOf(child, relations));

📌 这条设计原则值得抄进你自己的项目:把不可协商的规则放在最内层(领域层),外层无论是 HTTP API、小程序、定时任务还是后台脚本,都绕不过去。

如果校验写在路由层,别人加一个新路由就绕过了;写在 handler 层,别人直接调领域函数就绕过了。

"写在 handler 层被别人直接调领域函数就绕过了"这句是重点:handler 是可选的入口,领域函数才是必经的入口。所有路径最终都要算佣金、都要算层级,那么把关点放在计算入口,就一网打尽。

agent-hierarchy.js 里那两个额外判断——不能绑自己、不能改绑——也是这个思路的延伸。改绑(把一个已有上级的代理改挂到别人下面)看起来是合法的"层级不超 2"操作,但它可能是为了规避层级限制:本来 A→B→C 是 3 层,改绑一下变成 2 层,中间那笔多赚的佣金就省掉了。所以规则直接禁掉改绑。

3.3 错误怎么返回

server.js 的统一错误处理:

function sendCompliance(res, status, guardrail, message, note) {
  const token = note || GUARD_ASCII[guardrail] || 'compliance-blocked';
  send(res, status, { error: message, guardrail }, { 'x-compliance': `${guardrail}:${token}` });
}

function handleError(res, err) {
  if (err?.status) {
    const g = err.guardrail || err.guardrailId || 'Gxx';
    return sendCompliance(res, err.status, g, err.message);
  }
  // 脱敏:仅记录错误类型和消息,不打印完整堆栈/对象
  console.error(`[server] 内部错误: ${err?.name || 'Error'}: ${err?.message || 'unknown'}`);
  return sendCompliance(res, 500, 'Gxx', '服务器内部错误');
}

响应:

HTTP/1.1 422 Unprocessable Entity
x-compliance: G01:compliance-blocked
Content-Type: application/json

{
  "error": "[合规拦截 G01] 分销层级 3 超过出厂上限 2,已硬编码阻断",
  "guardrail": "G01"
}

注意两个细节:

  1. 响应头 x-compliance —— 便于前端和网关识别合规拦截,做专门提示(不是普通报错)
  2. 错误日志脱敏 —— console.error 只打 name 和 message,不打印完整堆栈。生产日志里出现完整堆栈很容易泄露路径、依赖版本、甚至代码片段

第 1 点的意义在于用户体验。合规拦截不是"系统出错",是"这个操作我们不能做"。如果前端把它当普通 422 弹个红色 toast,用户会以为是 bug 并找客服;如果前端识别到 x-compliance 头,就显示"根据合规要求,分销层级不能超过 2 级"——这是业务规则说明,不是错误。

用 422 而不是 400 或 403 也是有讲究的:422 Unprocessable Entity 的语义是"语法没问题,但语义上处理不了",最贴合"你的请求格式正确,但业务规则不允许"。

四、二开禁区清单

下面这些地方改了会出事,按危险程度排序:

🔴 绝对不要碰

禁区文件 / 位置后果
MAX_DISTRIBUTION_LEVELdomain/compliance-guardrails.js层级突破 2 级,直接踩传销刑事门槛(30 人 + 3 层)
PROHIBITED_COMPENSATION_FACTORS 里加白名单同上人头计酬复活,定性为传销
ALLOWED_COMPENSATION_BASES 加非交易基数同上计酬脱离真实交易,即"拉人头返利"
删掉领域层的 assert 调用commission-engine.js / agent-hierarchy.js护栏形同虚设
把 codeFreeze: true 改成 falseGUARDRAILS 数组让 P0 变成可配置,等于开后门
审计日志可删改handlers/audit.jsG09 失效,出事无法自证清白
敏感词过滤开关handlers/compliance-config.jsG10 失效,绝对化用语直接触广告法

最后一条有个真实实现上的细节。handlers/audit.js 的注释写得很直白:

// 护栏⑨:合规审计日志(只追加,不可篡改)
// 内存数组模拟;导出的 appendAudit() 与 getAuditLog() 为唯二公开入口,
// 不导出任何 delete / update / mutate 能力 —— 满足不可篡改(codeFreeze: G09)。

"不导出 delete / update 能力"就是不可篡改的实现方式。不是加权限校验,是根本不提供这个 API。要删一条审计记录,你得先改 audit.js 的源码加一个 export function deleteAudit() —— 这次改动在 code review 里是藏不住的。

而且它返回的记录是 Object.freeze 过的:

// handlers/audit.js —— appendAudit():唯一写入点
export function appendAudit(entry) {
  if (!entry || typeof entry.action !== 'string' || !entry.action.trim()) {
    throw new TypeError('audit entry requires string `action`');
  }
  const record = Object.freeze({
    id: `AUD-${String(++seq).padStart(6, '0')}`,
    ts: new Date().toISOString(),
    action: entry.action,
    actor: entry.actor ?? null,
    detail: entry.detail ? safeDeepClone(entry.detail) : {},
    guardrail: entry.guardrail ?? null,
  });
  auditLog.push(record); // 唯一写入点
  return record;
}

Object.freeze 让调用方拿到记录后改不动——哪怕它拿到了引用。而读取侧 getAuditLog() 返回的也是冻结副本:

export function getAuditLog() {
  return Object.freeze(auditLog.map((r) => Object.freeze({ ...r, detail: safeDeepClone(r.detail) })));
}

写侧冻结 + 读侧冻结 + 不提供删除 API,三层加起来才是完整的"不可篡改"。

🟡 需要非常谨慎

位置风险建议
auth/middleware.js 的 PUBLIC_ROUTES加太多公开接口 = 数据裸奔每个公开接口都要过一遍"这个字段能公开吗"
PortalController::ensureToken() 的硬编码 admin门户权限 = 管理员权限上线前必改,并考虑用只读专用账号
佣金计算逻辑算错直接是钱的问题改完必须跑 commission-engine.test.js 和相关用例
订单状态流转状态错乱导致重复支付/漏发状态变更统一走既有函数,不要直接改字段
跨域配置 CORS_ORIGIN默认 *生产收敛到具体域名

🟢 安全区(随便改)

  • 新增非分润类的业务模块(店铺、仓库、物流、品类……)
  • UI / 主题 / 文案 / 多语言
  • 新增报表、统计维度
  • 新增支付方式(走 payment.js 抽象)
  • 新增通知渠道(短信、企微、邮件)
  • 新增小程序页面

安全区的判据是你的改动不碰钱、不碰层级、不碰人头的定义。

五、正确的扩展姿势

5.1 加一条自己的合规校验

不要改 GUARDRAILS 数组(那是基线),在 domain/ 下新建文件,按同样的模式写:

// domain/my-guardrails.js
import { ComplianceViolation } from './compliance-guardrails.js';

/** G16:单笔订单金额上限(品牌商自定义风控,非法定护栏) */
export const MAX_ORDER_AMOUNT = 50000;

export function assertOrderAmount(amount) {
  if (amount > MAX_ORDER_AMOUNT) {
    throw new ComplianceViolation('G16',
      `单笔订单金额 ${amount} 超过风控上限 ${MAX_ORDER_AMOUNT}`);
  }
}

然后在被调用方领域层调用:

// domain/commission-engine.js 或你的业务模块
import { assertOrderAmount } from './my-guardrails.js';

export function calculateCommission(order) {
  assertOrderAmount(order.amount);
  // ...
}

要点:

  1. 复用 ComplianceViolation —— 这样错误会走统一的 422 + x-compliance 响应
  2. 用新的 G 编号(G16 起),不要占用 G01–G15
  3. 在 domain/ 层调用,不是路由层
  4. 补测试,断言它真的会抛

第 2 点不只是洁癖。G 编号是对外的错误契约——前端会按 guardrail 字段做不同的提示文案,运维会按编号查日志。占用已有编号会导致两种不同的拦截返回同一个编号,排查时根本分不清是哪条规则。

5.2 需要"可配置"的合规项怎么办

有些客户确实需要不同的阈值(比如订单金额上限)。正确做法是配置项 + 上下界硬编码:

import { config } from '../config.js';

// 配置可改,但硬编码上下界,防止配出离谱的值
const MIN_LIMIT = 1000;
const MAX_LIMIT = 200000;

export function getOrderAmountLimit() {
  const v = parseInt(process.env.ORDER_AMOUNT_LIMIT ?? '', 10);
  if (Number.isNaN(v)) return 50000;              // 默认
  return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, v));   // 夹紧
}

核心思想:允许调参,但不允许突破边界。级别数是"能不能"的问题,不是"多少"的问题 —— 前者冻结,后者可配。

Math.min(MAX, Math.max(MIN, v)) 这个"夹紧"写法比 if 判断优雅,而且永远不会漏。你写 if 的话,加第三个边界条件时很容易忘了改某一处。

5.3 加审计

所有写操作都要留痕:

import { appendAudit } from './audit.js';

export function createXxx(body) {
  // ...
  appendAudit({
    action: 'xxx.created',
    detail: { id: item.id, name: item.name },
  });
  return item;
}

命名约定:{模块}.{动作},如 store.created / order.paid / agent.bound。

六、怎么验证护栏还在工作

项目里有专门的测试文件 src/tests/compliance-guardrails.test.js:

node --test src/tests/compliance-guardrails.test.js

改完任何涉及层级、计酬、权限的代码,第一件事就是跑它。

上线前跑全量:

cd backend
node --test "src/tests/*.test.js"

当前基线:785 个用例。其中护栏专项 7 个用例,佣金引擎、代理层级、跨境数据这些也各有专项。

如果你改完之后用例数量变少或有 fail,说明动了不该动的地方。

一个真实案例:tests/forecast.test.js 里有个库存预警用例,它把订单日期写死在 2026-08-01 ~ 2026-08-30。而 buildProductTimeSeries() 只统计最近 30 天的订单——时间一过 9 月,这些订单就掉出窗口,销量曲线变成 0,预警自然不触发,断言 totalWarnings > 0 就挂了。

这类日期敏感的测试是零依赖项目特别容易踩的坑:没有 mock 时间,就只能拿真实时间当锚点。修法是让测试数据相对当前时间生成(比如 Date.now() - i * 86400000),而不是写死日期。

也可以手工打一发验证:

# 尝试创建 3 级分润规则(应被拒)
curl -X POST http://localhost:3000/api/commission/rules \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"三级分润","rate":0.1,"base":"real_order_amount","levels":3,"factors":[]}'

# 期望:422 + x-compliance: G01:...

这条 curl 我建议每次改动后都手工跑一遍,而不是只依赖单元测试。单元测试验证的是函数级行为,curl 验证的是整条链路(中间件识别 → 领域层抛错 → 统一错误处理 → 响应头)。中间任何一环漏了,单元测试照样全绿。

七、写给二开同学的一句话

这套系统的合规设计看起来"碍手碍脚" —— 你想加个"邀请 5 人奖励"被拦,想做三级分销被拦。

但正是这些拦截,让使用它的人不会坐牢。

如果你的客户真的要求突破这些限制,正确的回答不是"改代码",而是"这个需求有法律风险,我们换个方案实现同样的业务目标"。

举个怎么"换个方案"的例子:客户想要"拉新奖励"。合规的做法不是"按人头计酬",而是"按被推荐人首单成交额的一个固定比例计酬"——推荐人拿的是交易佣金,不是人头费。业务效果接近,法律性质完全不同。这中间的差别就是 ALLOWED_COMPENSATION_BASES 和 PROHIBITED_COMPENSATION_FACTORS 在做的事。

总结

这套合规设计可以浓缩成三句话:

**合规不能靠配置。**配置会被改,代码冻结改起来会留下痕迹、会过不了测试。

**合规必须在领域层。**路由和 handler 都是可选入口,领域层是所有路径的必经之路。

合规必须可验证。GUARDRAILS 数组 + assert* 函数 + 专项测试 + x-compliance 响应头 + Gxx 编号,构成一条从定义到执行的完整链路。

**而且它应该是可扩展的。**你自己的风控规则按同样的模式加进 domain/,用新的 G 编号,复用同一个 ComplianceViolation——既不污染基线,又能享受统一的错误处理。

最后提醒一句:这套护栏保护的不只是"系统",是用它做生意的人。别觉得它是障碍。


项目已开源,代码可自取:atomgit.com/pangzi_zi/d…

采用 Apache 2.0 许可证,可自由使用、修改与商用。