代码写错了,顶多返工一周。
分销系统写错了,是罚款,是刑责。
这不是吓唬人。《禁止传销条例》的刑事门槛是"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。
| 级别 | 数量 | 含义 |
|---|---|---|
| P0 | 7 | 上线门禁,代码冻结,不可配置绕过 |
| P1 | 7 | 应实现,可配置 |
| P2 | 1 | 建议项 |
注意这个分级不是按"重要程度"排的,是按能不能被改排的。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"
}
注意两个细节:
- 响应头
x-compliance—— 便于前端和网关识别合规拦截,做专门提示(不是普通报错) - 错误日志脱敏 ——
console.error只打name和message,不打印完整堆栈。生产日志里出现完整堆栈很容易泄露路径、依赖版本、甚至代码片段
第 1 点的意义在于用户体验。合规拦截不是"系统出错",是"这个操作我们不能做"。如果前端把它当普通 422 弹个红色 toast,用户会以为是 bug 并找客服;如果前端识别到 x-compliance 头,就显示"根据合规要求,分销层级不能超过 2 级"——这是业务规则说明,不是错误。
用 422 而不是 400 或 403 也是有讲究的:422 Unprocessable Entity 的语义是"语法没问题,但语义上处理不了",最贴合"你的请求格式正确,但业务规则不允许"。
四、二开禁区清单
下面这些地方改了会出事,按危险程度排序:
🔴 绝对不要碰
| 禁区 | 文件 / 位置 | 后果 |
|---|---|---|
MAX_DISTRIBUTION_LEVEL | domain/compliance-guardrails.js | 层级突破 2 级,直接踩传销刑事门槛(30 人 + 3 层) |
PROHIBITED_COMPENSATION_FACTORS 里加白名单 | 同上 | 人头计酬复活,定性为传销 |
ALLOWED_COMPENSATION_BASES 加非交易基数 | 同上 | 计酬脱离真实交易,即"拉人头返利" |
| 删掉领域层的 assert 调用 | commission-engine.js / agent-hierarchy.js | 护栏形同虚设 |
把 codeFreeze: true 改成 false | GUARDRAILS 数组 | 让 P0 变成可配置,等于开后门 |
| 审计日志可删改 | handlers/audit.js | G09 失效,出事无法自证清白 |
| 敏感词过滤开关 | handlers/compliance-config.js | G10 失效,绝对化用语直接触广告法 |
最后一条有个真实实现上的细节。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);
// ...
}
要点:
- 复用
ComplianceViolation—— 这样错误会走统一的 422 +x-compliance响应 - 用新的 G 编号(G16 起),不要占用 G01–G15
- 在
domain/层调用,不是路由层 - 补测试,断言它真的会抛
第 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 许可证,可自由使用、修改与商用。