Files
codex-bot b43043255c
Docker test deployment / test-build-deploy (push) Successful in 4m44s
Add versioned event registry and transactional feature rules
2026-09-16 04:46:17 +08:00

11 KiB
Raw Permalink Blame History

Identity Resolution / Identity Stitching

标识和可信边界

  • anonymous_id:服务端随机 UUID,浏览器用持久化的 HttpOnly Cookie 携带,移动端用签名 token 携带。
  • master_id:身份连通分量的规范 ID。匿名访问时已有一个 Master ID,登录关联后可保留它,或归并到已有账号的 Master ID。
  • 强身份:验证后的邮箱、国际格式手机号、命名空间下的 OAuth subject / 第三方 ID。
  • MAID、设备指纹、IP 和 Cookie 只表示设备/访问连续性,不用于自动合并两个已知账号。本实现生成随机匿名 ID,不生成设备指纹,也不伪造系统 MAID。

POST /v1/identity/stitch 只接受可信后端的 Bearer service token。 调用方必须先完成邮箱/手机号 OTP 验证,或校验 OAuth 令牌的签名、issuer、audience、subject 和已验证邮箱声明,再把已验证标识传入。 请求体没有可以由浏览器自行声明的 verified=true 字段。未验证的留资邮箱应先作为临时属性存储,完成验证后再添加强身份连线。 当前实现提供内部 Stitch 接口和鉴权,不包含验证码发送、密码登录或具体 OAuth provider 回调。

SERVICE_TOKEN 只放在可信服务端。它是单个部署/租户的管理凭据,不能交给广告商、开发者、浏览器或第三方 SDK。 移动端匿名 token 仅证明匿名访问 ID 的持有,不是登录令牌。应保存到应用安全存储。

图谱模型

匿名设备 A ─┐
            ├─ alias → Master A ← 已验证 Email X
匿名设备 B ─┘                    ← 已验证 Phone Y
                                 ← OAuth (issuer/client, subject)

邮箱、手机号、OAuth/第三方 ID 唯一性键包含租户和标识类型;OAuth/第三方 ID 额外包含 provider 命名空间。 命名空间应包含 issuer 与 client/app 作用域,避免不同 provider 或 pairwise subject 命名冲突。 邮箱使用 email-validator 规范化域名,保留 local-part 大小写;不擅自套用 Gmail 去点、去加号规则。 手机号必须以 + 国家码输入,验证后规范化为 E.164。 ID 查找键是带服务端 secret 的 HMAC-SHA256,不保存邮箱/手机号/OAuth subject 的原文;图谱 API 只展示脱敏摘要。 用户画像属性与事件属性仍是业务提供的 JSON 数据。

TENANT_ID 在部署中配置,不从客户端可任意指定的请求头读取。一个应用实例服务一个租户。 同一个数据库可以容纳不同部署实例的多个租户,查询和唯一约束均带 tenant_id。

Stitch 流程

  1. 首次访问调用 /v1/identity/anonymous,或直接发送 /v1/events;系统创建签名 ID 并设置 Cookie。
  2. 匿名事件绑定原始节点,偏好标签/临时属性由可信后端通过用户画像 PATCH 写入。
  3. 登录验证成功后,可信后端携带匿名 token 和全部已验证标识调用 /v1/identity/stitch。
  4. 若任意强身份已经连接到 Master ID,归并匿名分量;多个强身份可以传递连接多个分量。
  5. 同一事务中更新所有别名、合并稀疏属性和标签、记录连接证据,并写入画像投影 outbox。
  6. 事件保留原始节点。查询通过别名映射返回当前 Master ID 的全部历史,无需等待后台批量重写事件。

邮箱、手机号或 OAuth 任意一个可信连接即可解析到同一 Master ID。/resolve 仅查询,不隐式合并;多个 ID 解析到不同账号时返回 ambiguous=true,由已验证的 Stitch 请求明确连接。

合并目标优先使用可信后端提供的 account_master_id;否则使用已连接强身份中最早创建的账号;没有匹配项时保留当前匿名 Master ID。 原有目标账号的冲突属性优先,匿名属性填补缺失字段;请求中明确提供的属性最后覆盖。标签去重合并。 PATCH 的属性值 null 表示删除字段;Stitch 中 null 被省略。嵌套对象中空值也被省略。 输入允许 200 个顶层属性、64 KiB 属性文档,单次标签最多 100 个、累计最多 500 个。 如果合并后超过限制,返回 409 并整体回滚,不静默丢弃数据。

旧 Master ID 保留为别名,因此旧链接和旧事件仍能解析到新的规范账号。

共享浏览器 / 账号切换

如果 Cookie 已归属账号 A,而输入的强身份和 A 没有交集,系统创建新匿名节点再连接 B,返回 rotated_anonymous_id=true,不会把 A 的历史给 B。 对已有账号新增手机或 OAuth 标识时,可信后端传入当前已认证账号的 account_master_id。 退出登录应调用 /v1/identity/anonymous?rotate=true,随后使用返回的新 token / Cookie。 若 Stitch 由登录后端代发,该后端需要将返回 Cookie 设置到同一 API 域,或将返回的匿名 token 交回客户端更新;不要让客户端继续使用旧账号的 token。

API

