Files
adcloud-server/docs/identity.md
T
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

165 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。