← 返回文档导航
状态 设计评审中
定位 ECA 规则属性(通用)
影响面 仅后端 core service
发版 不发 App / H5
关联 ECA 引擎 + 账号注销
有些活动定义「
每个用户只能参加一次」——新人券、首单礼、限领券、抽奖限次……
这是
活动/规则自己的属性,不是「新人券」一个功能的特例。
但平台支持
账号注销 · 全链路物理删除:注销后用同一手机号
重注册 = 全新
userId、全新数据 → 「限一次」被绕过。
所以「限参加次数」不能绑 userId,要绑跨账号稳定身份(手机号哈希),并靠一张注销不删的去重账本兜底。
本文把它定义为
ECA 引擎的
通用能力:任何规则都能声明「每身份限 N 次」,新人券只是其一。
1 · 根因:限次绑了 userId
ECA 现有「每人次数」(见 规则引擎 §9)是 count by rule + user ≤ max_issue_per_user —— 限次锚点是 userId。
漏洞链:注销 = 全链路物理删除(连 activity_campaign_reward_records、risk_events 都删)→ 痕迹清零 → 同手机号重注册 = 全新 userId → 「每人次数」重新计 → 限一次的活动可反复参加。
本质:只要限次锚点会随注销消失,「每身份只能一次」就必然能被「注销→重注册」绕过。
2 · 核心原则
把「该身份已参加过此活动」绑到跨账号、注销也不消失的稳定身份(手机号)上,而不是绑账号(userId)。
账号会被物理删除、会重建;稳定身份不会变。限次必须挂在不变量上。
3 · 它是 ECA 的规则属性(配在活动定义里)
不新造一个「新人券」子系统,而是给规则/活动加一组「参加资格」字段,把现有 max_issue_per_user 从「按账号」扩展出「按身份」作用域:
| 字段 | 取值 | 说明 |
participation_scope | PER_USER(默认)/ PER_IDENTITY | PER_USER = 现状(绑 userId);PER_IDENTITY = 跨账号限次(绑手机号哈希,注销重注册照样算)。 |
participation_limit | 整数,默认 1 | 该作用域内允许参加几次。「只能参加一次」= 1。 |
participation_scope_key | 默认 = campaignId | 限次的「范围键」。默认整个活动算一份额度;可配成共享「参加组」,让多个活动合计一次(如多个新人活动合计只领一次)。 |
identity_type | 默认 PHONE | 锚点类型。手机号哈希为主,后续可叠加实名/设备。 |
「新人券」= 一条 participation_scope=PER_IDENTITY、limit=1 的规则,没有任何特殊逻辑;首单礼、限领券、抽奖限 3 次,都是同一套字段的不同取值。运营在活动后台直接配,引擎统一执行。
4 · 身份锚点选型(注销+重注册仍指向同一个人)
| 锚点 | 强度 | 说明 | 采集端 |
| 手机号 | 主选 | 注册必经短信验证,最稳的去重键。哈希前先归一化(去空格 / 统一 +62 区号 / 去前导 0)。 | 后端(注册已有) |
| 实名 / 绑卡(提现账户) | 最强 | 一个身份证 / 一张收款卡一份额度。用在「钱真正出去」的提现环节做终极去重。 | 后端(提现已有) |
| 设备指纹 / 设备 ID | 辅助 | 叠加风控信号;不能单用(家庭共用 / 二手机会误伤)。 | App native(要发版) |
本期主用手机号哈希(纯后端、零发版);设备指纹后续叠加;强校验下沉到提现用实名/绑卡兜底。
5 · 去重账本 campaign_participation_ledger
一张与用户表解耦的参加记录账本,只记「某身份 × 某活动(范围键)参加过几次」,不存任何明文 PII。
CREATE TABLE campaign_participation_ledger (
id VARCHAR(64) PRIMARY KEY,
country_code VARCHAR(8) NOT NULL, -- 一国一份额度
identity_type VARCHAR(16) NOT NULL, -- PHONE / IDCARD / PAYOUT_ACCOUNT / DEVICE
identity_hash VARCHAR(64) NOT NULL, -- HMAC(secret, 归一化手机号),不存明文
scope_key VARCHAR(64) NOT NULL, -- 限次范围键,默认 campaignId;可配「参加组」
participate_count INT NOT NULL DEFAULT 1, -- limit>1 时累加
first_at DATETIME NOT NULL,
last_at DATETIME NOT NULL,
ex_user_id VARCHAR(64) NULL, -- 最近一次参加的 userId(审计;注销后失效但保留)
source_ref VARCHAR(128) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_identity_scope (identity_type, identity_hash, scope_key, country_code)
);
| 设计点 | 说明 |
| 唯一索引 + 计数 | 每个(身份 × 范围键 × 国家)一行,靠唯一索引兜并发;participate_count 累加,count ≥ participation_limit 即拦。limit=1 就是「只能一次」。 |
| scope_key = 范围键 | 默认 campaignId(整个活动一份额度)。要让多个活动共享一份(如「新人福利」系列合计一次),配成同一个 scope_key。 |
| 只存哈希 | 仅 identity_hash(加盐 HMAC)+ 范围键 + 国家 + 时间,无明文、无可识别个人信息。 |
6 · 判定流程(引擎统一执行)
flowchart TD
FIRE["规则触发(任意动作:发券/现金/抽奖…)"]:::in --> SCOPE{"规则 participation_scope?"}:::decision
SCOPE -->|"PER_USER(默认)"| OLD["按现有 max_issue_per_user(count by rule+user)"]:::step
SCOPE -->|"PER_IDENTITY"| ANCHOR["取身份锚点
phoneHash = HMAC(secret, 归一化手机号)"]:::step
ANCHOR --> CHK{"账本 count(scope_key, identity_hash, country)
≥ participation_limit ?"}:::decision
CHK -->|"是(已达上限/已参加过)"| SKIP["拦截 · 不执行动作"]:::skip
CHK -->|"否"| RUN["执行动作 + 账本 upsert(count+1)"]:::ok
OLD --> RUN2["照常执行"]:::ok
classDef in fill:#eff6ff,stroke:#3b82f6,color:#1e40af;
classDef step fill:#ffffff,stroke:#e8d9b8,color:#1a1410;
classDef decision fill:#fef3c7,stroke:#e07c00,color:#7a4f24,stroke-width:2px;
classDef skip fill:#fff7ed,stroke:#fb923c,color:#9a3412;
classDef ok fill:#f0fdf4,stroke:#15803d,color:#15803d;
接入点:把 PER_IDENTITY 限次做成动作执行前的内置前置条件(与现有 max_issue_per_user 同位),参加时即写账本 —— 账本天生独立于账号生命周期,注销逻辑不用特意处理。「是不是新用户/能不能参加」从「userId 是新建的」改成「账本未达上限」。
7 · 与「全链路物理删除」的关系(关键)
现注销口径是
全链路物理删除,会删掉领券/参加痕迹。
若不为参加账本开例外,本方案就被注销自己废掉了。
结论:campaign_participation_ledger 是物理删除的唯一例外 —— 注销不删它。
注销删的是「可识别的个人账号数据」;参加账本只留脱敏哈希 + 范围键,不含可识别个人信息,属于反欺诈最小留存,是删除权的合法例外。
flowchart LR
DEL["账号注销
全链路物理删除"]:::danger --> A["users / 订单 / 钱包
风控 / 活动奖励 …全部物理删除"]:::danger
DEL --> B["campaign_participation_ledger
保留(脱敏哈希,无 PII)"]:::ok
B --> C["重注册同手机号
查账本命中 → 限次规则照常拦"]:::ok
classDef danger fill:#fff5f5,stroke:#dc2626,color:#991b1b,stroke-width:2px;
classDef ok fill:#f0fdf4,stroke:#15803d,color:#15803d;
8 · 纵深防御(防换号绕过)
| 层 | 做法 | 说明 |
| 参加层(宽) | 手机号哈希账本去重 | 虚拟权益,先用最便宜的手机号锚点挡住绝大多数。 |
| 提现层(严) | 实名 / 绑卡账户去重 | 钱真正出去前的终极校验:一个实名/收款账号只能提一次该活动收益。换号也绕不过实名。 |
| 风控层 | 设备指纹 / IP / 频率规则 | 同设备短时间多次注册参加 → 标记拦截。后续叠加。 |
最强校验放在提现环节:参加是虚拟的、可以宽松提转化;提现是真金白银、必须严。即使绕过参加,钱也拿不走。
9 · 合规(ID 有 PDP 法,注销要删个人数据)
- 账本只存加盐哈希(HMAC),不存明文手机号 —— 既能去重,又是「已脱敏的反欺诈最小记录」。
- 反欺诈所需数据的留存通常是「删除权」的合法例外,前提是数据最小化 + 脱敏:只留
identity_hash + scope_key + 国家 + 计数/时间。
- HMAC 密钥按环境隔离、走配置中心,不入库不入码。
10 · 发布范围
纯后端,不发 App / H5。能不能参加是服务端判定,前端只展示结果。改动全在 core service:规则加参加资格字段 + 建账本表 + 动作前置判定 + 注销开例外。走后端流水线即可,不进应用商店审核、不发 H5 bundle。
| 仅当要… | 才需发版 |
| 加设备指纹锚点 | App(指纹 native 采集) |
| 给用户专门的「已参加过」提示文案 | H5(很小;多半可让后端回 message 复用现有展示) |
| 提现新采实名 / 绑卡信息 | H5 / App 表单 |
11 · 验收点
| 场景 | 期望结果 |
| 限一次活动 · 首次参加 | 账本未命中 → 执行动作 → 账本写入 count=1。 |
| 同手机号注销后重注册再参加 | 账本命中(identity_hash 相同、count≥limit)→ 拦截,不再发放。 |
| 同号不同写法(+62 / 0 前缀) | 归一化后哈希一致 → 仍命中,拦截。 |
| limit=3 的限次活动 | 同身份第 4 次被拦,前 3 次放行。 |
| 注销执行物理删除 | 用户全链路数据删除,唯独 campaign_participation_ledger 保留。 |
| 并发重复触发 | 唯一索引兜底,计数正确,不超发。 |
PER_USER 普通规则 | 不受影响,照现有 max_issue_per_user 逻辑执行。 |
示例:「新人券」= participation_scope=PER_IDENTITY / limit=1 / scope_key=该活动;「首单返现礼」「限领 1 张券」同理,只是不同活动各配一行,无需任何专属代码。