← 返回文档导航

活动「每身份限参加 N 次」· 跨账号去重设计

活动级「限参加次数」是规则自己的属性 · 限次绑稳定身份(非 userId)· 注销重注册防绕过 · ECA 通用能力
状态 设计评审中 定位 ECA 规则属性(通用) 影响面 仅后端 core service 发版 不发 App / H5 关联 ECA 引擎 + 账号注销
有些活动定义「每个用户只能参加一次」——新人券、首单礼、限领券、抽奖限次…… 这是活动/规则自己的属性,不是「新人券」一个功能的特例。 但平台支持账号注销 · 全链路物理删除:注销后用同一手机号重注册 = 全新 userId、全新数据 → 「限一次」被绕过。 所以「限参加次数」不能绑 userId,要绑跨账号稳定身份(手机号哈希),并靠一张注销不删的去重账本兜底。 本文把它定义为 ECA 引擎通用能力:任何规则都能声明「每身份限 N 次」,新人券只是其一。
目录 1 根因· 2 核心原则· 3 规则属性(配在活动里)· 4 身份锚点选型· 5 去重账本· 6 判定流程· 7 与物理删除的关系· 8 纵深防御· 9 合规· 10 发布范围· 11 验收点

1 · 根因:限次绑了 userId

ECA 现有「每人次数」(见 规则引擎 §9)是 count by rule + usermax_issue_per_user —— 限次锚点是 userId

漏洞链:注销 = 全链路物理删除(连 activity_campaign_reward_recordsrisk_events 都删)→ 痕迹清零 → 同手机号重注册 = 全新 userId → 「每人次数」重新计 → 限一次的活动可反复参加
本质:只要限次锚点会随注销消失,「每身份只能一次」就必然能被「注销→重注册」绕过。

2 · 核心原则

把「该身份已参加过此活动」绑到跨账号、注销也不消失的稳定身份(手机号)上,而不是绑账号(userId)。 账号会被物理删除、会重建;稳定身份不会变。限次必须挂在不变量上。

3 · 它是 ECA 的规则属性(配在活动定义里)

不新造一个「新人券」子系统,而是给规则/活动加一组「参加资格」字段,把现有 max_issue_per_user 从「按账号」扩展出「按身份」作用域:

字段取值说明
participation_scopePER_USER(默认)/ PER_IDENTITYPER_USER = 现状(绑 userId);PER_IDENTITY = 跨账号限次(绑手机号哈希,注销重注册照样算)。
participation_limit整数,默认 1该作用域内允许参加几次。「只能参加一次」= 1
participation_scope_key默认 = campaignId限次的「范围键」。默认整个活动算一份额度;可配成共享「参加组」,让多个活动合计一次(如多个新人活动合计只领一次)。
identity_type默认 PHONE锚点类型。手机号哈希为主,后续可叠加实名/设备。
「新人券」= 一条 participation_scope=PER_IDENTITYlimit=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 法,注销要删个人数据)

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 张券」同理,只是不同活动各配一行,无需任何专属代码。