# 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 的持有,不是登录令牌。应保存到应用安全存储。 ## 图谱模型 ```text 匿名设备 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 | 首次加载页面时可以直接调用: ```js 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;未注册、未发布、参数不符或来源不合法会明确拒绝。详见 [事件字典与规则](events.md)。 普通画像属性继续按目标账号优先合并;标准特征由独立贡献记录按图谱成员汇总,因此身份缝合不覆盖数值、不重复加分。 Stitch 示例(只在 OTP/OAuth 验证后由可信后端执行): ```json { "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](https://cassandra.apache.org/doc/latest/cassandra/developing/data-modeling/data-modeling_rdbms.html)。 因此部署设置切换的是画像读取/投影后端,图谱主库始终由 DATABASE_URL 指定。 关系库配置: ```dotenv DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST:5432/adcloud PROFILE_BACKEND=relational COOKIE_SECURE=true ``` ```sh uv sync --locked --extra postgres ``` 启用宽表: ```dotenv 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 方言](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#jsonb);跨 worker 合并由 [数据库行锁](https://www.postgresql.org/docs/current/explicit-locking.html) 保护。 IDENTITY_SECRET 同时用于匿名 token 签名和强标识 HMAC。每个部署生成一次并持久保管,所有 worker 保持一致;改变它会使旧 token 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。