11 KiB
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 流程
- 首次访问调用
/v1/identity/anonymous,或直接发送/v1/events;系统创建签名 ID 并设置 Cookie。 - 匿名事件绑定原始节点,偏好标签/临时属性由可信后端通过用户画像 PATCH 写入。
- 登录验证成功后,可信后端携带匿名 token 和全部已验证标识调用
/v1/identity/stitch。 - 若任意强身份已经连接到 Master ID,归并匿名分量;多个强身份可以传递连接多个分量。
- 同一事务中更新所有别名、合并稀疏属性和标签、记录连接证据,并写入画像投影 outbox。
- 事件保留原始节点。查询通过别名映射返回当前 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
uv sync --locked --extra postgres --extra cassandra安装两个驱动。- 在 Cassandra 集群应用
deploy/cassandra.cql,按实际 datacenter 名、复制因子、认证和 TLS 配置调整;模板 RF=1 仅用于开发。 - 修改部署配置并重启后端。数据库仍保留 PostgreSQL。
- 切换时执行
uv run --extra postgres --extra cassandra adcloud-profiles --backfill --once,将现有画像加入 outbox 并处理一批任务。 - 持续运行
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 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。