Implement identity graph stitching and configurable profile storage
Issue persistent signed anonymous IDs and accept cookie/token-bound events. Resolve verified email, phone, OAuth, and namespaced third-party IDs into transactionally merged master identities with historical event aliases. Protect shared-device account switching, tenant isolation, and event retries. Store sparse canonical profiles as PostgreSQL JSONB or local SQLite JSON. Support an optional versioned Cassandra wide-column projection through a transactional outbox, retry worker, and immediate canonical read fallback. Document deployment settings and provide opt-in database integration tests. Validation: 43 local tests passed; 6 external database tests skipped. Ruff checks, formatting, and offline uv lock verification passed.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
# 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 的自动初始化集成尚未修改。
|
||||
浏览器请求需使用 `credentials: "include"`。匿名写接口检查 Origin;CORS 使用明确允许来源并启用凭据。
|
||||
Cookie 默认 Secure、HttpOnly、SameSite=Lax,有效期默认一年;本地 HTTP 开发明确设置 `COOKIE_SECURE=false`。
|
||||
跨站点 Cookie 如需 SameSite=None,必须同时启用 Secure。浏览器第三方 Cookie 策略可能阻止 Cookie,应优先将 API 放在第一方域名下。
|
||||
不设置父域 Cookie,不通过第三方设备指纹跨站点识别用户。
|
||||
|
||||
事件可提供 UUID `event_id` 进行幂等重试。相同 ID 的不同主体或内容返回 409;省略 event_id 时每次生成新事件。
|
||||
`occurred_at` 如果提供,必须含时区且不能晚于服务端当前时间。
|
||||
|
||||
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 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。
|
||||
Reference in New Issue
Block a user