# 产品成员邀请与生命周期

短信发送须先完成[滑块验证](CAPTCHA.md)，并在原发送请求中携带一次性 `captcha_token`；发布前同步升级相关客户端。

## 1. 模型与职责

图奕平台只维护三类数据：全局用户身份、用户与产品的成员关系、平台签发的认证状态。产品邀请完成后，用户就是普通图奕用户，拥有稳定且不复用的 OIDC `sub`。`managed_invitation` 只是成员关系的创建来源，不是平台角色。

代理、子用户、业务角色、套餐、设备、隧道及资源归属全部由产品维护。以网云穿为例，解除代理关系只修改网云穿自己的业务数据；不得删除图奕用户、改变 `sub` 或解除产品成员关系。

成员关系只有 `active` 和 `suspended`：

- `active`：可按产品既有策略登录。
- `suspended`：仅拒绝该产品的 OIDC 和原生登录，并撤销该产品对应 Token；不影响其他产品，也不撤销平台浏览器会话。
- 恢复必须显式调用 `reactivate`。重复邀请不能恢复已暂停成员。
- 用户本人重置全局密码时，平台撤销该用户全部平台会话和各产品 Token，并投递既有 `user.sessions_revoked` 事件，`reason` 为 `password_changed`。

## 2. 开通准备

管理员在产品向导中同时启用“Web 登录”和“产品成员邀请”。平台生成带 `managed_users:read managed_users:write` 的 Service Client，并把产品网站基础地址的精确 Origin 登记到 Web Client。Origin 只包含协议、主机和端口，例如 `https://console.example.com`，不能包含路径或通配符。

产品后端保存 Service Client Secret；浏览器、桌面客户端和日志不得保存它。产品必须配置并消费签名 Webhook，才能及时处理成员暂停、恢复和全局密码变更。

产品成员密码重置还要求管理员在“联麓短信”中配置已审核的专用密码重置模板 ID。该模板应包含验证码、产品名称和固定平台地址 `https://platform.too1.net/password/managed-reset`。未配置时重置接口返回 `password_reset_unavailable`，不会降级为产品代设密码。

## 3. 服务端鉴权与幂等

所有产品服务端接口使用 Service Bearer Token，并执行产品隔离、Scope、Client 状态和 CIDR 校验。写接口必须提供 1–64 字符的 `Idempotency-Key`；同一 Client 重复提交完全相同的请求会返回原结果，不同请求复用同一个 Key 返回 `idempotency_conflict`。

接口前缀为 `/api/v1`：

| 方法 | 路径 | Scope | 用途 |
| --- | --- | --- | --- |
| `POST` | `/managed-user-invitations` | `managed_users:write` | 创建邀请 |
| `GET` | `/managed-user-invitations?external_user_id=...` | `managed_users:read` | 按产品外部编号对账 |
| `GET` | `/managed-user-invitations/{id}` | `managed_users:read` | 查询邀请 |
| `POST` | `/managed-user-invitations/{id}` | `managed_users:write` | 以 `{"action":"cancel"}` 取消邀请 |
| `GET` | `/managed-users` | `managed_users:read` | 查询当前产品成员 |
| `GET` | `/managed-users/{sub}` | `managed_users:read` | 查询单个成员 |
| `POST` | `/managed-users/{sub}/suspend` | `managed_users:write` | 暂停当前产品成员 |
| `POST` | `/managed-users/{sub}/reactivate` | `managed_users:write` | 恢复当前产品成员 |
| `POST` | `/managed-users/{sub}/password-reset-requests` | `managed_users:write` | 请求用户本人重置密码 |
| `POST` | `/managed-users` | `managed_users:write` | 创建托管成员（见第 9 节） |
| `POST` | `/managed-users/{sub}/password` | `managed_users:write` | 为托管成员设置新密码 |
| `POST` | `/managed-users/{sub}/claims` | `managed_users:write` | 创建认领事务 |
| `GET` | `/managed-member-claims/{id}` | `managed_users:read` | 查询认领事务 |
| `POST` | `/managed-member-claims/{id}` | `managed_users:write` | 以 `{"action":"cancel"}` 取消认领事务 |

成员列表与详情包含 `hosted`（布尔，未认领的本产品托管成员为 `true`）和 `account_name`（有账号名时返回）；`provisioning_source` 取值增加 `hosted`。