方法和路径 调用方 用途
POST /v1/identity/anonymous 浏览器 / 移动端 创建或复用匿名 ID;rotate=true 重置
POST /v1/events 浏览器 / 移动端 接收事件;缺少 ID 时自动创建
POST /v1/identity/stitch 可信后端 已验证标识与匿名 ID 拼接
POST /v1/identity/resolve 可信后端 按已验证标识解析 Master ID
GET /v1/identity/{master_id}/graph 可信后端 图谱成员、脱敏标识和合并连线
GET /v1/identity/storage 可信后端 查看实际部署存储设置,不返回凭据
GET /v1/users/{master_id} 可信后端 画像、标签、历史行为计数
PATCH /v1/users/{master_id} 可信后端 更新临时/正式画像,空值删除字段
GET /v1/events?master_id=... 可信后端 全部关联行为,支持 limit / offset

首次加载页面时可以直接调用:

await fetch("https://YOUR_API_HOST/v1/identity/anonymous", {
  method: "POST",
  credentials: "include"
});

后端第一次收到匿名请求就签发 ID;React SDK 的 AdCloudProvider 在挂载时初始化,track 也会等待初始化完成。 浏览器请求需使用 credentials: "include"。匿名写接口检查 Origin;CORS 使用明确允许来源并启用凭据。 Cookie 默认 Secure、HttpOnly、SameSite=Lax,有效期默认一年;本地 HTTP 开发明确设置 COOKIE_SECURE=false。 跨站点 Cookie 如需 SameSite=None,必须同时启用 Secure。浏览器第三方 Cookie 策略可能阻止 Cookie,应优先将 API 放在第一方域名下。 不设置父域 Cookie,不通过第三方设备指纹跨站点识别用户。

默认事件接收要求注册并发布对应 app_id / name / event_version,携带应用 collection 或 server 凭据,以及 UUID event_id 和带时区的 occurred_at。 相同 ID 的不同主体或内容返回 409;未注册、未发布、参数不符或来源不合法会明确拒绝。详见 事件字典与规则。 普通画像属性继续按目标账号优先合并;标准特征由独立贡献记录按图谱成员汇总,因此身份缝合不覆盖数值、不重复加分。

Stitch 示例(只在 OTP/OAuth 验证后由可信后端执行):

{
  "anonymous_token": "服务端此前签发的 token",
  "identifiers": [
    {"kind": "email", "value": "alice@example.com"},
    {"kind": "phone", "value": "+14155552671"},
    {"kind": "oauth", "namespace": "https://issuer.example/client-id", "value": "verified-subject"}
  ],
  "attributes": {"preferred_category": "running"},
  "tags": ["runner"]
}

部署选择:JSONB / Cassandra 宽表

配置 身份图谱和规范画像 画像读取
本地 SQLite + relational SQLite JSON 关系库
PostgreSQL + relational PostgreSQL JSONB 关系库
PostgreSQL + cassandra PostgreSQL JSONB Cassandra 当前版本,缺失时回退 JSONB

选择 Cassandra 作为宽表后端,不额外引入 HBase 和 Bigtable 的运维/云依赖。 身份图谱需要跨节点唯一性和事务合并,保持关系库为唯一事实来源;本实现不支持完全移除关系库的纯 Cassandra 图谱。 宽表是可选择的画像投影,不是通过双写伪装成两个独立的事实来源。 Cassandra 的数据建模以查询为中心,并不提供关系库式 JOIN。 因此部署设置切换的是画像读取/投影后端,图谱主库始终由 DATABASE_URL 指定。

关系库配置:

DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST:5432/adcloud
PROFILE_BACKEND=relational
COOKIE_SECURE=true
uv sync --locked --extra postgres

启用宽表:

PROFILE_BACKEND=cassandra
CASSANDRA_HOSTS=node1,node2,node3
CASSANDRA_KEYSPACE=adcloud
CASSANDRA_TLS=true
  1. uv sync --locked --extra postgres --extra cassandra 安装两个驱动。
  2. 在 Cassandra 集群应用 deploy/cassandra.cql,按实际 datacenter 名、复制因子、认证和 TLS 配置调整;模板 RF=1 仅用于开发。
  3. 修改部署配置并重启后端。数据库仍保留 PostgreSQL。
  4. 切换时执行 uv run --extra postgres --extra cassandra adcloud-profiles --backfill --once,将现有画像加入 outbox 并处理一批任务。
  5. 持续运行 uv run --extra postgres --extra cassandra adcloud-profiles,重试失败任务并处理剩余同步任务。

.env 自动加载,环境变量优先。两种模式切换需要重启,不在请求中热切换连接或丢弃历史。 切回 relational 时直接从 PostgreSQL 读取完整画像;无需从 Cassandra 迁移回来。

每个 (tenant_id, master_id, revision) 是一个有限宽表分区,属性按字段写入,空字段不占用行。 同分区 batch 写入字段和完成标记,以 LOCAL_QUORUM 读写,只读取关系库指定的当前 revision;旧版本不能覆盖新值。 投影副本 TTL 为 7 天,过期/延迟/故障时回退规范画像;需要时可 backfill 重建。 outbox 与 Stitch 同事务提交;后台发布失败时任务不会确认,下次重试。已归并的旧节点和过期 revision 不再发布。 projection_status 显示 current / pending / unavailable,便于监控。

当前 PostgreSQL 写入以租户行锁串行保护合并及唯一标识,SQLite 用 BEGIN IMMEDIATE。这是首版偏向正确性的实现,高吞吐埋点场景后续需拆分写入路径并细化图谱锁,不能把当前实现视为已做广告云规模压测。 JSONB 使用 SQLAlchemy PostgreSQL 方言;跨 worker 合并由 数据库行锁 保护。

IDENTITY_SECRET 同时用于匿名 token 签名和强标识 HMAC。每个部署生成一次并持久保管,所有 worker 保持一致;改变它会使旧 token 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。