这是整个项目的用户身份设计,不是 Loop 专用。 HOOP、Loop 和以后的每个 App 都按这一份认人。 起因:Jeff 2026-10-15「Loop - User」房 07:20–10:53 的语音(第 1 节逐条列出)。10:53 定:改名为 User Identity、写详细、每个环节写清要做的任务和做在哪边、不许影响 HOOP 现在的登录、HOOP 和 Loop 的知识库放同一份。 状态:设计,还没动一行代码。 引用的现有代码按 HOOP master
cd725c0a4(10-15 10:50)核过。第 14 节是房里四个 AI 的讨论分工,第 13 节是还没定的事。 旧网址loop-identity自动转到这里。取代LOOP接口方案.md第 2 节 a(Loop 自己一套账号);那份的白名单、App 不放秘密、两套各自部署回滚照旧有效。 版本:v1 09:58 连接方案 · v2 10:10 改叫法 · v3 10:40 三层 · v3.1 10:50 重复账号 · v4 10:53 改名、加任务清单、加「用户放在哪」「Loop 里看到好友」「不影响 HOOP 登录」三节。 English summary: section 15.
0. 一句话
One User · One Login · Multiple Identities · Multiple Personas(一个用户、一个登录、多个身份、每个身份多个分身)。 - 用户只有一份,放在 HOOP。邮箱、手机、Apple、Google 认的都是它,登录一次。 - 身份:个人(HOOP 用)、商业(Loop 用)、创作者。开了才有,各有一份资料。 - 分身:每个身份下面可以有几张脸(名字 + 头像),每个群自己选用哪一张。 - 每个 App 决定接受哪几种身份;封号封的是「某个身份在某个 App」。 - 有用户 ≠ 能进某个 App:要有那个 App 接受的身份,而且在那个 App 的状态是正常。
1. Jeff 10-15 定下的规则(按语音整理)
- Loop 注册 / 登录先到 HOOP 的登录引擎认人;HOOP 没有这个人就当场建用户(07:20、08:01、08:05)。
- 用户看到的是 Loop 自己的登录页,HOOP 在背后认人;「用 HOOP 一键登录」是另一件事,以后再做(08:01、08:04)。
- Loop 来的新人不过 HOOP 的邀请门,直接进 Loop;他在 HOOP 有用户,但 HOOP 的门没为他开就进不了 HOOP(08:53)。
- HOOP 新人自动进官方频道,Loop 来的人不需要(08:53)。
- HOOP 删号 / 封号只影响 HOOP,Loop 照常;用户留着,只是 HOOP 的状态改成不能进(08:53、08:57)。
- 没下载或退出 HOOP,他仍是 Loop 会员,资料还在(10:00)。
- 叫法:One User · One Login · Multiple Identities;身份就是资料;用户主表只记用户的东西(10:05)。
- 个人身份 = HOOP,商业身份 = Loop(10:07);还有创作者身份;每个身份下面多个分身,为了隐私;三层(10:27)。
- 每个 App 允许几种身份进来,封掉一个身份它就进不了那个 App;Loop 下面也要有分身(10:32)。
- 分身只是我自己的名字和头像,和公司无关;是哪家公司的成员是另一回事(10:34)。
- 数据合并放第二版,第一版只做账号关联;合并用验证码确认、被封的号登不进去合并不了、合并不能撤销、Sparks 合在一起(10:45、10:48)。
- 这份是整个项目的 User Identity,不是 Loop 的;放 HOOP 和 Loop 两边的知识库,同一份(10:53)。
- 不可以影响 HOOP 现在的登录;表结构要改,要很谨慎,做好再延伸到 HOOP(10:53)。
- Jeff 的测试:用他 HOOP 的账号(Gmail)登录 Loop,要看到他所有的朋友 —— 朋友们自己还没装 Loop,但在他的角度要看得到(10:53)。
- 用户一定只有一份、两边一模一样、而且最完整;放 HOOP 一边还是两边,选维护最好的(10:53)。
2. 三层:用户 → 身份 → 分身
全文只用这三个词。「资料」不是另一样东西,只是身份那一页上写的内容。
| 层 | 是什么 | 一个人有几个 | 放在哪 |
|---|---|---|---|
| ① 用户(User) | 你是谁、怎么登录:用户 ID、验证过的邮箱和手机号、Apple / Google 绑定;关于这个人本身的事实(生日、实名认证) | 一个 | HOOP 现有 users 表(000001_init.up.sql:10-28),只有登录引擎能改 |
| ② 身份(Identity) | 以哪种身份出现:个人 / 商业 / 创作者,每种一份资料(字段按类型不同) | 每种最多一个,按需开通 | 新表 identities |
| ③ 分身(Persona) | 这个身份对外的一张脸:名字 + 头像;每个身份恰好一个默认分身 | 每个身份可以有几个 | 新表 personas |
外加一张「身份在各 App 的使用状态」(identity_app_access)。
| 身份 | 今天在哪个 App 用 | 现有代码里对应什么 |
|---|---|---|
| 个人 | HOOP | user_profiles(000001_init.up.sql:64-84)= 默认分身;现有「分身」personas(社交 / 游戏两张脸,000031_personas.up.sql)= 其它分身 |
| 商业 | Loop | 全新 |
| 创作者 | HOOP(Loop 以后再定) | developers + 创作者资料(000131_developers.up.sql 原话「HOOP 用户的第二身份」、000175_creator_profile.up.sql) |
规矩: - 事实放用户层,展示放身份层。 生日、实名只存一份;名字、头像、职业、性格每个身份各填各的,改一边不覆盖另一边。 - 分身只属于一个身份,用哪个是你的自由;每个群自己记住选的哪个。 - 隐私:别人默认看不出两个分身、两个身份是同一个人;公开资料和接口都不露这层关联。只有管理和安全审查看得到。 - 封号三级:一个分身 → 只关它;某身份在某 App → 它在这个 App 的所有分身一起关,别的 App 不跟着封;整个用户 → 全关,只用于最严重的情况。 - 分身不是公司。是哪家公司的成员是另一件事,不写进资料、不给分身任何权限;公司这一层不在这份范围。
2.1 新表(都在 HOOP 数据库)
identities —— 每人每种身份一行
- id(身份 ID)· user_id(外键 users.id;(user_id, kind) 唯一)· kind:personal / business / creator
- 按类型的资料:个人(职业、性格……;已有字段仍读 user_profiles,不搬)、商业(业务介绍)、创作者(作品介绍)
- created_at · deleted_at · delete_source(admin / member)
personas —— 每个身份几行
- id · identity_id · display_name · avatar_url · is_default · status(active / banned)
- 个人身份的默认分身就是 user_profiles,这里不再存一份。群里选哪个分身,沿用 conversation_members.persona_scope(000032)的做法,改成记分身 ID。
- ⚠️ 表名和现有的 personas(000031)撞名,建表时新表另取名或把旧表并进来,H1 定。
identity_app_access —— 某身份在某 App 的状态
- 主键 (identity_id, app);app:hoop / loop · status:active / suspended / banned / deleted · suspended_until
每个 App 接受哪几种身份写在代码配置里(今天:HOOP = 个人、创作者;Loop = 商业),改它不用改表。
上线时给每个现有用户补「个人身份 + 个人身份在 HOOP」,状态照抄今天的 users.status;开发者再补创作者身份。
3. 用户只有一份:放在 HOOP,Loop 不存副本(回答第 1 节第 15 条)
决定(建议):用户、身份、分身、好友关系都只存在 HOOP 一边。Loop 的数据库不存任何一份用户资料,只存 Loop 自己的内容(工作、消息……),每一行挂在身份 ID 上。
- 为什么不两边都存:两份就要同步,同步一定会有一刻对不上(改名、换头像、删号、封号的那几秒),还要多写一套补偿程序。一份就永远一模一样,这正是「最完整」。
- Loop 要用户资料时:Loop 后端调 HOOP 的只读接口(任务 H6),短缓存几分钟;HOOP 改了,缓存过期就是新的。
- 代价:HOOP 的账号服务挂了,Loop 新登录会失败(已登录的人凭证还能用到过期)。所以账号服务要单独看可用性。Google、Meta 那种统一账号是同样的做法、同样的代价。
- ⚠️ Loop 现在的后端自己有一套用户表和注册(从 hoop-core 复制的登录,LOOP记忆.md §6)。按这一节,那一套要停用,改成验 HOOP 的凭证(任务 L1)。
4. 在 Loop 里看到 HOOP 的朋友(回答第 1 节第 14 条)
Jeff 的测试:用 HOOP 账号登录 Loop,要看到他所有的朋友,哪怕朋友们还没装 Loop。
做法:好友关系是用户和用户之间的,只存 HOOP 一份(好友表在 000001_init.up.sql,接口 GET /v1/friends,friend/handler.go:65)。Loop 不另建好友表,直接读这一份。
- Loop 显示好友列表时,每个朋友显示:他在 HOOP 用的默认名字和头像(你在 HOOP 本来就看得到,不算新泄露),以及「已在 Loop」或「还没加入 Loop」。
- 还没加入的朋友:显示成灰色、可以邀请;加入以后他用自己的商业身份出现。
- 隐私:Loop 只给本来就是你好友的人看到这些,陌生人在 Loop 里搜不到你的个人身份。
- ⚠️ 这一条和 v3 不同:v3 写「好友各 App 管各的」,按 10:53 的测试要求改成「好友关系共用、内容(朋友圈、动态)各 App 管各的」。要 Jeff 确认(第 13 节第 1 条)。
5. 登录流程
现有登录入口(backend/internal/domain/auth/handler.go:26-31)不换路径,请求里多一个字段 product(不带 = hoop,旧版 HOOP App 完全不受影响)。
a) 在 Loop 用邮箱或手机验证码登录
1. Loop App 请求验证码,带 product: loop → 发 Loop 品牌的邮件 / 短信(auth.Config.Brand 已能按产品给文案)。
2. 用户填验证码 → 登录引擎核对 → 用邮箱 / 手机号找用户(FindUserByContact,service.go:427)。
3. 没有 → 建用户(CreateUser,service.go:465),跳过邀请门、不跑 HOOP 的建号钩子(官方频道)。
4. 查他的商业身份和「商业身份在 Loop」:没有就建;封禁 / 已删除就拒绝。
5. 发一张只给 Loop 用的登录凭证(第 7 节)。
b) 在 Loop 用 Apple / Google:先按 Apple / Google 编号找用户,找不到再按已验证的邮箱找。Apple 的坑见第 8 节第 1 条。
c) 只有商业身份的人打开 HOOP:认出用户,发现没有个人身份 → 这时才走 HOOP 邀请门;过了就建个人身份、跑官方频道钩子。⚠️ 今天「老用户登录不查邀请门」(service.go:427-432),这一步必须和 Loop 登录同时上,否则 Loop 成了绕过邀请门的路。
d) 已有 HOOP 的人第一次进 Loop(Jeff 的测试就是这条):找到用户 → 建商业身份和它的默认分身 → 进 Loop。默认分身要不要先填上 HOOP 的名字和头像,见第 13 节第 2 条。
6. 每种情况怎么算
表里「个人身份」= 个人身份在 HOOP 的状态,「商业身份」= 商业身份在 Loop 的状态。
| 情况 | 用户 | 个人身份 | 商业身份 |
|---|---|---|---|
| HOOP 邀请门 | 不管 | 建个人身份时才查 | 不查 |
| 建号后进官方频道 | — | 建个人身份时跑 | 不跑 |
| HOOP 管理员删号 | 留着 | deleted |
不动 |
| HOOP 管理员封号 / 暂停 | 留着 | banned / suspended |
不动 |
| Loop 管理员删号 / 封号 | 留着 | 不动 | deleted / banned |
| 被 HOOP 管理员删掉的邮箱 / 手机号 30 天内再注册 | — | 挡住 | 不挡 |
| 用户在某个 App 里注销 | 看他选 | 选了才删 | 选了才删 |
| 所有身份都删了 | 进现有 30 天保留期,到期匿名化(purge.go) |
— | — |
| 退出登录 / 所有设备退出 | — | 只退 HOOP | 只退 Loop |
注销页让用户选「只注销这个 App」或「全部一起注销」。
7. 登录凭证
今天 HOOP 的凭证是 HS256 的 JWT,没有「给哪个 App」,只验签法和签发者(token.go:24-67)。
- HOOP 的凭证一字不改 —— 现有用户不会被踢下线。
- Loop 的凭证另签:Ed25519,写 aud: loop。HOOP 中间件只认 HS256 + 签发者 hoop,Loop 的凭证到 HOOP 自然被拒;Loop 后端只认 aud: loop。
- Loop 后端只拿公钥(/.well-known/jwks.json),本地验,拿不到任何能签凭证的秘密。
- 续期凭证(refresh_tokens,000001_init.up.sql:133-145)加 product 列:Loop 的续期只能换 Loop 的凭证。
- 每次请求还要查状态:HOOP 查「用户没删且个人身份在 HOOP 正常」(今天查用户状态,blocked.go:163-167);Loop 查「用户没删且商业身份在 Loop 正常」。
8. 要提前知道的坑
- Apple 登录两个 App 对不上号。Apple 编号按开发者团队分:HOOP
CL4ZMA59T6,LoopYZD2N9XXN4(LOOP记忆.md§7);隐藏邮箱的转发地址也不同。对不上的人,登录后给「关联已有的 HOOP 账号」入口(验证码确认)。Google 没这个问题,Jeff 用 Gmail 测试不受影响。 - 今天的删号、封号改的是用户本身。管理员删号走
auth.SoftDeleteTx(repository.go:611-679),封号走risk.go:211-221,都改users.status。不改的话 HOOP 一删号 Loop 就登不上(任务 H3)。 - 两家公司共用用户资料要写进条款。Loop 的 Apple 账号是 ONECARE WELLNESS SDN. BHD.;两边隐私条款写明「用 HOOP 账号登录」,注册页也写一句。
- 重复账号(Jeff 10:45 / 10:48 定):
- 第一版不做数据合并。一个用户可以绑好几种登录方式;HOOP 已有登录后绑 Apple / Google(
/v1/auth/apple/bind、/v1/auth/google/bind,handler.go:39-40)。注册时邮箱和已有用户的已验证邮箱一样,就提示「你已经有账号了」。 - 第二版合并:另一个号收一次验证码确认是本人;被封的号登不进去,本来就合并不了;合并不能撤销;Sparks 合在一起;旧用户 ID 留「旧 → 新」对照。Loop 数据挂在身份上,以后合并只要把身份换个用户挂。
9. 不影响 HOOP 现在的登录:守则(回答第 1 节第 13 条)
- 只加不改:新表、新列、新接口;不删、不改名任何现有列和接口。迁移号从
backend/migrations/LEDGER.md取。 - 默认等于今天:登录请求不带
product就是 HOOP、走今天的每一步;HOOP 凭证、签发者、密钥都不动,没有人会被踢下线。 - 先补数据、再换读法:先上表并回填(H1),核对「个人身份在 HOOP 的状态」和
users.status一行不差,才让中间件改读新表(H2)。 - 开关:Loop 相关的路(
product: loop、Loop 凭证、只读接口)全挂在一个服务器开关后面,关掉就回到今天。 - 会红的回归测试,每一步上线前跑:HOOP 验证码 / Apple / Google 登录、续期、退出、所有设备退出、邀请门、官方频道、管理员删号 / 恢复 / 封号、用户注销,结果和今天一样。
- 上线顺序:只走
tools/deploy_backend.sh;先测试环境、再生产;每步可单独回滚。
10. 任务清单
「测试必需」= Jeff 第 1 节第 14 条那个测试之前必须做完。认领人是建议,第 14 节讨论定。
| # | 做在哪 | 做什么 | 依赖 | 测试必需 | 建议认领 |
|---|---|---|---|---|---|
| H1 | HOOP 后端 | 建 identities / personas / identity_app_access,回填现有用户(个人身份 + HOOP 状态;开发者补创作者身份);核对一行不差 |
— | ✅ | Vega |
| H2 | HOOP 后端 | HOOP 中间件改读「个人身份在 HOOP」;第 9 节回归测试全绿 | H1 | ✅ | Vega |
| H3 | HOOP 后端 + HOOP App | 管理员删号 / 封号、用户注销、30 天匿名化改成按身份;注销页加「只注销这个 App / 全部」 | H2 | 真用户上 Loop 前 | Vega(App 部分等 NOVA 出包) |
| H4 | HOOP 后端 | 登录入口加 product;Loop 品牌验证码;Loop 跳过邀请门和官方频道;5c「只有商业身份的人开 HOOP 要过邀请门」 |
H1 | ✅ | Vega |
| H5 | HOOP 后端 | Loop 凭证(Ed25519 + aud: loop)、公钥地址、续期按产品分 |
H4 | ✅ | Vega |
| H6 | HOOP 后端 | 给 Loop 的只读接口:我的用户 + 身份 + 分身;我的好友(带对方默认名字头像、是否已在 Loop);身份状态 | H1 | ✅ | Vega |
| H7 | HOOP 后端 | 分身接口(建 / 改 / 删分身、群里选分身);现有 personas 和 user_profiles 接进去 |
H1 | 否 | Vega |
| H8 | HOOP 后端 | 绑定登录方式支持 product: loop;同邮箱提示 |
H4 | 否 | Vega |
| L1 | Loop 后端 | 停用 Loop 自己的用户表和注册;改验 HOOP 凭证(公钥);Loop 数据挂身份 ID | H5 | ✅ | Gene Loop |
| L2 | Loop App | 登录页走 HOOP 登录引擎(product: loop),Apple / Google 用 Loop 的 App 编号 |
H4 H5 | ✅ | Gene Loop / Gene Veyra |
| L3 | Loop App | 好友列表显示 HOOP 好友(已在 Loop / 还没加入) | H6 L1 | ✅ | Gene Loop / Gene Veyra |
| L4 | Loop App | 商业身份的分身:建、换头像、每个群选分身 | H7 | 否 | Gene Loop / Gene Veyra |
| L5 | Loop App | Apple 隐藏邮箱的「关联已有 HOOP 账号」入口 | H8 | 否 | Gene Loop / Gene Veyra |
| D1 | 两边知识库 | HOOP:本页 + docs/00-目录.md;Loop:loop 仓库 docs/00-目录.md 加一行指向本页(同一份,不复制) |
— | — | Vega(HOOP)/ Gene Loop(Loop) |
| R1 | 审查 | 每个 H 任务上线前审一轮,重点第 9 节守则 | — | ✅ | Cora |
| T1 | 测试 | 第 11 节,Jeff 亲测 | 以上 ✅ | — | Jeff |
11. Jeff 的测试怎么走
- 打开 Loop 测试版 → 选 Google → 用他 HOOP 的 Gmail 登录。
- 不需要邀请码,直接进 Loop;后台看到:同一个用户 ID,多了商业身份和「商业身份在 Loop:正常」。
- 好友列表:HOOP 里的每个朋友都在,显示 HOOP 的名字和头像;没装 Loop 的标「还没加入」。
- 打开 HOOP:一切照旧,登录没变,没被踢下线,资料没变。
- 用另一个只在 Loop 注册的测试号打开 HOOP:被邀请门挡住(证明没有绕过)。 做完这五步,第 10 节标 ✅ 的任务才算交付。
12. 多个身份,这样设计好不好
好,比只加一个「能不能进」的开关更好:Meta 的账户中心就是一个账号、Facebook 和 Instagram 各有主页和封禁状态;HOOP 自己已经有「开发者 = 第二身份」和「分身」;Jeff 定的规则不用写特例就成立。
13. 还没定的
- 好友共用(第 4 节):好友关系共用、朋友圈和动态各 App 管各的 —— 对吗?
- 已有 HOOP 的人第一次进 Loop,商业身份的默认分身要不要先填上 HOOP 的名字和头像(他可以再改)?
- 注销默认「只注销这个 App」还是「全部」?(建议前者)
- Loop 的后端放哪台机器(
LOOP接口方案.md§9 第 1 条)。 - Jeff 的测试要看到真实朋友,所以 H1–H6 要上 HOOP 生产(都是只加不改、带开关);测试用的是生产的用户和好友数据(只读)。
14. 讨论分工(Jeff 10:53「每一个细节你们各自讨论然后定下来」)
| 谁 | 负责讨论定什么 |
|---|---|
| Jeff Vega | 本文档;HOOP 侧 H1–H8;第 3、7、9 节 |
| Gene Loop | Loop 后端 L1;Loop 知识库 D1;Loop 现有用户表怎么停用 |
| Gene Veyra | Loop App L2–L5;第 4 节好友在 Loop 里怎么显示 |
| Jeff Cora | 审查 R1;第 6 节每种情况、第 8 节坑有没有漏 |
每条讨论定了,就改本页对应那一节和第 10 节那一行;不另开卡,都记在卡 2610.1065。
15. English summary
This is the project-wide User Identity design, not a Loop-only document. HOOP, Loop and every future app identify people this way.
Model. One User · One Login · Multiple Identities · Multiple Personas. The User exists once, in HOOP: email, phone, Apple and Google all lead to it. Identities are personal (used in HOOP), business (used in Loop) and creator; each has its own profile fields. Personas are faces (name + avatar) under an identity, and each group remembers which persona you use. Each app decides which identity kinds it accepts, and a ban targets "this identity in this app". New HOOP tables: identities, personas, identity_app_access. Existing user_profiles, personas and developers map in.
One copy (Jeff: "the user must be one, identical, most complete"). User, identities, personas and friendships live only in HOOP. Loop's database stores no user copy, only Loop content keyed by identity ID. It reads users via HOOP read-only APIs with short caching. Loop's current own user table must be retired (L1).
Seeing HOOP friends in Loop (Jeff's test). Friendship is user-to-user and stored once in HOOP. Loop shows your HOOP friends with their HOOP default name and avatar, marked "on Loop" or "not on Loop yet". This changes v3's "friends per app"; Jeff to confirm.
Login. The existing endpoints gain product (missing = HOOP, unchanged). Loop login finds or creates the User, skips HOOP's invite gate and official channel, and creates the business identity. A Loop-only user opening HOOP must pass the invite gate. Loop tokens are Ed25519 with aud: loop, verified via JWKS; HOOP tokens are untouched.
Do not break HOOP login. Changes are additive only. Requests without product behave as today. Backfill first and verify row-for-row before switching reads. Loop paths sit behind a server switch. Regression tests run before every step, and deployment goes through tools/deploy_backend.sh, test before production.
Tasks. HOOP: H1 tables and backfill, H2 middleware read, H3 per-identity delete/ban, H4 product login, H5 Loop tokens, H6 read-only APIs, H7 persona APIs, H8 login-method binding. Loop: L1 retire own users, L2 login page, L3 friends list, L4 personas, L5 Apple linking. Also D1 knowledge bases, R1 review, T1 Jeff's test. Needed for the test: H1, H2, H4, H5, H6, L1, L2, L3.
Traps. Apple IDs differ per developer team (Gmail testing is unaffected). Today's delete and ban change the User itself. Both privacy policies must say "sign in with your HOOP account". Duplicate accounts: v1 binds several login methods; merging is v2.
Open. Shared friendships? Pre-fill the Loop default persona from HOOP? Default deletion scope? Loop server location? The test needs H1–H6 on HOOP production (additive, behind a switch).