创建邀请示例：

```http
POST /api/v1/managed-user-invitations HTTP/1.1
Authorization: Bearer SERVICE_ACCESS_TOKEN
Idempotency-Key: member-invite:agent-user-1008:v1
Content-Type: application/json

{
  "external_user_id": "agent-user-1008",
  "phone_e164": "+8613812345678",
  "display_name": "待激活成员",
  "browser_origin": "https://console.example.com"
}
```

成功响应只返回邀请、一次性 `transaction_token` 和 `activation_api`。产品服务端不得收到验证码、密码或“手机号是否已经注册”的判断。`transaction_token` 只保存哈希，有效期 15 分钟，产品后端应把它作为短期事务值交给同 Origin 激活页，不得落入持久 Cookie、URL、埋点或日志。产品可在服务端按邀请有效期短期加密保存事务值以恢复同一邀请；必须限制为邀请创建者访问，完成或过期后清除，不缓存本人验证码或密码。

创建请求响应丢失时，不要换 Key 自动重建邀请。先用原 Key 重试；仍无法取得结果时，按 `external_user_id` 查询并恢复业务状态。

## 4. 浏览器激活

浏览器只可从邀请登记的精确 Origin 调用响应里的 `activation_api`，每次请求均使用：

```http
Authorization: ManagedAuth TRANSACTION_TOKEN
Origin: https://console.example.com
Content-Type: application/json
```

请求必须省略 Cookie，不能混用 OIDC Access Token、Service Token、`WebAuth` 或 `Native` 凭据。平台只为邀请对应的精确 Origin 返回 CORS 响应，不使用 `*`，也不返回 `Access-Control-Allow-Credentials`。

| 方法 | 相对 `activation_api` 的地址 | 用途 |
| --- | --- | --- |
| `GET` | `` | 查询邀请状态、下一步和当前协议 |
| `POST` | `/sms/send` | 发送 5 分钟验证码；60 秒内不得重发 |
| `POST` | `/sms/verify` | 提交 `{"code":"123456"}` |
| `POST` | `/complete` | 完成激活 |
| `POST` | `/cancel` | 用户取消 |

验证成功后，`next_action` 只会是：

- `set_password`：手机号尚无图奕用户。提交新密码、当前 `legal_version` 和 `accept_terms:true`，平台原子创建全局用户、手机号身份、协议记录和产品成员关系。可附带 `account_name`：4–32 位，以小写字母开头，允许数字、点、下划线及短横线；全局唯一，冲突回滚且保留当前手机验证证明。新账号可使用此账号名或手机号进行 Web/Native 密码登录，二者共用手机号对应的失败限流。
- `confirm_membership`：手机号已有图奕用户。不得提交密码或 `account_name`，只确认当前协议和加入产品；平台复用原 `sub`，不能通过产品邀请改名或代设全局密码。

验证码验证会生成可重试的短期证明。若数据库提交失败，证明不会提前销毁；重试完成不会创建两个用户或两条成员关系。外部编号、手机号对应用户或已有产品成员发生冲突时返回 `relationship_conflict`，平台不会自动覆盖、迁移、合并或解除暂停。

短信状态为 `unknown` 表示渠道可能已经受理。界面必须提示用户检查短信并允许输入当前验证码；不得自动再次发送，也不得声称“未发送”。邀请过期、取消或步骤不匹配返回 `invitation_unavailable`。

## 5. 暂停、恢复与产品业务关系

产品以平台 `sub` 调用暂停或恢复接口。暂停事务会同时更新成员状态、撤销该产品的认证请求和 Token，并写入 Outbox。网云穿收到事件后再按自己的业务规则撤销本地会话；平台不会修改代理树或资源归属。

产品内“解除代理关系”不调用平台接口。用户仍是网云穿成员，也可以继续使用网云穿和其他产品；是否允许其成为独立账号、保留哪些资源由网云穿决定。

## 6. 用户本人密码重置

产品只能为当前产品的 active 成员发起重置请求，不能设置、读取或传输密码。平台限制同一用户每 60 秒一次、同一产品每日最多 5 次；任务有效期 30 分钟。

用户从短信中的固定平台地址打开重置页，输入手机号、验证码和新密码。平台验证后在一个数据库事务内更新 Argon2id 密码、撤销全部平台会话和 Token、取消未完成认证事务并写入各产品 Outbox。失败或数据库回滚时，已验证证明仍可重试；成功提交后才销毁证明。

## 7. Webhook 与对账

以下事件均使用既有 HMAC-SHA256 Webhook，事件 ID 必须幂等消费：

| 事件 | 关键字段 | 产品动作 |
| --- | --- | --- |
| `product_membership.activated` | `sub`、`product_code`、`external_user_id`、`provisioning_source` | 建立或确认产品业务账号 |
| `product_membership.suspended` | `sub`、`product_code`、`external_user_id` | 撤销该产品本地会话 |
| `product_membership.reactivated` | `sub`、`product_code`、`external_user_id` | 恢复登录资格，不自动变更资源 |
| `user.sessions_revoked` | `sub`、`product_code`、`reason` | `reason` 为 `password_changed` 或 `account_claimed` 时撤销产品本地会话 |
| `user.claimed` | `sub`、`product_code`、`external_user_id`、`external_ref`、`claim_id` | 托管成员已认领为本人账号：解除上级关系、撤销上级授权 |

Webhook 至少投递一次，可能重复或乱序。产品先验签和检查时间窗口，再以事件 ID 建 inbox 唯一约束，并在同一业务事务中更新本地状态。若事件积压，可用成员和邀请查询接口主动对账。

## 8. 网云穿接入边界

网云穿仓库需要实现：邀请激活页面、短期事务凭据的内存态或短期服务端存储、成员 `sub` 与业务账号映射、代理树与解除关系、Webhook inbox、成员暂停后的本地会话撤销，以及标准 OIDC Logout 的 `state` 校验。图奕平台不会替网云穿维护代理层级，也不会在 Token 中加入代理或子用户声明。

退出时，网云穿先清理自身业务会话，再发起 `/oauth2/logout`，携带 `id_token_hint`、已登记的 `post_logout_redirect_uri` 和随机独立 `state`。平台校验后直接退出当前浏览器会话及该产品凭据并 302 返回，不展示二次确认页；网云穿回调必须校验并一次性消费 `state`。

## 9. 托管成员与认领

托管成员由产品直接创建：只有账号名、名称和密码，没有手机号身份，平台在 `users.hosted_product_code` 记录托管它的产品。认领前它**只能登录该产品**：托管 OIDC、自定义网页、原生登录在签发授权码前，以及 Token 签发与刷新时，凡 Client 所属产品不同一律拒绝（网页登录提示“该账号仅限在 {产品名} 使用”，接口返回 `403 hosted_member_product_only`，Token 端点返回 `invalid_grant`）。平台账户中心可访问，但托管成员不能在账户中心绑定或修改手机号，手机号只能通过认领写入。

账号名密码登录（网页 `/login/password`、`WebAuth`/`Native` 的 `/password`，字段仍为 `phone_e164`）按用户 ID 校验，不再要求手机号身份。同一账号用手机号或账号名登录共用失败计数：有手机号时按手机号计数，否则按账号名计数；不存在的账号名单独计数，返回与密码错误相同的结果。

### 9.1 创建托管成员

```http
POST /api/v1/managed-users
Authorization: Bearer SERVICE_ACCESS_TOKEN
Idempotency-Key: hosted:wyc-hosted-123:v1
Content-Type: application/json

{"external_user_id":"wyc-hosted-123","account_name":"factory01","display_name":"某某工厂","password":"可选"}
```

- `account_name` 规则 `^[a-z][a-z0-9_.-]{3,31}$`（输入会转小写），全平台唯一；`display_name` 1–64 字符；`password` 可省略，省略时 `password_required=true`，成员在设置密码前无法登录。平台只保存 Argon2id 哈希，不回传密码。
- 成功 `201`：`{"data":{"subject","external_user_id","account_name","display_name","status":"active","hosted":true,"password_required":false,"created_at"}}`。
- 同一 `external_user_id` 与同一账号名再次提交（即使换了 Idempotency-Key）返回 `200` 和原成员，不修改密码；同一外部编号对应其他账号名或非托管成员返回 `409 relationship_conflict`。
- 错误：账号名已占用 `409 account_name_taken`；字段不合法 `400 invalid_request`；密码不满足策略 `422 weak_password`。
- 平台投递 `product_membership.activated`（`provisioning_source=hosted`）。

### 9.2 设置密码

`POST /api/v1/managed-users/{sub}/password`，请求 `{"password":"..."}`，响应 `200` 与 9.1 相同的成员对象。只允许本产品的未认领托管成员；已认领或非托管返回 `409 not_hosted`，非本产品成员返回 `404 not_found`。成功后在同一事务撤销该用户全部平台会话、认证事务和各产品 Token，并投递 `user.sessions_revoked`（`reason=password_changed`）。

### 9.3 认领事务

`POST /api/v1/managed-users/{sub}/claims`，请求 `{"external_ref":"wyc-claim-45","browser_origin":"https://console.example.com","expires_in_hours":168}`（有效期 1–168 小时，默认 168）。`external_ref` 在同一 Service Client 内唯一，重复返回 `409 relationship_conflict`；`browser_origin` 必须登记在本产品 Web Client 的精确 Origin 中，否则 `403 origin_not_allowed`；成员已认领返回 `409 not_hosted`。同一成员新建认领会取消其旧的进行中认领。

成功 `201`：

```json
{"data":{"claim":{"claim_id":"…","subject":"…","external_ref":"wyc-claim-45","account_name":"factory01","display_name":"某某工厂","status":"pending","next_action":"verify_phone","expires_at":"…","created_at":"…"},
 "transaction_token":"43 位一次性凭据","activation_api":"https://platform.too1.net/api/v1/managed-member-claim-activations/{claim_id}"}}
```

`transaction_token` 只存哈希，保管要求与成员邀请相同。查询 `GET /api/v1/managed-member-claims/{id}` 返回 `{"data": claim}`；`status` 为 `pending`、`phone_verified`、`completed`、`cancelled`、`expired`，完成后带 `completed_at`、`phone_masked`。取消 `POST /api/v1/managed-member-claims/{id}` `{"action":"cancel"}` 返回 `{"data":{"status":"cancelled"}}`，非进行中返回 `409 invitation_unavailable`。

### 9.4 认领页（浏览器直连）

认领页使用响应里的 `activation_api`，鉴权与成员激活完全相同：`Authorization: ManagedAuth TRANSACTION_TOKEN`、精确 `Origin`、`Content-Type: application/json`、省略 Cookie。

| 方法 | 相对 `activation_api` | 请求 | 成功响应 |
| --- | --- | --- | --- |
| `GET` | `` | — | `200 {"data":{"claim":{…},"legal":{"version","terms_url","privacy_url"}}}` |
| `POST` | `/sms/send` | `{"phone_e164":"+86…"}` | `201`（`202` 结果未知）`{"data":{"status":"accepted|unknown","message","retry_after":60,"phone_masked"}}` |
| `POST` | `/sms/verify` | `{"code":"123456"}` | `200 {"data": claim}`，`next_action=set_password` |
| `POST` | `/complete` | `{"new_password":"…","accept_terms":true,"legal_version":"…"}` | `200 {"data": claim}`，`status=completed` |
| `POST` | `/cancel` | `{}` | `200 {"data":{"status":"cancelled"}}` |

- 只支持 `+86` 手机号，否则 `400 invalid_request`。手机号已属于其他图奕用户时 `/sms/send` 与 `/complete` 返回 `409 phone_taken`：平台**不合并账号**，提示更换手机号或联系客服。
- 验证码错误 `422 invalid_code`；协议版本不符 `422 terms_changed`；密码不合规 `422 weak_password`；事务过期、取消或步骤不匹配 `409 invitation_unavailable`；重复完成返回原结果。
- 短信沿用成员激活模板（`managed_user_activation`），`unknown` 时同样不得自动重发。
- 完成在一个事务内：写入手机号身份、设置新密码、清除 `hosted_product_code`（`provisioning_source` 保持 `hosted`）、记录协议同意、认领置 `completed`、撤销该用户全部会话与 Token，并投递 `user.claimed`（仅本产品）与 `user.sessions_revoked`（`reason=account_claimed`，所有已映射产品）。之后成员可用手机号或账号名登录任意产品，产品不能再为其设置密码。
