diff --git a/chell要点.txt b/chell要点.txt index d1e8717..824eeee 100644 --- a/chell要点.txt +++ b/chell要点.txt @@ -30,4 +30,10 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理; 3. 熟悉群消息扩散策略,如读扩散、写扩散; 4. 理解多端同步、消息漫游、消息推送等 IM 核心机制; -5. 有 OpenIM 或类似 IM 项目经验优先。 \ No newline at end of file +5. 有 OpenIM 或类似 IM 项目经验优先。 + +1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行 +2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码 +3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册 + +用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段 \ No newline at end of file diff --git a/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md b/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md new file mode 100644 index 0000000..49b53bd --- /dev/null +++ b/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md @@ -0,0 +1,747 @@ +# IM HotGo 注册短信开关与邀请码管理改造交接文档 + +> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明 +> 对应实施顺序:注册改造计划第 4 项(HotGo 后台) +> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager` +> IM 服务项目:`/Users/youshunlai/go_workspace/im_server` +> 编写日期:2026-08-18 + +## 1. 文档用途 + +本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确: + +1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。 +2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。 +3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。 +4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。 +5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。 + +本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。 + +## 2. 开发前必须阅读 + +在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档: + +1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md` +2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节 +3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节 +4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md` + +规则冲突时按以下优先级处理: + +1. 本文针对 HotGo 的落地边界和接口约定。 +2. 《IM 注册、邀请码与认证数据模型改造需求文档》。 +3. 《IM 独立库多租户改造方案》。 +4. 其他较早的需求和设计文档。 + +`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。 + +## 3. 需求前因后果 + +### 3.1 注册页面最终规则 + +客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系: + +1. 手机号非空时,以手机号方式为准,忽略用户名输入。 +2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。 +3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。 +4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。 +5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。 + +### 3.2 为什么需要短信开关 + +短信服务在不同部署环境中可能暂未启用,因此需要平台级开关: + +| 场景 | 短信开关开启 | 短信开关关闭 | +| --- | --- | --- | +| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 | +| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 | +| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 | +| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 | + +HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。 + +### 3.3 为什么当前改为 HotGo 直接访问租户库 + +邀请码属于 IM 租户业务数据: + +1. 每个租户有独立业务库。 +2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。 +3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。 +4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。 + +因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。 + +密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。 + +## 4. 系统边界 + +```mermaid +flowchart LR + A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"] + B -->|读写全局开关| C[("hotgo.hg_sys_config")] + B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")] + B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")] + B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")] + B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")] + F --> I["im_registration_invite_uses"] + J["IM 注册与发码接口"] -->|短时缓存读取| C + J -->|按 tenant_id 校验| H + J -->|记录使用关系与事件| F + B -->|写 security.password_reset| K["im_business_events_outbox"] + F --> K + K -->|当前轮询,后续 Kafka| J +``` + +### 4.1 HotGo 负责 + +1. 在 `hg_sys_config` 保存全局注册短信开关。 +2. 只允许平台超级管理员修改开关。 +3. 为邀请码提供管理页面、表单、权限和审计入口。 +4. 从当前登录管理员身份解析可信的 IM `tenant_id`。 +5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。 +6. 对邀请码明文查询、查看和导出执行独立权限校验。 + +### 4.2 IM 服务负责 + +1. 读取并缓存短信开关。 +2. 实际发送或抑制短信。 +3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。 +4. 邀请码使用后不改变 active 状态。 +5. 消费 `security.password_reset` 事件并清退在线会话。 +6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。 +7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。 + +### 4.3 明确禁止 + +1. 浏览器直接连接租户数据库。 +2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。 +3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。 +4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。 +5. HotGo 绕过 outbox 直接修改在线会话状态。 +6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。 + +## 5. 当前 HotGo 项目现状 + +目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。 + +已确认的现状: + +1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。 +2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。 +3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。 +4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。 +5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。 +6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。 +7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。 +8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。 + +## 6. 建议开发顺序 + +### 阶段 A:先完成 HotGo 基础边界 + +1. 增加全局短信开关配置种子和专用读写接口。 +2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。 +3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。 +4. 增加邀请码相关菜单、路由权限和后台代理接口。 +5. 增加邀请码管理页面。 +6. 增加敏感字段日志脱敏和操作审计。 + +### 阶段 B:与 IM 服务联调 + +1. 核对 IM 租户库迁移已执行且表结构一致。 +2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。 +3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。 +4. 联调短信开关在 IM 服务缓存时间内生效。 + +IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。 + +## 7. 全局注册短信开关 + +### 7.1 数据定义 + +在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置: + +```text +group = im +name = 注册短信发送 +type = bool +key = register_sms_enabled +value = 1 +default_value = 1 +status = 1 +is_default = 1 +``` + +建议的 MySQL 幂等种子: + +```sql +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT + 'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10, + '控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM hg_sys_config + WHERE `group` = 'im' AND `key` = 'register_sms_enabled' +); +``` + +同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。 + +```sql +ALTER TABLE hg_sys_config + ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`); +``` + +新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。 + +### 7.2 `sys_switch` 映射陷阱 + +`sys_switch` 字典值与真实配置值不相同: + +| UI 字典值 | UI 含义 | `hg_sys_config.value` | +| --- | --- | --- | +| `1` | 开启 | `1` | +| `2` | 关闭 | `0` | + +实现要求: + +1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。 +2. 页面提交时反向映射:`1 -> true`,`2 -> false`。 +3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。 +4. 数据库只允许保存可解释为 `1/0` 的值。 + +### 7.3 后端接口 + +不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验: + +| 方法 | HotGo 路径 | 权限 | +| --- | --- | --- | +| GET | `/imRegistration/config/get` | 平台超级管理员 | +| POST | `/imRegistration/config/update` | 平台超级管理员 | + +读取响应: + +```json +{ + "registerSmsEnabled": true, + "updatedAt": "2026-08-18T16:00:00+08:00" +} +``` + +更新请求: + +```json +{ + "registerSmsEnabled": false +} +``` + +后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。 + +### 7.4 页面位置 + +建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增: + +```text +web/src/views/system/config/ImRegistrationSetting.vue +``` + +页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。 + +### 7.5 IM 服务读取约定 + +HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取: + +```sql +SELECT value +FROM hg_sys_config +WHERE `group` = 'im' + AND `key` = 'register_sms_enabled' + AND status = 1 +LIMIT 1; +``` + +约定: + +1. 种子默认值为开启。 +2. IM 服务缓存建议为 30 秒,缓存时长可配置。 +3. 临时读取失败时优先使用最近一次成功值。 +4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。 +5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。 + +## 8. 统一租户身份 + +### 8.1 唯一租户主键 + +`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。 + +### 8.2 管理员租户归属 + +后台管理员记录直接引用统一租户: + +```sql +ALTER TABLE hg_admin_member + ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id', + ADD INDEX idx_hg_admin_member_tenant (tenant_id), + ADD CONSTRAINT fk_hg_admin_member_im_tenant + FOREIGN KEY (tenant_id) REFERENCES im_tenant(id); +``` + +平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。 + +### 8.3 解析规则 + +租户管理员请求邀请码接口时: + +1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。 +2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。 +3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。 +4. 将该 `tenant_id` 写入 IM 服务调用上下文。 +5. 忽略并拒绝浏览器提交的 `tenantId`。 + +平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。 + +后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。 + +## 9. 租户库直连与事件通道 + +### 9.1 数据库选择 + +HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。 + +若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。 + +### 9.2 Snowflake 配置 + +HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID: + +```yaml +imDirectDb: + snowflakeWorkerId: 1023 +``` + +也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。 + +### 9.3 写入职责 + +HotGo 可以写: + +1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。 +2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。 +3. 管理操作产生的 `im_business_events_outbox`。 + +IM 独占写入: + +1. 客户端注册事务及用户创建。 +2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。 +3. 客户端登录、改密、短信找回等认证事务。 + +### 9.4 Kafka 演进 + +管理员重置密码在数据库事务内写入: + +```json +{ + "event_type": "security.password_reset", + "payload": { + "user_id": 198000000000000001, + "auth_version": 2, + "operator_id": 10001, + "request_id": "..." + } +} +``` + +当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。 + +邀请码使用事件契约: + +```json +{ + "event_type": "registration.invite_used", + "payload": { + "tenant_id": 1, + "invite_code_id": 198000000000000001, + "user_id": 198000000000000002, + "request_id": "acc-request-id", + "used_at": "2026-08-19T10:00:00.000Z" + } +} +``` + +迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。 + +## 10. 邀请码接口契约 + +### 10.1 数据表契约 + +HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。 + +平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。 + +邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。 + +### 10.2 HotGo 对浏览器接口 + +沿用 HotGo 现有命名风格,建议增加: + +| 方法 | HotGo 路径 | 说明 | +| --- | --- | --- | +| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body | +| POST | `/imInvite/create` | 单个生成 | +| POST | `/imInvite/batchCreate` | 批量生成 | +| GET | `/imInvite/batchView` | 批次详情 | +| POST | `/imInvite/revoke` | 单个撤销 | +| POST | `/imInvite/batchRevoke` | 批量撤销 | +| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body | + +HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。 + +### 10.3 单个生成 + +HotGo 请求: + +```json +{ + "expiresAt": "2026-09-01T00:00:00+08:00", + "remark": "新员工入职", + "idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f" +} +``` + +规则: + +1. `expiresAt` 必填,必须晚于服务端当前时间。 +2. `remark` 最长 255 字符。 +3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。 +4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。 + +响应数据: + +```json +{ + "id": 198000000000000001, + "code": "AbCdE", + "status": "active", + "expiresAt": "2026-09-01T00:00:00+08:00", + "remark": "新员工入职", + "createdBy": 10001, + "createdAt": "2026-08-18T16:10:00+08:00" +} +``` + +### 10.4 批量生成 + +HotGo 请求: + +```json +{ + "quantity": 100, + "expiresAt": "2026-09-01T00:00:00+08:00", + "remark": "2026年8月新员工入职", + "idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95" +} +``` + +规则: + +1. `quantity` 为 1 到 1000,由 HotGo 校验。 +2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。 +3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。 +4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。 + +响应数据: + +```json +{ + "batchId": 198000000000000100, + "quantity": 100, + "activeCount": 100, + "usageCount": 0, + "revokedCount": 0, + "expiredCount": 0, + "expiresAt": "2026-09-01T00:00:00+08:00", + "createdAt": "2026-08-18T16:15:00+08:00" +} +``` + +### 10.5 分页查询 + +列表请求使用 POST JSON Body,支持字段: + +```text +page, perPage, inviteCode, status, batchId, createdBy, +createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd +``` + +要求: + +1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。 +2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。 +3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。 +4. 默认按 `created_at DESC, id DESC` 排序。 +5. `perPage` 设置合理上限,建议不超过 100。 + +列表字段至少包括: + +```json +{ + "id": 198000000000000001, + "batchId": 198000000000000100, + "code": "AbCdE", + "status": "active", + "expiresAt": "2026-09-01T00:00:00+08:00", + "usageCount": 12, + "lastUsedAt": "2026-08-20T09:30:00+08:00", + "createdBy": 10001, + "remark": "新员工入职", + "createdAt": "2026-08-18T16:10:00+08:00" +} +``` + +### 10.6 撤销 + +1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。 +2. 已过期、已撤销的邀请码不能恢复为 active。 +3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。 +4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。 +5. 最终状态由租户库条件更新结果决定,不以前端状态为准。 + +### 10.7 导出 + +1. 导出必须具备独立 `/imInvite/export` 权限。 +2. 导出过滤条件与列表一致。 +3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。 +4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。 +5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。 +6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。 + +## 11. 页面和交互 + +建议页面路径: + +```text +web/src/views/im/registrationInvite/index.vue +web/src/views/im/registrationInvite/model.ts +web/src/views/im/registrationInvite/CreateModal.vue +web/src/views/im/registrationInvite/BatchCreateModal.vue +web/src/views/im/registrationInvite/BatchViewModal.vue +web/src/api/im/registrationInvite.ts +``` + +页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。 + +页面必须具备: + +1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。 +2. 单个生成按钮。 +3. 批量生成按钮。 +4. 导出按钮。 +5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。 +6. active 行提供撤销操作,其他状态不显示撤销操作。 +7. 批次详情展示总数、active、used、expired、revoked 统计。 +8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。 +9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。 + +邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。 + +## 12. 菜单与权限 + +HotGo 的权限标识使用接口路径。建议创建以下按钮权限: + +```text +/imRegistration/config/get +/imRegistration/config/update +/imInvite/list +/imInvite/create +/imInvite/batchCreate +/imInvite/batchView +/imInvite/revoke +/imInvite/batchRevoke +/imInvite/export +``` + +权限分配: + +| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 | +| --- | --- | --- | --- | +| 查看/修改短信开关 | 允许 | 禁止 | 禁止 | +| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 | +| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 | +| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 | +| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 | + +服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。 + +## 13. 日志脱敏与审计 + +### 13.1 必须修复现有访问日志泄露 + +`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏: + +```text +inviteCode +invite_code +serviceSecret +service_secret +authorization +``` + +建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。 + +对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。 + +### 13.2 业务审计 + +仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为: + +```sql +CREATE TABLE im_admin_operation_audit ( + id BIGINT NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) NOT NULL, + tenant_id BIGINT NULL, + operator_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL, + target_id BIGINT NULL, + result VARCHAR(16) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + detail_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_im_admin_audit_tenant_time (tenant_id, created_at), + KEY idx_im_admin_audit_operator_time (operator_id, created_at), + UNIQUE KEY uk_im_admin_audit_request_action (request_id, action) +); +``` + +必须审计: + +1. 短信开关修改,记录旧值和新值。 +2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。 +3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。 +4. 单个撤销和批量撤销。 +5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。 +6. 后台管理员租户归属变更。 + +IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。 + +## 14. 错误处理 + +HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。 + +至少处理: + +| 场景 | HotGo 行为 | +| --- | --- | +| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 | +| 目标租户停用 | 拒绝操作 | +| 无查看/导出权限 | 403 | +| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 | +| 幂等键重复且请求相同 | 返回原结果 | +| 幂等键重复但参数不同 | 返回幂等冲突 | +| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID | +| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 | +| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 | + +## 15. 建议代码落点 + +遵循现有 HotGo 分层,建议新增或修改: + +```text +server/api/admin/imregistration/ +server/api/admin/iminvite/ +server/internal/controller/admin/admin/im_registration.go +server/internal/controller/admin/admin/im_invite.go +server/internal/model/input/adminin/im_registration.go +server/internal/model/input/adminin/im_invite.go +server/internal/service/im.go +server/internal/logic/admin/im_registration.go +server/internal/logic/admin/im_invite.go +server/internal/library/imtenant/db.go +server/internal/library/imtenant/snowflake.go +server/internal/router/admin.go +server/internal/logic/sys/log.go +web/src/api/im/registrationInvite.ts +web/src/views/im/registrationInvite/ +web/src/views/system/config/ImRegistrationSetting.vue +web/src/views/system/config/system.vue +``` + +如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。 + +同时提交: + +1. 生产数据库幂等迁移。 +2. 新装数据库初始化数据。 +3. 菜单和按钮权限种子。 +4. 默认角色授权或明确的部署授权步骤。 +5. Snowflake worker 配置示例,但不提交真实 DSN。 + +## 16. 测试清单 + +### 16.1 短信开关 + +1. 无配置时 IM 按开启处理并告警。 +2. 平台超级管理员可查看和修改。 +3. 租户管理员即使手工调用接口也不能修改。 +4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。 +5. UI 重新加载时 `0` 正确显示为“关闭”。 +6. IM 服务在 30 秒内读取到新值。 +7. 开关审计包含操作者、旧值、新值和 Request ID。 + +### 16.2 租户隔离 + +1. 租户 A 管理员只能看到租户 A 邀请码。 +2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。 +3. 未绑定管理员不能回退到 default 租户。 +4. 停用租户不能生成、查询、撤销或导出。 +5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。 + +### 16.3 邀请码 + +1. 单个生成返回恰好 5 位 `[A-Za-z]`。 +2. 批量生成数量正确,最大值限制生效。 +3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。 +4. 重复提交相同幂等键不产生第二个批次。 +5. active 可撤销,expired/revoked 不可撤销。 +6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。 +7. 列表、详情和导出权限相互独立。 +8. 导出文件数量、过滤条件和明文字段正确。 + +### 16.4 安全和日志 + +1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。 +2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。 +3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。 +4. 无权限接口返回 403,不能只依赖前端按钮隐藏。 +5. 数据库连接和 SQL 错误不泄露内部实现信息。 + +## 17. 完成标准 + +满足以下条件才能认为 HotGo 第 4 项完成: + +1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。 +2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。 +3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。 +4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。 +5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。 +6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。 +7. 配置修改和邀请码敏感操作均有可关联的审计记录。 +8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。 + +## 18. 新会话可直接使用的任务说明 + +在新的开发会话中可直接发送: + +```text +请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。 +先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md, +再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、 +统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。 +只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN, +不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。 +开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。 +``` diff --git a/docs/IM-产品原型需求文档.md b/docs/IM-产品原型需求文档.md index 22a7c7c..53636b8 100644 --- a/docs/IM-产品原型需求文档.md +++ b/docs/IM-产品原型需求文档.md @@ -24,7 +24,7 @@ 功能说明: -展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。 +展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。登录成功后安全保存 Token 和过期时间;应用重启或断线重连时优先使用未过期 Token 自动登录,并用服务端返回的新 Token 覆盖旧值。Token 过期或失效后返回账号密码登录。 ### 1.3 登录页面-密码错误.png @@ -40,7 +40,17 @@ 功能说明: -提供用户名/手机号、短信验证码、密码和确认密码输入。需支持获取验证码、密码显隐、两次密码一致性校验、已有账号返回登录以及协议勾选。 +提供独立的用户名和手机号输入框,两个字段二选一,允许同时填写但手机号优先。需支持获取验证码、密码显隐、已有账号返回登录以及协议确认。 + +业务规则: + +1. 普通用户名只允许 1~10 位小写英文字母或数字,允许纯数字,不允许大写、中文、空格和符号。 +2. 手机号只允许 11 位数字,服务端固定补 `+86`。 +3. 手机号非空时走手机号方式并忽略用户名输入;注册后 `username` 和 `phone` 均保存规范化手机号。手机号为空时才走用户名方式,`phone=NULL`。 +4. 邀请码固定为 5 位大小写英文字母,区分大小写,不允许数字和符号。 +5. 用户名方式不发送短信,必须填写有效邀请码;注册前提示“未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置”。 +6. 短信开关开启时,手机号方式必须完成验证码验证,邀请码不能绕过验证码。 +7. 短信开关关闭时,点击获取验证码仍显示正常倒计时,但服务端不发送短信;手机号方式必须填写有效邀请码才能无验证码注册。 ### 1.5 注册页面-倒计时.png @@ -48,7 +58,7 @@ 功能说明: -展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。 +展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。短信开关关闭时前端交互和倒计时保持不变,但用户不会收到短信,最终是否允许注册由服务端结合邀请码判断。 ### 1.6 忘记密码.png @@ -56,7 +66,7 @@ 功能说明: -通过手机号、验证码、新密码和再次确认完成密码重置。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录。 +通过手机号、验证码、新密码和再次确认完成密码重置,仅适用于已保存手机号的账号。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录;无手机号的用户名账号应提示联系当前企业管理员重置密码。 ### 1.7 身份验证(安卓).png diff --git a/docs/IM-前后端对接文档-正式版.html b/docs/IM-前后端对接文档-正式版.html new file mode 100644 index 0000000..5a8cba0 --- /dev/null +++ b/docs/IM-前后端对接文档-正式版.html @@ -0,0 +1,1559 @@ +IM-前后端对接文档 +

IM 前后端逐图对接协议

+
版本:v2.0
+
日期:2026-08-11
+
需求依据:IM 产品原型需求文档
+
设计依据:IM 系统设计文档
+
说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。
+

0. 公共对接约定

+

0.1 实现状态

+ + + + + +
状态含义
已实现当前服务端已有能力,可按本文联调
需改造已有基础能力,但字段、鉴权或业务规则需要调整
新增当前服务端没有该产品接口或数据模型
纯客户端界面或系统能力,不调用服务端;关联的提交协议会单独指出
+

文档中所有 新增、需改造 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。

+

0.2 API Key、启动入口与通道

+

0.2.1 现有系统的 API Key 从哪里来

+

API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。

+

API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置:

+
    +
  1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 go run ./keygen。
  2. +
  3. 已有服务端继续使用当前 api_key_salt 时,执行 go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>' 生成兼容的普通应用 Key。
  4. +
  5. 输出中的 HMAC salt 配置到服务端 server/tinode.conf 的 api_key_salt;输出中的 API key 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
  6. +
  7. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 -isroot 1 生成的 Root Key 放进客户端。
  8. +
+

生成示意:

+

+$ go run ./keygen
+API key v1 seq1 [ordinary]: <client-api-key>
+HMAC salt: <server-hmac-salt>
+
+

API Key、企业码和用户 Token 是三个不同概念:

+ + + + +
值取得时间作用是否代表用户身份
api_key应用发布/环境配置时预置允许应用访问 Tinode 入口否
tenant_code用户输入企业码将 Session 路由并绑定到企业否
token用户登录成功后由服务端返回标识当前租户内的登录用户是
+

API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 checkAPIKey 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。

+

当前 checkAPIKey 没有按 sequence 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 api_key_salt 会让所有旧 Key 同时失效,必须与客户端升级协调。

+

0.2.2 客户端怎样携带 API Key

+ + + + + +
通道地址认证用途
WebSocket(浏览器/通用方式)wss://{host}/{api_path}v0/channels?apikey={api_key}Upgrade 校验 API Key,hi 绑定租户,login 登录Topic、消息、回执、在线状态、通话信令
WebSocket(支持自定义 Header 的原生客户端)wss://{host}/{api_path}v0/channelsUpgrade Header X-Tinode-APIKey同上
产品 HTTPhttps://{host}/{api_path}v1/im/*Authorization: Bearer <params.token>好友、群、收藏、搜索、工作台和设置
文件 HTTPPOST /v0/file/u/、GET /v0/file/s/{file_id}当前用 X-Tinode-Auth: token <base64_token>文件上传下载;禁止 Query 传 Token
+

HTTP 请求统一通过 X-Tinode-APIKey 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 apikey Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 X-IM-Schema: 1 和 X-Request-ID;写操作带 Idempotency-Key。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。

+

WebSocket Upgrade 阶段由 server/hdl_websock.go 先调用 checkAPIKey。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403:

+

+{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}}
+
+

客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。

+

两种连接方式的第一个请求对照如下:

+ + + +
连接方式发起第一个网络请求前已有的值第一个网络请求第一个响应得到的值下一步
WebSocket(推荐)host + api_key + tenant_code带 API Key Upgrade /v0/channelsHTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid发送 {hi tenant}
Long Polling(兼容)host + api_key + tenant_codePOST /v0/channels/lpHTTP 201 和 ctrl.params.sid带 sid POST {hi},再 GET 等待响应
+

现有实现对应关系:

+ + + + + + + +
源码作用
keygen/keygen.go生成普通/Root API Key 和服务端 HMAC Salt
server/api_key.gocheckAPIKey 校验 API Key 格式与签名
server/http.go按 Header、Query、Form、Cookie 顺序读取 API Key
server/hdl_websock.goWebSocket Upgrade 前校验 API Key,无效直接 403
server/hdl_longpoll.go创建 Long Polling Session 并返回 sid
server/session.go处理 {hi} 租户绑定、{login} 和登录 Token 返回
+

现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 {hi}.tenant 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。

+

0.3 多租户强制规则

+
    +
  1. 未登录阶段只传公开的 tenant_code;客户端永远不接收、不提交数据库 tenant_id。
  2. +
  3. WebSocket 第一条消息必须是 {hi},其中 tenant 为企业码。服务端解析后把租户固定到 Session。
  4. +
  5. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 TENANT_MISMATCH,不得自动切换。
  6. +
  7. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。
  8. +
  9. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 tenant_id、user_id 不能覆盖身份。
  10. +
  11. 跨租户对象统一返回 RESOURCE_NOT_FOUND 或 FORBIDDEN,不得暴露对象是否存在。
  12. +
+

0.4 HTTP 请求与响应

+

成功响应统一为:

+

+{
+  "code": 0,
+  "data": {},
+  "request_id": "01K...",
+  "server_time": "2026-08-11T08:30:15.123Z"
+}
+
+

失败响应统一为:

+

+{
+  "code": 30001,
+  "reason": "GROUP_MUTED",
+  "message": "permission denied",
+  "details": {},
+  "request_id": "01K...",
+  "server_time": "2026-08-11T08:30:15.123Z"
+}
+
+

客户端只使用稳定的 reason 分支和本地化文案,不解析 message。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 limit 与不透明 cursor,返回 items、next_cursor、has_more。

+

0.5 WebSocket 建连、登录和恢复顺序

+

0.5.1 现有服务端可直接执行的冷启动流程

+

+读取客户端预置的 host 和普通 API Key
+-> 用户输入/客户端读取 tenant_code
+-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置
+-> 带 API Key 完成 WebSocket Upgrade
+-> hi(tenant_code)
+-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制
+-> 有历史 Token 时 token login,否则 basic login/acc
+-> 收到 login 的 200 ctrl,保存 user/token/expires
+-> sub(me) 拉取本人资料和会话订阅
+-> sub(fnd) 建立搜索 Topic
+-> 订阅需要在线的会话并按 seq 增量拉取
+-> 进入可发送状态
+
+

当前服务端已提供 /v1/im/tenants/resolve,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 {hi}。即使 HTTP 解析成功,WebSocket Upgrade 返回 101 Switching Protocols 后仍必须先发送下面的 {hi} JSON 文本帧,并使用同一个 tenant_code 绑定当前 Session。

+

hi 请求:

+

+{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}}
+
+

login 请求使用 basic 或 token:

+

+{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}
+
+

+{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}}
+
+

basic.secret 是 base64(username + ":" + password)。Token 登录必须把上次 params.token 的 JSON 字符串原样填入 secret,不能再次 Base64 编码。登录成功 {ctrl} 的 params 至少包含 user、authlvl、token、expires。产品 HTTP 也使用同一个 params.token:Authorization: Bearer <params.token>,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。id 只用于匹配本次 Session 的命令响应,不是业务幂等键。

+

hi 成功示例:

+

+{
+  "ctrl": {
+    "id": "hi-1",
+    "code": 201,
+    "text": "created",
+    "params": {
+      "ver": "0.25",
+      "tenant": {"code": "acme", "name": "示例企业"},
+      "maxFileUploadSize": 104857600,
+      "callTimeout": 30,
+      "iceServers": []
+    }
+  }
+}
+
+

客户端只使用实际响应中存在的限制字段;未知字段忽略。hi 返回非 2xx 时必须关闭连接,不得继续发送 login。

+

登录成功后发送:

+

+{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}
+
+

客户端按相同 id 接收 {ctrl} 和一个或多个 {meta},从 meta.desc 取得本人资料和读写游标,从 meta.sub 取得会话 Topic、访问权限、seq/read/recv 与会话摘要。不要依赖 {ctrl}、{meta} 的固定先后顺序。

+

随后建立查找 Topic:

+

+{"sub":{"id":"sub-fnd-1","topic":"fnd"}}
+
+

进入具体会话时订阅并增量拉取,since 使用客户端已完整持久化的最后 seq + 1:

+

+{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}
+
+

订阅成功后才允许 {pub}、note read/recv 等会话操作。断线恢复必须重新执行 WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic,同一连接内不得跳过 hi 或切换企业码。

+

0.5.2 Long Polling 兼容流程:先取得的是 sid

+

如果客户端无法使用 WebSocket,可以使用现有 /v0/channels/lp。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID sid,API Key 仍然必须由客户端预先持有。

+

第一步,创建 Long Polling Session:

+

+POST /v0/channels/lp HTTP/1.1
+Host: im.example.com
+X-Tinode-APIKey: <预置的普通 API Key>
+Content-Length: 0
+
+

服务端返回 HTTP 201:

+

+{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}}
+
+

第二步开始,所有 Long Polling 请求都同时携带 API Key 和 sid。发送 hi:

+

+POST /v0/channels/lp?sid=session-id HTTP/1.1
+X-Tinode-APIKey: <预置的普通 API Key>
+Content-Type: application/json
+
+{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}}
+
+

POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 {ctrl}、{meta}、{data}、{pres} 或 {info}:

+

+GET /v0/channels/lp?sid=session-id HTTP/1.1
+X-Tinode-APIKey: <预置的普通 API Key>
+
+

收到 hi 成功后,以相同方式 POST {login},再 GET 登录结果;登录成功后依次 POST {sub me}、{sub fnd} 和会话订阅,并持续发起下一次 GET 等待下行消息。

+

sid 无效或过期时服务端返回 HTTP 403 invalid or expired session,客户端必须重新创建 Long Polling Session 并从 hi 开始,不能只重发 login。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。

+

0.5.3 企业解析与产品流程

+

第 1.1 节的 POST /v1/im/tenants/resolve 已实现,推荐产品入口流程为:

+

+读取预置 API Key
+-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置
+-> 带同一 API Key 建立返回地址的 WebSocket
+-> hi 再次绑定并校验 tenant_code
+-> login -> sub me -> 恢复会话
+
+

企业解析接口只改善入口、服务发现和品牌配置,不能代替 {hi} 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 tenant_code。如果解析响应中的 endpoints 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。

+

0.6 Topic 与产品消息

+

核心命令:sub 订阅、pub 发送、get 拉取、set 更新 Topic、leave 离开、del 删除、note 发送回执/临时状态。发送成功为 {ctrl code:202, params:{seq}},服务端下行消息为 {data}。

+

所有产品消息在 pub.head 中携带:

+

+{
+  "pub": {
+    "id": "pub-1",
+    "topic": "p2p...",
+    "noecho": false,
+    "head": {
+      "x-im-schema": 1,
+      "x-im-type": "text",
+      "x-im-client-mid": "01K...",
+      "mentions": []
+    },
+    "content": {"text":"你好"}
+  }
+}
+
+

x-im-client-mid 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 seq。未知 x-im-type 显示“暂不支持的消息”,不得崩溃或删除消息。

+

消息类型首版固定为:text、image、video、voice、file、contact_card、link、forward_bundle、call、system_event。自定义表情启用后追加 sticker。媒体内容只保存 file_id、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。

+

0.7 HTTP 幂等、重试和安全

+
    +
  1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 Idempotency-Key。
  2. +
  3. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 retry_after_seconds 重试。
  4. +
  5. 401 TOKEN_EXPIRED 只允许一次静默刷新;刷新失败必须退出登录。403 不重试,429 按服务端退避。
  6. +
  7. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。
  8. +
  9. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。
  10. +
+

1. 账号注册、登录与身份安全

+

1.1 企业码登录.png

+
企业码登录
企业码登录
+

状态:企业解析接口与 WebSocket 租户握手均已实现。

+

当前系统的执行方式:

+ +

企业解析接口:

+ +

成功响应示例:

+

+{
+  "code": 0,
+  "data": {
+    "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}},
+    "endpoints": {
+      "websocket": "wss://im.example.com/v0/channels",
+      "api": "https://im.example.com/v1/im",
+      "file": "https://im.example.com/v0/file"
+    },
+    "auth_methods": ["password", "sms"],
+    "password_policy": {"min_length": 8, "max_length": 64},
+    "app_update": {"type": "none"}
+  },
+  "request_id": "01K...",
+  "server_time": "2026-08-11T08:30:15.123Z"
+}
+
+

1.2 登录页面.png

+
登录页面
登录页面
+

状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。

+ +

登录成功示例:

+

+{
+  "ctrl": {
+    "id": "login-1",
+    "code": 200,
+    "text": "ok",
+    "params": {
+      "user": "usr...",
+      "authlvl": "auth",
+      "token": "base64-opaque-token",
+      "expires": "2026-08-11T10:30:15.123Z"
+    }
+  }
+}
+
+

失败仍是同一 {ctrl} 结构,使用 code 和改造后的稳定 params.reason 分支;客户端不得用 text 做程序判断。

+

1.3 登录页面-密码错误.png

+
登录页面-密码错误
登录页面-密码错误
+

状态:已实现基础错误,错误码和限流需改造。

+ +

1.4 注册页面.png

+
注册页面
注册页面
+

状态:Tinode {acc} 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 123456。

+ +

+{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}}
+
+ +

1.5 注册页面-倒计时.png

+
注册页面-倒计时
注册页面-倒计时
+

状态:新增。

+ +

1.6 忘记密码.png

+
忘记密码
忘记密码
+

状态:新增。

+ +

1.7 身份验证(安卓).png

+
身份验证(安卓)
身份验证(安卓)
+

状态:新增。

+ +

1.8 实名认证.png

+
实名认证
实名认证
+

状态:新增。

+ +

1.9 实名认证-已实名.png

+
实名认证-已实名
实名认证-已实名
+

状态:新增。

+ +

1.10 实名认证(安卓).png

+
实名认证(安卓)
实名认证(安卓)
+

状态:新增。

+ +

1.11 设置独立密码(安卓).png

+
设置独立密码(安卓)
设置独立密码(安卓)
+

状态:新增。

+ +

2. 消息首页、全局导航与系统通知

+

2.1 消息.png

+
消息
消息
+

状态:会话订阅已实现;产品摘要和系统入口需改造。

+ +

2.2 消息-无网络状态.png

+
消息-无网络状态
消息-无网络状态
+

状态:需改造客户端恢复流程和消息幂等。

+ +

2.3 消息-更多.png

+
消息-更多
消息-更多
+

状态:菜单为纯客户端;权限数据新增。

+ +

2.4 消息页面—长按弹出.png

+
消息页面—长按弹出
消息页面—长按弹出
+

状态:基础消息操作部分已实现;产品权限矩阵需改造。

+ +

2.5 系统消息.png

+
系统消息
系统消息
+

状态:需改造。

+ +

3. 单聊会话、会话建立与消息状态

+

3.1 个人对话框(对方的状态).png

+
个人对话框(对方的状态)
个人对话框(对方的状态)
+

状态:需改造好友关系状态。

+ +

3.2 个人对话框(对方的状态)(1).png

+

!个人对话框(对方的状态)(1).png)

+

状态:在线状态已具备基础能力;关系文案新增。

+ +

3.3 个人对话框(我的状态).png

+
个人对话框(我的状态)
个人对话框(我的状态)
+

状态:新增好友申请状态。

+ +

3.4 个人对话框(我的状态)(1).png

+

!个人对话框(我的状态)(1).png)

+

状态:发送和读回执已实现基础能力;送达状态映射需统一。

+ +

3.5 开启沟通页面.png

+
开启沟通页面
开启沟通页面
+

状态:新增好友审批与 Topic 联动。

+ +

3.6 双方沟通状态.png

+
双方沟通状态
双方沟通状态
+

状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。

+ +

3.7 个人对话—拉黑状态/已读状态.png

+
个人对话—拉黑状态/已读状态
个人对话—拉黑状态/已读状态
+

状态:黑名单产品规则新增。

+ +

3.8 个人对话—删除状态.png

+
个人对话—删除状态
个人对话—删除状态
+

状态:新增好友删除后的发送约束。

+ +

3.9 个人对话-键盘收起状态.png

+
个人对话-键盘收起状态
个人对话-键盘收起状态
+

状态:纯客户端。

+ +

3.10 个人对话-发各种文件状态.png

+
个人对话-发各种文件状态
个人对话-发各种文件状态
+

状态:面板纯客户端;上传需改造。

+ +

3.11 个人对话-发各种文件状态(1).png

+

!个人对话-发各种文件状态(1).png)

+

状态:需改造产品消息 Schema。

+ +

3.12 个人对话-发送名片状态.png

+
个人对话-发送名片状态
个人对话-发送名片状态
+

状态:新增产品消息类型。

+ +

4. 消息输入、媒体内容与消息操作

+

4.1 键盘展开.png

+
键盘展开
键盘展开
+

状态:纯客户端。

+ +

4.2 @人员—选择提醒人.png

+
@人员—选择提醒人
@人员—选择提醒人
+

状态:新增群成员查询。

+ +

4.3 @人员—完成.png

+
@人员—完成
@人员—完成
+

状态:纯客户端选择;发送协议需改造。

+ +

4.4 @人员状态.png

+
@人员状态
@人员状态
+

状态:需改造提醒和 Push。

+ +

4.5 表情包展开(发送按钮默认状态).png

+
表情包展开(发送按钮默认状态)
表情包展开(发送按钮默认状态)
+

状态:纯客户端。

+ +

4.6 表情包发送页面.png

+
表情包发送页面
表情包发送页面
+

状态:Unicode 表情使用已实现文本消息;独立表情包新增。

+ +

4.7 语音—按住说话.png

+
语音—按住说话
语音—按住说话
+

状态:录音纯客户端;上传和语音消息需改造。

+ +

4.8 语音按住说话.png

+
语音按住说话
语音按住说话
+

状态:纯客户端。

+ +

4.9 内容沟通对话3-语音录音中.png

+
内容沟通对话3-语音录音中
内容沟通对话3-语音录音中
+

状态:录音纯客户端;临时状态可选改造。

+ +

4.10 内容沟通对话3-图片和语音发送中.png

+
内容沟通对话3-图片和语音发送中
内容沟通对话3-图片和语音发送中
+

状态:客户端任务管理新增;服务端上传需改造。

+ +

4.11 内容沟通对话3-语音/视频/图片状态.png

+
内容沟通对话3-语音/视频/图片状态
内容沟通对话3-语音/视频/图片状态
+

状态:需改造媒体 Schema 和受权下载。

+ +

4.12 语音图片发送状态.png

+
语音图片发送状态
语音图片发送状态
+

状态:需改造。

+ +

4.13 上传文件.png

+
上传文件
上传文件
+

状态:纯客户端视觉资源。

+ +

4.14 文件发送状态.png

+
文件发送状态
文件发送状态
+

状态:上传已实现基础能力;消息引用和 ACL 需改造。

+ +

4.15 多选-选中对话框.png

+
多选-选中对话框
多选-选中对话框
+

状态:选择纯客户端;批量动作新增。

+ +

4.16 多选-选中对话内容-收藏成功.png

+
多选-选中对话内容-收藏成功
多选-选中对话内容-收藏成功
+

状态:新增。

+ +

4.17 转发-单选.png

+
转发-单选
转发-单选
+

状态:新增。

+ +

4.18 转发-多选.png

+
转发-多选
转发-多选
+

状态:新增。

+ +

4.19 转发—逐条转发.png

+
转发—逐条转发
转发—逐条转发
+

状态:新增。

+ +

4.20 转发—合并转发—多选状态.png

+
转发—合并转发—多选状态
转发—合并转发—多选状态
+

状态:新增。

+ +

5. 通讯录、好友关系与黑名单

+

5.1 通讯录.png

+
通讯录
通讯录
+

状态:新增产品通讯录接口。

+ +

5.2 新朋友.png

+
新朋友
新朋友
+

状态:新增。

+ +

5.3 添加好友.png

+
添加好友
添加好友
+

状态:新增。

+ +

5.4 添加好友(1).png

+

!添加好友(1).png)

+

状态:新增。

+ +

5.5 添加好友-输入精准用户ID.png

+
添加好友-输入精准用户ID
添加好友-输入精准用户ID
+

状态:新增。

+ +

5.6 添加好友验证.png

+
添加好友验证
添加好友验证
+

状态:新增。

+ +

5.7 好友个人主页.png

+
好友个人主页
好友个人主页
+

状态:新增。

+ +

5.8 群聊—非好友个人主页.png

+
群聊—非好友个人主页
群聊—非好友个人主页
+

状态:新增。

+ +

5.9 好友—聊天设置—未拉黑状态.png

+
好友—聊天设置—未拉黑状态
好友—聊天设置—未拉黑状态
+

状态:部分 Topic 设置已实现;产品设置新增。

+ +

5.10 好友—聊天设置—拉黑状态.png

+
好友—聊天设置—拉黑状态
好友—聊天设置—拉黑状态
+

状态:新增。

+ +

5.11 删除好友提示.png

+
删除好友提示
删除好友提示
+

状态:确认框纯客户端;删除接口新增。

+ +

5.12 黑名单.png

+
黑名单
黑名单
+

状态:新增。

+ +

5.13 黑名单提示(1).png

+

!黑名单提示(1).png)

+

状态:确认框纯客户端;写接口新增。

+ +

5.14 黑名单提示.png

+
黑名单提示
黑名单提示
+

状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。

+ +

6. 群聊创建、资料维护与成员治理

+

6.1 群聊.png

+
群聊
群聊
+

状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。

+ +

6.2 空白页.png

+
空白页
空白页
+

状态:纯客户端。

+ +

6.3 加入群聊.png

+
加入群聊
加入群聊
+

状态:新增邀请卡片和加入接口。

+ +

6.4 内容沟通对话3-开启进群验证与拉人状态.png

+
内容沟通对话3-开启进群验证与拉人状态
内容沟通对话3-开启进群验证与拉人状态
+

状态:新增群成员事件。

+ +

6.5 邀请进群申请.png

+
邀请进群申请
邀请进群申请
+

状态:新增。

+ +

6.6 新邀请进群申请.png

+
新邀请进群申请
新邀请进群申请
+

状态:新增。

+ +

6.7 群公告状态.png

+
群公告状态
群公告状态
+

状态:新增。

+ +

6.8 群公告状态/邀请进群申请同时存在.png

+
群公告状态/邀请进群申请同时存在
群公告状态/邀请进群申请同时存在
+

状态:纯客户端组合;数据接口新增。

+ +

6.9 群公告(对方查看状态).png

+
群公告(对方查看状态)
群公告(对方查看状态)
+

状态:新增。

+ +

6.10 群聊设置.png

+
群聊设置
群聊设置
+

状态:部分订阅设置已实现;产品菜单新增。

+ +

6.11 群资料.png

+
群资料
群资料
+

状态:需改造。

+ +

6.12 设置群头像.png

+
设置群头像
设置群头像
+

状态:上传基础已实现;用途绑定需改造。

+ +

6.13 设置昵称(1).png

+

!设置昵称(1).png)

+

状态:新增。

+ +

6.14 群简介.png

+
群简介
群简介
+

状态:同 6.11,需改造。

+ +

6.15 群标识.png

+
群标识
群标识
+

状态:角色已有基础数据;产品渲染需统一。

+ +

6.16 群二维码页面.png

+
群二维码页面
群二维码页面
+

状态:新增。

+ +

6.17 群组管理.png

+
群组管理
群组管理
+

状态:新增。

+ +

6.18 设置群管理员.png

+
设置群管理员
设置群管理员
+

状态:新增。

+ +

6.19 添加管理员.png

+
添加管理员
添加管理员
+

状态:新增。

+ +

6.20 添加管理员-设置成功.png

+
添加管理员-设置成功
添加管理员-设置成功
+

状态:新增。

+ +

6.21 添加管理员-设置成功(1).png

+

!添加管理员-设置成功(1).png)

+

状态:新增。

+ +

6.22 删除管理员-设置成功.png

+
删除管理员-设置成功
删除管理员-设置成功
+

状态:新增。

+ +

6.23 添加禁言成员.png

+
添加禁言成员
添加禁言成员
+

状态:新增。

+ +

6.24 禁言的群成员.png

+
禁言的群成员
禁言的群成员
+

状态:新增。

+ +

6.25 选择新群主.png

+
选择新群主
选择新群主
+

状态:新增。

+ +

6.26 选择新群主提示.png

+
选择新群主提示
选择新群主提示
+

状态:新增。

+ +

6.27 群文件.png

+
群文件
群文件
+

状态:新增群文件索引;底层文件存储已实现。

+ +

6.28 群文件-上传状态.png

+
群文件-上传状态
群文件-上传状态
+

状态:需改造。

+ +

7. 文件下载、收藏管理与聊天记录检索

+

7.1 我的收藏.png

+
我的收藏
我的收藏
+

状态:新增。

+ +

7.2 我的收藏(1).png

+

!我的收藏(1).png)

+

状态:新增。

+ +

7.3 我的收藏-文件.png

+
我的收藏-文件
我的收藏-文件
+

状态:新增。

+ +

7.4 我的收藏-文本.png

+
我的收藏-文本
我的收藏-文本
+

状态:新增。

+ +

7.5 我的收藏-语音.png

+
我的收藏-语音
我的收藏-语音
+

状态:新增。

+ +

7.6 我的收藏—链接.png

+
我的收藏—链接
我的收藏—链接
+

状态:新增。

+ +

7.7 收藏—文件(多选).png

+
收藏—文件(多选)
收藏—文件(多选)
+

状态:新增。

+ +

7.8 收藏文件—分享给好友页面.png

+
收藏文件—分享给好友页面
收藏文件—分享给好友页面
+

状态:新增。

+ +

7.9 文件-下载.png

+
文件-下载
文件-下载
+

状态:底层下载已实现租户校验;资源 ACL 需改造。

+ +

7.10 文件-下载中.png

+
文件-下载中
文件-下载中
+

状态:客户端任务管理;服务端需支持 Range。

+ +

7.11 文件-下载完成.png

+
文件-下载完成
文件-下载完成
+

状态:纯客户端完成态。

+ +

7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png

+
查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)
查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)
+

状态:新增服务端搜索。

+ +

7.13 查找聊天记录-文件.png

+
查找聊天记录-文件
查找聊天记录-文件
+

状态:新增。

+ +

7.14 查找聊天记录-链接.png

+
查找聊天记录-链接
查找聊天记录-链接
+

状态:新增。

+ +

7.15 查看聊天记录-图片.png

+
查看聊天记录-图片
查看聊天记录-图片
+

状态:新增。

+ +

7.16 查看聊天记录-视频.png

+
查看聊天记录-视频
查看聊天记录-视频
+

状态:新增。

+ +

8. 实时语音与视频通话

+

8.1 各种通话提示状态.png

+
各种通话提示状态
各种通话提示状态
+

状态:已实现基础通话记录替换;产品文案映射需统一。

+ +

8.2 对方状态.png

+
对方状态
对方状态
+

状态:在线邀请已实现;Push 与产品鉴权需改造。

+ +

8.3 语音通话-对方待接受状态.png

+
语音通话-对方待接受状态
语音通话-对方待接受状态
+

状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。

+ +

+{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}}
+
+ +

8.4 语音通话-双方语音沟通中.png

+
语音通话-双方语音沟通中
语音通话-双方语音沟通中
+

状态:WebRTC 客户端能力;信令和服务端计时已实现。

+ +

8.5 语音通话-麦克风与扬声器已关.png

+
语音通话-麦克风与扬声器已关
语音通话-麦克风与扬声器已关
+

状态:纯客户端设备控制。

+ +

8.6 语音通话-麦克风与扬声器已开.png

+
语音通话-麦克风与扬声器已开
语音通话-麦克风与扬声器已开
+

状态:纯客户端设备控制。

+ +

8.7 视频通话—等待对方接受接受邀请中.png

+
视频通话—等待对方接受接受邀请中
视频通话—等待对方接受接受邀请中
+

状态:基础通话信令已实现;隐私约束需落实。

+ +

8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png

+
视频通话—等待对方接受接受邀请-摄像头关闭
视频通话—等待对方接受接受邀请-摄像头关闭
+

状态:纯客户端状态;媒体状态事件需改造。

+ +

8.9 双方视频通话中.png

+
双方视频通话中
双方视频通话中
+

状态:WebRTC 客户端能力;服务端信令已具备基础。

+ +

8.10 双方视频通话中-对方关闭摄像头状态.png

+
双方视频通话中-对方关闭摄像头状态
双方视频通话中-对方关闭摄像头状态
+

状态:现阶段纯客户端;可选媒体状态事件需改造。

+ +

8.11 视频与语音通话悬浮状态中.png

+
视频与语音通话悬浮状态中
视频与语音通话悬浮状态中
+

状态:纯客户端。

+ +

9. 企业工作台与办公协同

+

9.1 工作台.png

+
工作台
工作台
+

状态:新增。

+ +

9.2 部门组织框架.png

+
部门组织框架
部门组织框架
+

状态:新增。

+ +

9.3 会议通知-发起人状态.png

+
会议通知-发起人状态
会议通知-发起人状态
+

状态:新增。

+ +

9.4 会议通知-接收人状态.png

+
会议通知-接收人状态
会议通知-接收人状态
+

状态:新增。

+ +

9.5 日报.png

+
日报
日报
+

状态:新增。

+ +

9.6 日报-普通用户页面.png

+
日报-普通用户页面
日报-普通用户页面
+

状态:新增。

+ +

9.7 日报-管理员页面.png

+
日报-管理员页面
日报-管理员页面
+

状态:新增。

+ +

9.8 日报-查看详情.png

+
日报-查看详情
日报-查看详情
+

状态:新增。

+ +

9.9 月报(1).png

+

!月报(1).png)

+

状态:新增。

+ +

9.10 月报.png

+
月报
月报
+

状态:新增。

+ +

9.11 月报-详情页.png

+
月报-详情页
月报-详情页
+

状态:新增。

+ +

9.12 总经理信箱.png

+
总经理信箱
总经理信箱
+

状态:新增。

+ +

9.13 总经理信箱-文案提示.png

+
总经理信箱-文案提示
总经理信箱-文案提示
+

状态:新增配置接口。

+ +

10. 个人资料、通用设置、隐私与青少年模式

+

10.1 我的.png

+
我的
我的
+

状态:基础用户资料已有;产品菜单新增。

+ +

10.2 个人信息.png

+
个人信息
个人信息
+

状态:Tinode me 资料更新已有基础;目标产品接口需改造。

+ +

10.3 设置昵称.png

+
设置昵称
设置昵称
+

状态:需改造。

+ +

10.4 出生日期.png

+
出生日期
出生日期
+

状态:新增产品字段。

+ +

10.5 个性签名.png

+
个性签名
个性签名
+

状态:需改造。

+ +

10.6 二维码.png

+
二维码
二维码
+

状态:图标纯客户端;二维码协议新增。

+ +

10.7 设置(iOS).png

+
设置(iOS)
设置(iOS)
+

状态:菜单纯客户端;数据接口部分新增。

+ +

10.8 设置(安卓).png

+
设置(安卓)
设置(安卓)
+

状态:菜单纯客户端;业务协议同 10.7。

+ +

10.9 清空缓存的提示.png

+
清空缓存的提示
清空缓存的提示
+

状态:纯客户端。

+ +

10.10 版本更新页面.png

+
版本更新页面
版本更新页面
+

状态:新增 App 配置接口。

+ +

10.11 发现新版本.png

+
发现新版本
发现新版本
+

状态:纯客户端弹窗;数据来自 10.10。

+ +

10.12 隐私设置.png

+
隐私设置
隐私设置
+

状态:新增。

+ +

10.13 隐私设置-添加我的方式选项.png

+
隐私设置-添加我的方式选项
隐私设置-添加我的方式选项
+

状态:新增。

+ +

10.14 青少年模式未开启(安卓).png

+
青少年模式未开启(安卓)
青少年模式未开启(安卓)
+

状态:新增。

+ +

10.15 青少年模式提示(安卓).png

+
青少年模式提示(安卓)
青少年模式提示(安卓)
+

状态:弹窗纯客户端;文案由服务端配置。

+ +

10.16 青少年模式已开启(安卓).png

+
青少年模式已开启(安卓)
青少年模式已开启(安卓)
+

状态:新增。

+ +

10.17 关闭青少年模式(安卓).png

+
关闭青少年模式(安卓)
关闭青少年模式(安卓)
+

状态:新增。

+ +

10.18 获取推送权限(安卓).png

+
获取推送权限(安卓)
获取推送权限(安卓)
+

状态:权限引导纯客户端;设备注册需改造。

+ +

10.19 提醒(安卓).png

+
提醒(安卓)
提醒(安卓)
+

状态:纯客户端。

+ +

10.20 相机说明(安卓).png

+
相机说明(安卓)
相机说明(安卓)
+

状态:纯客户端。

+ +

10.21 麦克风说明(安卓).png

+
麦克风说明(安卓)
麦克风说明(安卓)
+

状态:纯客户端。

+ +

11. 反诈安全、举报与意见反馈

+

11.1 反诈弹窗(安卓).png

+
反诈弹窗(安卓)
反诈弹窗(安卓)
+

状态:新增风控决策协议。

+ +

11.2 反诈弹窗(二次提示安卓).png

+
反诈弹窗(二次提示安卓)
反诈弹窗(二次提示安卓)
+

状态:新增。

+ +

11.3 举报.png

+
举报
举报
+

状态:新增。

+ +

11.4 举报(1).png

+

!举报(1).png)

+

状态:新增。

+ +

11.5 意见反馈.png

+
意见反馈
意见反馈
+

状态:新增。

+ +

12. 联调实施清单

+

12.1 当前可直接联调的基础能力

+
    +
  1. WebSocket /v0/channels、Wire Version 0.25 和最低版本 0.20。
  2. +
  3. 第一条 hi 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
  4. +
  5. basic/token 登录,sub/get/set/pub/leave/del/note 基础 Tinode 协议。
  6. +
  7. pub 成功返回 202 + seq,Topic 消息与基础回执同步。
  8. +
  9. /v0/file/u/ 和 /v0/file/s/{file_id} 的基础上传下载及租户存储隔离。
  10. +
+

12.2 必须优先完成的服务端阻塞项

+ + + + + + + + + + + + +
优先级改造项阻塞图片章节
P0/v1/im 统一认证中间件、响应封装、幂等键、租户 Principal全部 HTTP 产品能力
P0企业码解析、验证码、密码重置、二次认证、Token auth_version 和刷新/撤销1.1~1.11、10.7~10.8
P0普通用户禁止发布系统 Topic;系统事件可信生产通道2.5、3.1~3.5、6.4、11.1
P0消息 x-im-schema/type/client-mid 校验和服务端幂等2~4 章
P0文件资源级 ACL、Range、ETag、用途绑定和租户存储配额3.6、4.10~4.14、6.27~7.16、11.4~11.5
P1好友申请、好友关系、黑名单状态机第 3、5 章
P1群成员、角色、禁言、公告、邀请审批和配额第 6 章
P1收藏、转发和聊天记录搜索第 4、7 章
P1通话媒体状态、结果消息、Push 来电第 8 章
P2工作台、组织、会议、日报、月报、信箱第 9 章
P2隐私、青少年模式、反诈、举报、反馈第 10、11 章
+

12.3 前后端联调最低验收用例

+ + + + + + + + + + + + + +
编号场景预期结果
T-01A 企业 Token 连接 B 企业 Session登录失败 TENANT_MISMATCH,不产生用户/Topic 数据
T-02企业停用后使用旧 Token 登录和请求 HTTPWS/HTTP 均拒绝,客户端清除凭证并回企业入口
T-03同 client_mid 断线重发同一消息只落一条消息并返回同一 seq
T-04相同 HTTP 幂等键、相同请求体重复提交返回第一次结果;不同请求体返回 IDEMPOTENCY_CONFLICT
T-05被拉黑或删除好友后绕过按钮直接 pub服务端拒绝,对方不收到消息
T-06普通用户构造 system_event 或向系统 Topic 发布服务端拒绝,系统消息页不展示可信样式
T-07普通群成员调用管理员、禁言、转让群主接口返回 FORBIDDEN,群状态不变且写安全审计
T-08群成员数已达租户配置上限时同意两个并发申请至多一个成功,成员数不超限
T-09非消息接收者持同租户 Token 下载文件返回 FILE_FORBIDDEN,不能只通过 file_id 下载
T-10文件下载中 Token 过期后刷新并 Range 续传文件完整、checksum 正确、不重复下载已完成分片
T-11风控二次确认后重复重放 risk token只有首次有效,其余返回 Token 已消费
T-12切换企业后查看缓存、上传任务、收藏和搜索不出现上一租户数据或任务
+

12.4 统一错误 reason

+

首版客户端至少实现:TENANT_UNAVAILABLE、TENANT_MISMATCH、AUTH_FAILED、TOKEN_EXPIRED、ACCOUNT_DISABLED、RATE_LIMITED、VALIDATION_FAILED、IDEMPOTENCY_CONFLICT、RESOURCE_NOT_FOUND、FORBIDDEN、FRIEND_RELATION_REQUIRED、MESSAGE_NOT_ALLOWED、GROUP_MUTED、GROUP_MEMBER_LIMIT_EXCEEDED、TENANT_USER_QUOTA_EXCEEDED、TENANT_GROUP_QUOTA_EXCEEDED、FILE_TOO_LARGE、FILE_FORBIDDEN、FILE_UNAVAILABLE、STORAGE_QUOTA_EXCEEDED、REALNAME_REQUIRED、YOUTH_MODE_RESTRICTED、RISK_CONFIRMATION_REQUIRED、RISK_ACTION_BLOCKED。

+

未知 reason 使用通用失败文案并记录 request_id,不得把服务端 message 原样展示给用户。

+

12.5 完成定义

+

每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。

+ \ No newline at end of file diff --git a/docs/IM-前后端对接文档-禅道发布版.md b/docs/IM-前后端对接文档-禅道发布版.md new file mode 100644 index 0000000..35c9b2e --- /dev/null +++ b/docs/IM-前后端对接文档-禅道发布版.md @@ -0,0 +1,1813 @@ +# IM 前后端逐图对接协议 + +> 版本:v2.0 +> 日期:2026-08-11 +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) +> 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 + +## 0. 公共对接约定 + +### 0.1 实现状态 + +| 状态 | 含义 | +| --- | --- | +| `已实现` | 当前服务端已有能力,可按本文联调 | +| `需改造` | 已有基础能力,但字段、鉴权或业务规则需要调整 | +| `新增` | 当前服务端没有该产品接口或数据模型 | +| `纯客户端` | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 | + +文档中所有 `新增`、`需改造` 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。 + +### 0.2 API Key、启动入口与通道 + +#### 0.2.1 现有系统的 API Key 从哪里来 + +**API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。** + +API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置: + +1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 +2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 + +生成示意: + +```text +$ go run ./keygen +API key v1 seq1 [ordinary]: <client-api-key> +HMAC salt: <server-hmac-salt> +``` + +API Key、企业码和用户 Token 是三个不同概念: + +| 值 | 取得时间 | 作用 | 是否代表用户身份 | +| --- | --- | --- | --- | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | +| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | +| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | + +API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 `checkAPIKey` 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。 + +当前 `checkAPIKey` 没有按 `sequence` 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 `api_key_salt` 会让所有旧 Key 同时失效,必须与客户端升级协调。 + +#### 0.2.2 客户端怎样携带 API Key + +| 通道 | 地址 | 认证 | 用途 | +| --- | --- | --- | --- | +| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | +| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | +| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | + +HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 + +WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: + +```json +{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}} +``` + +客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。 + +两种连接方式的第一个请求对照如下: + +| 连接方式 | 发起第一个网络请求前已有的值 | 第一个网络请求 | 第一个响应得到的值 | 下一步 | +| --- | --- | --- | --- | --- | +| WebSocket(推荐) | `host + api_key + tenant_code` | 带 API Key Upgrade `/v0/channels` | HTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid | 发送 `{hi tenant}` | +| Long Polling(兼容) | `host + api_key + tenant_code` | `POST /v0/channels/lp` | HTTP 201 和 `ctrl.params.sid` | 带 `sid` POST `{hi}`,再 GET 等待响应 | + +现有实现对应关系: + +| 源码 | 作用 | +| --- | --- | +| `keygen/keygen.go` | 生成普通/Root API Key 和服务端 HMAC Salt | +| `server/api_key.go` | `checkAPIKey` 校验 API Key 格式与签名 | +| `server/http.go` | 按 Header、Query、Form、Cookie 顺序读取 API Key | +| `server/hdl_websock.go` | WebSocket Upgrade 前校验 API Key,无效直接 403 | +| `server/hdl_longpoll.go` | 创建 Long Polling Session 并返回 `sid` | +| `server/session.go` | 处理 `{hi}` 租户绑定、`{login}` 和登录 Token 返回 | + +现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 `{hi}.tenant` 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。 + +### 0.3 多租户强制规则 + +1. 未登录阶段只传公开的 `tenant_code`;客户端永远不接收、不提交数据库 `tenant_id`。 +2. WebSocket 第一条消息必须是 `{hi}`,其中 `tenant` 为企业码。服务端解析后把租户固定到 Session。 +3. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 `TENANT_MISMATCH`,不得自动切换。 +4. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。 +5. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 `tenant_id`、`user_id` 不能覆盖身份。 +6. 跨租户对象统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得暴露对象是否存在。 + +### 0.4 HTTP 请求与响应 + +成功响应统一为: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +失败响应统一为: + +```json +{ + "code": 30001, + "reason": "GROUP_MUTED", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 + +### 0.5 WebSocket 建连、登录和恢复顺序 + +#### 0.5.1 现有服务端可直接执行的冷启动流程 + +```text +读取客户端预置的 host 和普通 API Key +-> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 +-> 带 API Key 完成 WebSocket Upgrade +-> hi(tenant_code) +-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 +-> 有历史 Token 时 token login,否则 basic login/acc +-> 收到 login 的 200 ctrl,保存 user/token/expires +-> sub(me) 拉取本人资料和会话订阅 +-> sub(fnd) 建立搜索 Topic +-> 订阅需要在线的会话并按 seq 增量拉取 +-> 进入可发送状态 +``` + +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 + +`hi` 请求: + +```json +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}} +``` + +`login` 请求使用 `basic` 或 `token`: + +```json +{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}} +``` + +```json +{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} +``` + +`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +`hi` 成功示例: + +```json +{ + "ctrl": { + "id": "hi-1", + "code": 201, + "text": "created", + "params": { + "ver": "0.25", + "tenant": {"code": "acme", "name": "示例企业"}, + "maxFileUploadSize": 104857600, + "callTimeout": 30, + "iceServers": [] + } + } +} +``` + +客户端只使用实际响应中存在的限制字段;未知字段忽略。`hi` 返回非 2xx 时必须关闭连接,不得继续发送 `login`。 + +登录成功后发送: + +```json +{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}} +``` + +客户端按相同 `id` 接收 `{ctrl}` 和一个或多个 `{meta}`,从 `meta.desc` 取得本人资料和读写游标,从 `meta.sub` 取得会话 Topic、访问权限、`seq/read/recv` 与会话摘要。不要依赖 `{ctrl}`、`{meta}` 的固定先后顺序。 + +随后建立查找 Topic: + +```json +{"sub":{"id":"sub-fnd-1","topic":"fnd"}} +``` + +进入具体会话时订阅并增量拉取,`since` 使用客户端已完整持久化的最后 `seq + 1`: + +```json +{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} +``` + +订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 + +#### 0.5.2 Long Polling 兼容流程:先取得的是 sid + +如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 + +第一步,创建 Long Polling Session: + +```http +POST /v0/channels/lp HTTP/1.1 +Host: im.example.com +X-Tinode-APIKey: <预置的普通 API Key> +Content-Length: 0 +``` + +服务端返回 HTTP 201: + +```json +{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}} +``` + +第二步开始,所有 Long Polling 请求都同时携带 API Key 和 `sid`。发送 `hi`: + +```http +POST /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +Content-Type: application/json + +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} +``` + +POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 `{ctrl}`、`{meta}`、`{data}`、`{pres}` 或 `{info}`: + +```http +GET /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +``` + +收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 + +`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 + +#### 0.5.3 企业解析与产品流程 + +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: + +```text +读取预置 API Key +-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置 +-> 带同一 API Key 建立返回地址的 WebSocket +-> hi 再次绑定并校验 tenant_code +-> login -> sub me -> 恢复会话 +``` + +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +### 0.6 Topic 与产品消息 + +核心命令:`sub` 订阅、`pub` 发送、`get` 拉取、`set` 更新 Topic、`leave` 离开、`del` 删除、`note` 发送回执/临时状态。发送成功为 `{ctrl code:202, params:{seq}}`,服务端下行消息为 `{data}`。 + +所有产品消息在 `pub.head` 中携带: + +```json +{ + "pub": { + "id": "pub-1", + "topic": "p2p...", + "noecho": false, + "head": { + "x-im-schema": 1, + "x-im-type": "text", + "x-im-client-mid": "01K...", + "mentions": [] + }, + "content": {"text":"你好"} + } +} +``` + +`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 + +消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 + +### 0.7 HTTP 幂等、重试和安全 + +1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 +2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 +3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 +5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 + +## 1. 账号注册、登录与身份安全 + +### 1.1 企业码登录.png + +> 图片:企业码登录(images/企业码登录.png) +**状态:企业解析接口与 WebSocket 租户握手均已实现。** + +**当前系统的执行方式:** + +- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 +- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 +- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 + +**企业解析接口:** + +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 +- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 +- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 + +成功响应示例: + +```json +{ + "code": 0, + "data": { + "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, + "endpoints": { + "websocket": "wss://im.example.com/v0/channels", + "api": "https://im.example.com/v1/im", + "file": "https://im.example.com/v0/file" + }, + "auth_methods": ["password", "sms"], + "password_policy": {"min_length": 8, "max_length": 64}, + "app_update": {"type": "none"} + }, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +### 1.2 登录页面.png + +> 图片:登录页面(images/登录页面.png) +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** + +- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。 +- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 + +登录成功示例: + +```json +{ + "ctrl": { + "id": "login-1", + "code": 200, + "text": "ok", + "params": { + "user": "usr...", + "authlvl": "auth", + "token": "base64-opaque-token", + "expires": "2026-08-11T10:30:15.123Z" + } + } +} +``` + +失败仍是同一 `{ctrl}` 结构,使用 `code` 和改造后的稳定 `params.reason` 分支;客户端不得用 `text` 做程序判断。 + +### 1.3 登录页面-密码错误.png + +> 图片:登录页面-密码错误(images/登录页面-密码错误.png) +**状态:已实现基础错误,错误码和限流需改造。** + +- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 +- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 +- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 +- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 + +### 1.4 注册页面.png + +> 图片:注册页面(images/注册页面.png) +**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。** + +- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 +- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 +- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 +- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +``` +- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +### 1.5 注册页面-倒计时.png + +> 图片:注册页面-倒计时(images/注册页面-倒计时.png) +**状态:新增。** + +- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 +- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 +- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 + +### 1.6 忘记密码.png + +> 图片:忘记密码(images/忘记密码.png) +**状态:新增。** + +- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 +- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 +- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 +- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 + +### 1.7 身份验证(安卓).png + +> 图片:身份验证(安卓)(images/身份验证(安卓).png) +**状态:新增。** + +- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。 +- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 +- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 +- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 + +### 1.8 实名认证.png + +> 图片:实名认证(images/实名认证.png) +**状态:新增。** + +- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 +- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 +- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 + +### 1.9 实名认证-已实名.png + +> 图片:实名认证-已实名(images/实名认证-已实名.png) +**状态:新增。** + +- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 +- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 + +### 1.10 实名认证(安卓).png + +> 图片:实名认证(安卓)(images/实名认证(安卓).png) +**状态:新增。** + +- 登录后 `GET /v1/im/bootstrap` 返回 `security.realname{status,required,allow_skip,remind_after}`;只有 `required=true` 且未认证时显示弹窗。 +- 点击“去认证”进入 1.8;点击稍后仅在 `allow_skip=true` 时本地关闭,并按 `remind_after` 控制再次提示。 +- 服务端对必须实名的受限写操作仍返回 `REALNAME_REQUIRED`,前端弹窗不能作为授权依据。 + +### 1.11 设置独立密码(安卓).png + +> 图片:设置独立密码(安卓)(images/设置独立密码(安卓).png) +**状态:新增。** + +- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。 +- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 +- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 +- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 + +## 2. 消息首页、全局导航与系统通知 + +### 2.1 消息.png + +> 图片:消息(images/消息.png) +**状态:会话订阅已实现;产品摘要和系统入口需改造。** + +- 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 +- 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 + +### 2.2 消息-无网络状态.png + +> 图片:消息-无网络状态(images/消息-无网络状态.png) +**状态:需改造客户端恢复流程和消息幂等。** + +- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 +- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 +- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 +- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 + +### 2.3 消息-更多.png + +> 图片:消息-更多(images/消息-更多.png) +**状态:菜单为纯客户端;权限数据新增。** + +- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 +- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 +- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 + +### 2.4 消息页面—长按弹出.png + +> 图片:消息页面—长按弹出(images/消息页面—长按弹出.png) +**状态:基础消息操作部分已实现;产品权限矩阵需改造。** + +- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 +- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 + +### 2.5 系统消息.png + +> 图片:系统消息(images/系统消息.png) +**状态:需改造。** + +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 +- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 +- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 + +## 3. 单聊会话、会话建立与消息状态 + +### 3.1 个人对话框(对方的状态).png + +> 图片:个人对话框(对方的状态)(images/个人对话框(对方的状态).png) +**状态:需改造好友关系状态。** + +- 进入会话先 `GET /v1/im/conversations/{topic}/context`,返回 `relationship=pending_inbound`、`can_send=false`、对方公开资料和申请 ID。 +- 历史申请提示由 `relationship_event` 系统消息渲染;普通消息仍通过 Topic `{data}` 下行。 +- 输入区锁定;“通过/拒绝”调用 5.2 的好友申请接口。前端不可通过直接 `pub` 绕过,服务端发布路径必须校验好友关系。 + +### 3.2 个人对话框(对方的状态)(1).png + +> 图片:个人对话框(对方的状态)(1)(images/个人对话框(对方的状态)(1).png) +**状态:在线状态已具备基础能力;关系文案新增。** + +- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 +- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 +- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 + +### 3.3 个人对话框(我的状态).png + +> 图片:个人对话框(我的状态)(images/个人对话框(我的状态).png) +**状态:新增好友申请状态。** + +- 发起申请使用 `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"我是...","source":"search"}`。 +- 返回 `request_id,status:"pending",created_at`;页面以该服务端状态显示“已发送”,重复点击用同一幂等键返回原申请。 +- 申请待处理时 `context.relationship=pending_outbound`、`can_send=false`,禁止重复创建和普通消息发送。 + +### 3.4 个人对话框(我的状态)(1).png + +> 图片:个人对话框(我的状态)(1)(images/个人对话框(我的状态)(1).png) +**状态:发送和读回执已实现基础能力;送达状态映射需统一。** + +- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 +- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 +- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 + +### 3.5 开启沟通页面.png + +> 图片:开启沟通页面(images/开启沟通页面.png) +**状态:新增好友审批与 Topic 联动。** + +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 +- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 + +### 3.6 双方沟通状态.png + +> 图片:双方沟通状态(images/双方沟通状态.png) +**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** + +- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 +- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 +- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 + +### 3.7 个人对话—拉黑状态/已读状态.png + +> 图片:个人对话—拉黑状态/已读状态(images/个人对话—拉黑状态/已读状态.png) +**状态:黑名单产品规则新增。** + +- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 +- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 +- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 + +### 3.8 个人对话—删除状态.png + +> 图片:个人对话—删除状态(images/个人对话—删除状态.png) +**状态:新增好友删除后的发送约束。** + +- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 +- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 +- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 + +### 3.9 个人对话-键盘收起状态.png + +> 图片:个人对话-键盘收起状态(images/个人对话-键盘收起状态.png) +**状态:纯客户端。** + +- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 +- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 + +### 3.10 个人对话-发各种文件状态.png + +> 图片:个人对话-发各种文件状态(images/个人对话-发各种文件状态.png) +**状态:面板纯客户端;上传需改造。** + +- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 +- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 +- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 + +### 3.11 个人对话-发各种文件状态(1).png + +> 图片:个人对话-发各种文件状态(1)(images/个人对话-发各种文件状态(1).png) +**状态:需改造产品消息 Schema。** + +- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 +- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 +- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 + +### 3.12 个人对话-发送名片状态.png + +> 图片:个人对话-发送名片状态(images/个人对话-发送名片状态.png) +**状态:新增产品消息类型。** + +- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 +- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 +- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 + +## 4. 消息输入、媒体内容与消息操作 + +### 4.1 键盘展开.png + +> 图片:键盘展开(images/键盘展开.png) +**状态:纯客户端。** + +- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 +- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 + +### 4.2 @人员—选择提醒人.png + +> 图片:@人员—选择提醒人(images/@人员—选择提醒人.png) +**状态:新增群成员查询。** + +- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。 +- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。 +- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。 + +### 4.3 @人员—完成.png + +> 图片:@人员—完成(images/@人员—完成.png) +**状态:纯客户端选择;发送协议需改造。** + +- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。 +- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。 +- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。 + +### 4.4 @人员状态.png + +> 图片:@人员状态(images/@人员状态.png) +**状态:需改造提醒和 Push。** + +- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 +- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 +- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 + +### 4.5 表情包展开(发送按钮默认状态).png + +> 图片:表情包展开(发送按钮默认状态)(images/表情包展开(发送按钮默认状态).png) +**状态:纯客户端。** + +- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 +- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 + +### 4.6 表情包发送页面.png + +> 图片:表情包发送页面(images/表情包发送页面.png) +**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** + +- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 +- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 + +### 4.7 语音—按住说话.png + +> 图片:语音—按住说话(images/语音—按住说话.png) +**状态:录音纯客户端;上传和语音消息需改造。** + +- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 +- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 +- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 + +### 4.8 语音按住说话.png + +> 图片:语音按住说话(images/语音按住说话.png) +**状态:纯客户端。** + +- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 +- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 + +### 4.9 内容沟通对话3-语音录音中.png + +> 图片:内容沟通对话3-语音录音中(images/内容沟通对话3-语音录音中.png) +**状态:录音纯客户端;临时状态可选改造。** + +- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 +- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 + +### 4.10 内容沟通对话3-图片和语音发送中.png + +> 图片:内容沟通对话3-图片和语音发送中(images/内容沟通对话3-图片和语音发送中.png) +**状态:客户端任务管理新增;服务端上传需改造。** + +- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 +- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 +- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 + +### 4.11 内容沟通对话3-语音/视频/图片状态.png + +> 图片:内容沟通对话3-语音/视频/图片状态(images/内容沟通对话3-语音/视频/图片状态.png) +**状态:需改造媒体 Schema 和受权下载。** + +- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 +- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 +- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 + +### 4.12 语音图片发送状态.png + +> 图片:语音图片发送状态(images/语音图片发送状态.png) +**状态:需改造。** + +- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 +- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 + +### 4.13 上传文件.png + +> 图片:上传文件(images/上传文件.png) +**状态:纯客户端视觉资源。** + +- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 +- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 + +### 4.14 文件发送状态.png + +> 图片:文件发送状态(images/文件发送状态.png) +**状态:上传已实现基础能力;消息引用和 ACL 需改造。** + +- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 +- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 +- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 + +### 4.15 多选-选中对话框.png + +> 图片:多选-选中对话框(images/多选-选中对话框.png) +**状态:选择纯客户端;批量动作新增。** + +- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 +- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 +- 不允许只因第一条可操作就假定整批可操作。 + +### 4.16 多选-选中对话内容-收藏成功.png + +> 图片:多选-选中对话内容-收藏成功(images/多选-选中对话内容-收藏成功.png) +**状态:新增。** + +- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 +- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 +- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 + +### 4.17 转发-单选.png + +> 图片:转发-单选(images/转发-单选.png) +**状态:新增。** + +- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 +- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 + +### 4.18 转发-多选.png + +> 图片:转发-多选(images/转发-多选.png) +**状态:新增。** + +- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 +- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 + +### 4.19 转发—逐条转发.png + +> 图片:转发—逐条转发(images/转发—逐条转发.png) +**状态:新增。** + +- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。 +- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 +- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 + +### 4.20 转发—合并转发—多选状态.png + +> 图片:转发—合并转发—多选状态(images/转发—合并转发—多选状态.png) +**状态:新增。** + +- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 获取。 +- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 +- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 + +## 5. 通讯录、好友关系与黑名单 + +### 5.1 通讯录.png + +> 图片:通讯录(images/通讯录.png) +**状态:新增产品通讯录接口。** + +- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 +- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 +- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 + +### 5.2 新朋友.png + +> 图片:新朋友(images/新朋友.png) +**状态:新增。** + +- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 +- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 +- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 + +### 5.3 添加好友.png + +> 图片:添加好友(images/添加好友.png) +**状态:新增。** + +- 账号搜索:`GET /v1/im/users/search?q=&type=account&cursor=`;扫码后调用 `POST /v1/im/qrcodes/resolve`,Body `{"token":"...","purpose":"add_friend"}`。 +- 结果统一返回 `relationship=none|friend|pending_inbound|pending_outbound` 和 `can_add`;隐私不允许或未找到统一为 `USER_NOT_DISCOVERABLE`。 +- 分享/二维码见 5.4;发申请见 5.6。服务端按租户配置 `allow_user_search` 和用户隐私再次拦截。 + +### 5.4 添加好友(1).png + +> 图片:添加好友(1)(images/添加好友(1).png) +**状态:新增。** + +- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 + +### 5.5 添加好友-输入精准用户ID.png + +> 图片:添加好友-输入精准用户ID(images/添加好友-输入精准用户ID.png) +**状态:新增。** + +- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 +- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 +- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 + +### 5.6 添加好友验证.png + +> 图片:添加好友验证(images/添加好友验证.png) +**状态:新增。** + +- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。 +- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。 +- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。 + +### 5.7 好友个人主页.png + +> 图片:好友个人主页(images/好友个人主页.png) +**状态:新增。** + +- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 +- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 + +### 5.8 群聊—非好友个人主页.png + +> 图片:群聊—非好友个人主页(images/群聊—非好友个人主页.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。 +- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 +- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 + +### 5.9 好友—聊天设置—未拉黑状态.png + +> 图片:好友—聊天设置—未拉黑状态(images/好友—聊天设置—未拉黑状态.png) +**状态:部分 Topic 设置已实现;产品设置新增。** + +- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。 +- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。 +- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。 + +### 5.10 好友—聊天设置—拉黑状态.png + +> 图片:好友—聊天设置—拉黑状态(images/好友—聊天设置—拉黑状态.png) +**状态:新增。** + +- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 + +### 5.11 删除好友提示.png + +> 图片:删除好友提示(images/删除好友提示.png) +**状态:确认框纯客户端;删除接口新增。** + +- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 +- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 +- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 + +### 5.12 黑名单.png + +> 图片:黑名单(images/黑名单.png) +**状态:新增。** + +- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。 +- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。 +- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。 + +### 5.13 黑名单提示(1).png + +> 图片:黑名单提示(1)(images/黑名单提示(1).png) +**状态:确认框纯客户端;写接口新增。** + +- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 +- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 +- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 + +### 5.14 黑名单提示.png + +> 图片:黑名单提示(images/黑名单提示.png) +**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** + +- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 +- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 + +## 6. 群聊创建、资料维护与成员治理 + +### 6.1 群聊.png + +> 图片:群聊(images/群聊.png) +**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** + +- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 + +### 6.2 空白页.png + +> 图片:空白页(images/空白页.png) +**状态:纯客户端。** + +- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 +- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 + +### 6.3 加入群聊.png + +> 图片:加入群聊(images/加入群聊.png) +**状态:新增邀请卡片和加入接口。** + +- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 +- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 + +### 6.4 内容沟通对话3-开启进群验证与拉人状态.png + +> 图片:内容沟通对话3-开启进群验证与拉人状态(images/内容沟通对话3-开启进群验证与拉人状态.png) +**状态:新增群成员事件。** + +- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 +- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 +- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 + +### 6.5 邀请进群申请.png + +> 图片:邀请进群申请(images/邀请进群申请.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 +- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 +- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 + +### 6.6 新邀请进群申请.png + +> 图片:新邀请进群申请(images/新邀请进群申请.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 +- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 + +### 6.7 群公告状态.png + +> 图片:群公告状态(images/群公告状态.png) +**状态:新增。** + +- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 +- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 +- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 + +### 6.8 群公告状态/邀请进群申请同时存在.png + +> 图片:群公告状态/邀请进群申请同时存在(images/群公告状态/邀请进群申请同时存在.png) +**状态:纯客户端组合;数据接口新增。** + +- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 +- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 + +### 6.9 群公告(对方查看状态).png + +> 图片:群公告(对方查看状态)(images/群公告(对方查看状态).png) +**状态:新增。** + +- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 +- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 +- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 + +### 6.10 群聊设置.png + +> 图片:群聊设置(images/群聊设置.png) +**状态:部分订阅设置已实现;产品菜单新增。** + +- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 +- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 +- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 + +### 6.11 群资料.png + +> 图片:群资料(images/群资料.png) +**状态:需改造。** + +- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。 +- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。 + +### 6.12 设置群头像.png + +> 图片:设置群头像(images/设置群头像.png) +**状态:上传基础已实现;用途绑定需改造。** + +- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 +- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 +- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 + +### 6.13 设置昵称(1).png + +> 图片:设置昵称(1)(images/设置昵称(1).png) +**状态:新增。** + +- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 +- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 + +### 6.14 群简介.png + +> 图片:群简介(images/群简介.png) +**状态:同 6.11,需改造。** + +- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 +- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 + +### 6.15 群标识.png + +> 图片:群标识(images/群标识.png) +**状态:角色已有基础数据;产品渲染需统一。** + +- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 +- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 +- 客户端不得根据本地管理员列表给消息自行加可信标识。 + +### 6.16 群二维码页面.png + +> 图片:群二维码页面(images/群二维码页面.png) +**状态:新增。** + +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 +- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 + +### 6.17 群组管理.png + +> 图片:群组管理(images/群组管理.png) +**状态:新增。** + +- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 +- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 +- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 + +### 6.18 设置群管理员.png + +> 图片:设置群管理员(images/设置群管理员.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。 +- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。 + +### 6.19 添加管理员.png + +> 图片:添加管理员(images/添加管理员.png) +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。 +- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。 +- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。 + +### 6.20 添加管理员-设置成功.png + +> 图片:添加管理员-设置成功(images/添加管理员-设置成功.png) +**状态:新增。** + +- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 +- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 + +### 6.21 添加管理员-设置成功(1).png + +> 图片:添加管理员-设置成功(1)(images/添加管理员-设置成功(1).png) +**状态:新增。** + +- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 +- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 + +### 6.22 删除管理员-设置成功.png + +> 图片:删除管理员-设置成功(images/删除管理员-设置成功.png) +**状态:新增。** + +- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 +- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 +- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 + +### 6.23 添加禁言成员.png + +> 图片:添加禁言成员(images/添加禁言成员.png) +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。 +- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。 +- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。 + +### 6.24 禁言的群成员.png + +> 图片:禁言的群成员(images/禁言的群成员.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。 +- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。 +- 退群时清理禁言记录;过期由服务端自动失效并推送更新。 + +### 6.25 选择新群主.png + +> 图片:选择新群主(images/选择新群主.png) +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 +- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 +- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 + +### 6.26 选择新群主提示.png + +> 图片:选择新群主提示(images/选择新群主提示.png) +**状态:新增。** + +- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 +- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 + +### 6.27 群文件.png + +> 图片:群文件(images/群文件.png) +**状态:新增群文件索引;底层文件存储已实现。** + +- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。 +- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。 +- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。 + +### 6.28 群文件-上传状态.png + +> 图片:群文件-上传状态(images/群文件-上传状态.png) +**状态:需改造。** + +- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 +- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 + +## 7. 文件下载、收藏管理与聊天记录检索 + +### 7.1 我的收藏.png + +> 图片:我的收藏(images/我的收藏.png) +**状态:新增。** + +- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。 +- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。 +- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。 + +### 7.2 我的收藏(1).png + +> 图片:我的收藏(1)(images/我的收藏(1).png) +**状态:新增。** + +- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 +- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 +- 删除收藏不删除原消息或已转发消息。 + +### 7.3 我的收藏-文件.png + +> 图片:我的收藏-文件(images/我的收藏-文件.png) +**状态:新增。** + +- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 +- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 + +### 7.4 我的收藏-文本.png + +> 图片:我的收藏-文本(images/我的收藏-文本.png) +**状态:新增。** + +- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 +- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 + +### 7.5 我的收藏-语音.png + +> 图片:我的收藏-语音(images/我的收藏-语音.png) +**状态:新增。** + +- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 +- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 + +### 7.6 我的收藏—链接.png + +> 图片:我的收藏—链接(images/我的收藏—链接.png) +**状态:新增。** + +- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。 +- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。 +- 客户端不得在未确认时自动打开或预加载高风险外链。 + +### 7.7 收藏—文件(多选).png + +> 图片:收藏—文件(多选)(images/收藏—文件(多选).png) +**状态:新增。** + +- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 +- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 + +### 7.8 收藏文件—分享给好友页面.png + +> 图片:收藏文件—分享给好友页面(images/收藏文件—分享给好友页面.png) +**状态:新增。** + +- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 +- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 +- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 + +### 7.9 文件-下载.png + +> 图片:文件-下载(images/文件-下载.png) +**状态:底层下载已实现租户校验;资源 ACL 需改造。** + +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 +- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 +- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 + +### 7.10 文件-下载中.png + +> 图片:文件-下载中(images/文件-下载中.png) +**状态:客户端任务管理;服务端需支持 Range。** + +- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。 +- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 +- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 + +### 7.11 文件-下载完成.png + +> 图片:文件-下载完成(images/文件-下载完成.png) +**状态:纯客户端完成态。** + +- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 +- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 + +### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png + +> 图片:查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)(images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png) +**状态:新增服务端搜索。** + +- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 +- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 +- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 + +### 7.13 查找聊天记录-文件.png + +> 图片:查找聊天记录-文件(images/查找聊天记录-文件.png) +**状态:新增。** + +- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 +- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 + +### 7.14 查找聊天记录-链接.png + +> 图片:查找聊天记录-链接(images/查找聊天记录-链接.png) +**状态:新增。** + +- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 +- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 + +### 7.15 查看聊天记录-图片.png + +> 图片:查看聊天记录-图片(images/查看聊天记录-图片.png) +**状态:新增。** + +- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。 +- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。 + +### 7.16 查看聊天记录-视频.png + +> 图片:查看聊天记录-视频(images/查看聊天记录-视频.png) +**状态:新增。** + +- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 +- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 + +## 8. 实时语音与视频通话 + +### 8.1 各种通话提示状态.png + +> 图片:各种通话提示状态(images/各种通话提示状态.png) +**状态:已实现基础通话记录替换;产品文案映射需统一。** + +- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。 +- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。 +- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。 + +### 8.2 对方状态.png + +> 图片:对方状态(images/对方状态.png) +**状态:在线邀请已实现;Push 与产品鉴权需改造。** + +- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 +- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 +- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 + +### 8.3 语音通话-对方待接受状态.png + +> 图片:语音通话-对方待接受状态(images/语音通话-对方待接受状态.png) +**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** + +- 发起前查询 `capabilities.can_call`,再发布持久邀请消息: + +```json +{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}} +``` + +- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。 +- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。 + +### 8.4 语音通话-双方语音沟通中.png + +> 图片:语音通话-双方语音沟通中(images/语音通话-双方语音沟通中.png) +**状态:WebRTC 客户端能力;信令和服务端计时已实现。** + +- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。 +- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。 +- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。 + +### 8.5 语音通话-麦克风与扬声器已关.png + +> 图片:语音通话-麦克风与扬声器已关(images/语音通话-麦克风与扬声器已关.png) +**状态:纯客户端设备控制。** + +- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 +- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 + +### 8.6 语音通话-麦克风与扬声器已开.png + +> 图片:语音通话-麦克风与扬声器已开(images/语音通话-麦克风与扬声器已开.png) +**状态:纯客户端设备控制。** + +- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 +- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 + +### 8.7 视频通话—等待对方接受接受邀请中.png + +> 图片:视频通话—等待对方接受接受邀请中(images/视频通话—等待对方接受接受邀请中.png) +**状态:基础通话信令已实现;隐私约束需落实。** + +- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 +- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 + +### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png + +> 图片:视频通话—等待对方接受接受邀请-摄像头关闭(images/视频通话—等待对方接受接受邀请-摄像头关闭.png) +**状态:纯客户端状态;媒体状态事件需改造。** + +- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 +- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 + +### 8.9 双方视频通话中.png + +> 图片:双方视频通话中(images/双方视频通话中.png) +**状态:WebRTC 客户端能力;服务端信令已具备基础。** + +- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 +- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 +- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 + +### 8.10 双方视频通话中-对方关闭摄像头状态.png + +> 图片:双方视频通话中-对方关闭摄像头状态(images/双方视频通话中-对方关闭摄像头状态.png) +**状态:现阶段纯客户端;可选媒体状态事件需改造。** + +- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 +- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 + +### 8.11 视频与语音通话悬浮状态中.png + +> 图片:视频与语音通话悬浮状态中(images/视频与语音通话悬浮状态中.png) +**状态:纯客户端。** + +- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 +- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 + +## 9. 企业工作台与办公协同 + +### 9.1 工作台.png + +> 图片:工作台(images/工作台.png) +**状态:新增。** + +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 +- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 + +### 9.2 部门组织框架.png + +> 图片:部门组织框架(images/部门组织框架.png) +**状态:新增。** + +- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 +- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 +- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 + +### 9.3 会议通知-发起人状态.png + +> 图片:会议通知-发起人状态(images/会议通知-发起人状态.png) +**状态:新增。** + +- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 +- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 +- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 + +### 9.4 会议通知-接收人状态.png + +> 图片:会议通知-接收人状态(images/会议通知-接收人状态.png) +**状态:新增。** + +- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 +- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 +- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 + +### 9.5 日报.png + +> 图片:日报(images/日报.png) +**状态:新增。** + +- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。 +- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。 +- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。 + +### 9.6 日报-普通用户页面.png + +> 图片:日报-普通用户页面(images/日报-普通用户页面.png) +**状态:新增。** + +- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。 +- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。 +- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。 + +### 9.7 日报-管理员页面.png + +> 图片:日报-管理员页面(images/日报-管理员页面.png) +**状态:新增。** + +- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。 +- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。 +- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。 + +### 9.8 日报-查看详情.png + +> 图片:日报-查看详情(images/日报-查看详情.png) +**状态:新增。** + +- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 +- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 +- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 + +### 9.9 月报(1).png + +> 图片:月报(1)(images/月报(1).png) +**状态:新增。** + +- 创建/取得当月草稿:`POST /v1/im/reports/monthly`,Body `{"month":"2026-08"}`。 +- 保存:`PUT /v1/im/reports/monthly/{id}/draft`,Body `{"work_summary":"...","next_plan":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/monthly/{id}/submit`。 +- 字段必填、最大长度、提交截止时间由响应 `validation` 提供;自动保存和冲突处理同 9.6。 + +### 9.10 月报.png + +> 图片:月报(images/月报.png) +**状态:新增。** + +- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。 +- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。 +- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。 + +### 9.11 月报-详情页.png + +> 图片:月报-详情页(images/月报-详情页.png) +**状态:新增。** + +- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。 +- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。 + +### 9.12 总经理信箱.png + +> 图片:总经理信箱(images/总经理信箱.png) +**状态:新增。** + +- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 +- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 +- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 + +### 9.13 总经理信箱-文案提示.png + +> 图片:总经理信箱-文案提示(images/总经理信箱-文案提示.png) +**状态:新增配置接口。** + +- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 +- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 + +## 10. 个人资料、通用设置、隐私与青少年模式 + +### 10.1 我的.png + +> 图片:我的(images/我的.png) +**状态:基础用户资料已有;产品菜单新增。** + +- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 +- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 +- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 + +### 10.2 个人信息.png + +> 图片:个人信息(images/个人信息.png) +**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** + +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 + +### 10.3 设置昵称.png + +> 图片:设置昵称(images/设置昵称.png) +**状态:需改造。** + +- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 +- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 +- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 + +### 10.4 出生日期.png + +> 图片:出生日期(images/出生日期.png) +**状态:新增产品字段。** + +- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 +- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 + +### 10.5 个性签名.png + +> 图片:个性签名(images/个性签名.png) +**状态:需改造。** + +- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 +- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 + +### 10.6 二维码.png + +> 图片:二维码(images/二维码.png) +**状态:图标纯客户端;二维码协议新增。** + +- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 +- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 + +### 10.7 设置(iOS).png + +> 图片:设置(iOS)(images/设置(iOS).png) +**状态:菜单纯客户端;数据接口部分新增。** + +- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 +- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 + +### 10.8 设置(安卓).png + +> 图片:设置(安卓)(images/设置(安卓).png) +**状态:菜单纯客户端;业务协议同 10.7。** + +- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 +- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 + +### 10.9 清空缓存的提示.png + +> 图片:清空缓存的提示(images/清空缓存的提示.png) +**状态:纯客户端。** + +- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 +- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 + +### 10.10 版本更新页面.png + +> 图片:版本更新页面(images/版本更新页面.png) +**状态:新增 App 配置接口。** + +- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 +- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 + +### 10.11 发现新版本.png + +> 图片:发现新版本(images/发现新版本.png) +**状态:纯客户端弹窗;数据来自 10.10。** + +- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 +- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 + +### 10.12 隐私设置.png + +> 图片:隐私设置(images/隐私设置.png) +**状态:新增。** + +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 +- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 +- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 + +### 10.13 隐私设置-添加我的方式选项.png + +> 图片:隐私设置-添加我的方式选项(images/隐私设置-添加我的方式选项.png) +**状态:新增。** + +- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。 +- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。 +- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。 + +### 10.14 青少年模式未开启(安卓).png + +> 图片:青少年模式未开启(安卓)(images/青少年模式未开启(安卓).png) +**状态:新增。** + +- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 +- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。 +- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 + +### 10.15 青少年模式提示(安卓).png + +> 图片:青少年模式提示(安卓)(images/青少年模式提示(安卓).png) +**状态:弹窗纯客户端;文案由服务端配置。** + +- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 +- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 + +### 10.16 青少年模式已开启(安卓).png + +> 图片:青少年模式已开启(安卓)(images/青少年模式已开启(安卓).png) +**状态:新增。** + +- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 +- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 +- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 + +### 10.17 关闭青少年模式(安卓).png + +> 图片:关闭青少年模式(安卓)(images/关闭青少年模式(安卓).png) +**状态:新增。** + +- 先调用 1.11 的独立密码验证,获得 `action_token`;再 `POST /v1/im/settings/youth-mode/disable`,Header `X-Action-Token`,带幂等键。 +- 成功返回 `enabled:false,updated_at` 并广播;连续密码错误按服务端 `retry_after_seconds` 锁定。 +- 忘记密码走 1.7 身份验证后重置,不能绕过密码直接修改本地状态。 + +### 10.18 获取推送权限(安卓).png + +> 图片:获取推送权限(安卓)(images/获取推送权限(安卓).png) +**状态:权限引导纯客户端;设备注册需改造。** + +- 用户确认后调用 Android 系统权限;拒绝状态本地记录,最终以系统回调为准。 +- 获取 Push Token 后 `PUT /v1/im/devices/{device_id}/push-token`,Body `{"provider":"fcm","token":"...","app_version":"..."}`;Token 轮换时覆盖。 +- 退出登录删除/解绑该用户设备 Token。Push Token 属敏感凭证,不写日志。 + +### 10.19 提醒(安卓).png + +> 图片:提醒(安卓)(images/提醒(安卓).png) +**状态:纯客户端。** + +- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 +- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 + +### 10.20 相机说明(安卓).png + +> 图片:相机说明(安卓)(images/相机说明(安卓).png) +**状态:纯客户端。** + +- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 +- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 + +### 10.21 麦克风说明(安卓).png + +> 图片:麦克风说明(安卓)(images/麦克风说明(安卓).png) +**状态:纯客户端。** + +- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 +- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 反诈弹窗(安卓).png + +> 图片:反诈弹窗(安卓)(images/反诈弹窗(安卓).png) +**状态:新增风控决策协议。** + +- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 +- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 +- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 + +### 11.2 反诈弹窗(二次提示安卓).png + +> 图片:反诈弹窗(二次提示安卓)(images/反诈弹窗(二次提示安卓).png) +**状态:新增。** + +- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。 +- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。 +- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。 + +### 11.3 举报.png + +> 图片:举报(images/举报.png) +**状态:新增。** + +- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。 +- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。 +- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。 + +### 11.4 举报(1).png + +> 图片:举报(1)(images/举报(1).png) +**状态:新增。** + +- 证据使用 `purpose=abuse_evidence` 上传;提交 `POST /v1/im/reports/abuse`,Body `{"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}`。 +- 服务端从目标消息保存必要、受控的审核快照,校验证据数量/MIME/大小,返回 `report_id,status:"received"`。 +- 重复举报按策略合并并返回原受理号或 `REPORT_RATE_LIMITED`;证据使用独立严格 ACL 和保留期。 + +### 11.5 意见反馈.png + +> 图片:意见反馈(images/意见反馈.png) +**状态:新增。** + +- 配置:`GET /v1/im/feedback/config` 返回类别、最大正文、附件规则、是否允许联系方式和日志授权说明。 +- 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 +- 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + +## 12. 联调实施清单 + +### 12.1 当前可直接联调的基础能力 + +1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 +2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 +4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 +5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 + +### 12.2 必须优先完成的服务端阻塞项 + +| 优先级 | 改造项 | 阻塞图片章节 | +| --- | --- | --- | +| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | +| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | +| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | +| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | +| P1 | 群成员、角色、禁言、公告、邀请审批和配额 | 第 6 章 | +| P1 | 收藏、转发和聊天记录搜索 | 第 4、7 章 | +| P1 | 通话媒体状态、结果消息、Push 来电 | 第 8 章 | +| P2 | 工作台、组织、会议、日报、月报、信箱 | 第 9 章 | +| P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 | + +### 12.3 前后端联调最低验收用例 + +| 编号 | 场景 | 预期结果 | +| --- | --- | --- | +| T-01 | A 企业 Token 连接 B 企业 Session | 登录失败 `TENANT_MISMATCH`,不产生用户/Topic 数据 | +| T-02 | 企业停用后使用旧 Token 登录和请求 HTTP | WS/HTTP 均拒绝,客户端清除凭证并回企业入口 | +| T-03 | 同 `client_mid` 断线重发同一消息 | 只落一条消息并返回同一 `seq` | +| T-04 | 相同 HTTP 幂等键、相同请求体重复提交 | 返回第一次结果;不同请求体返回 `IDEMPOTENCY_CONFLICT` | +| T-05 | 被拉黑或删除好友后绕过按钮直接 `pub` | 服务端拒绝,对方不收到消息 | +| T-06 | 普通用户构造 `system_event` 或向系统 Topic 发布 | 服务端拒绝,系统消息页不展示可信样式 | +| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | +| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | +| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | +| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | +| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | + +### 12.4 统一错误 reason + +首版客户端至少实现:`TENANT_UNAVAILABLE`、`TENANT_MISMATCH`、`AUTH_FAILED`、`TOKEN_EXPIRED`、`ACCOUNT_DISABLED`、`RATE_LIMITED`、`VALIDATION_FAILED`、`IDEMPOTENCY_CONFLICT`、`RESOURCE_NOT_FOUND`、`FORBIDDEN`、`FRIEND_RELATION_REQUIRED`、`MESSAGE_NOT_ALLOWED`、`GROUP_MUTED`、`GROUP_MEMBER_LIMIT_EXCEEDED`、`TENANT_USER_QUOTA_EXCEEDED`、`TENANT_GROUP_QUOTA_EXCEEDED`、`FILE_TOO_LARGE`、`FILE_FORBIDDEN`、`FILE_UNAVAILABLE`、`STORAGE_QUOTA_EXCEEDED`、`REALNAME_REQUIRED`、`YOUTH_MODE_RESTRICTED`、`RISK_CONFIRMATION_REQUIRED`、`RISK_ACTION_BLOCKED`。 + +未知 `reason` 使用通用失败文案并记录 `request_id`,不得把服务端 `message` 原样展示给用户。 + +### 12.5 完成定义 + +每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。 diff --git a/docs/IM-前后端对接文档-禅道安全版.md b/docs/IM-前后端对接文档-禅道安全版.md new file mode 100644 index 0000000..c851a62 --- /dev/null +++ b/docs/IM-前后端对接文档-禅道安全版.md @@ -0,0 +1,1969 @@ +# IM 前后端逐图对接协议 + +> 版本:v2.0 +> 日期:2026-08-11 +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) +> 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 + +## 0. 公共对接约定 + +### 0.1 实现状态 + +| 状态 | 含义 | +| --- | --- | +| `已实现` | 当前服务端已有能力,可按本文联调 | +| `需改造` | 已有基础能力,但字段、鉴权或业务规则需要调整 | +| `新增` | 当前服务端没有该产品接口或数据模型 | +| `纯客户端` | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 | + +文档中所有 `新增`、`需改造` 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。 + +### 0.2 API Key、启动入口与通道 + +#### 0.2.1 现有系统的 API Key 从哪里来 + +**API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。** + +API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置: + +1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 +2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 + +生成示意: + +```text +$ go run ./keygen +API key v1 seq1 [ordinary]: <client-api-key> +HMAC salt: <server-hmac-salt> +``` + +API Key、企业码和用户 Token 是三个不同概念: + +| 值 | 取得时间 | 作用 | 是否代表用户身份 | +| --- | --- | --- | --- | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | +| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | +| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | + +API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 `checkAPIKey` 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。 + +当前 `checkAPIKey` 没有按 `sequence` 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 `api_key_salt` 会让所有旧 Key 同时失效,必须与客户端升级协调。 + +#### 0.2.2 客户端怎样携带 API Key + +| 通道 | 地址 | 认证 | 用途 | +| --- | --- | --- | --- | +| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | +| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | +| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | + +HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 + +WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: + +```json +{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}} +``` + +客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。 + +两种连接方式的第一个请求对照如下: + +| 连接方式 | 发起第一个网络请求前已有的值 | 第一个网络请求 | 第一个响应得到的值 | 下一步 | +| --- | --- | --- | --- | --- | +| WebSocket(推荐) | `host + api_key + tenant_code` | 带 API Key Upgrade `/v0/channels` | HTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid | 发送 `{hi tenant}` | +| Long Polling(兼容) | `host + api_key + tenant_code` | `POST /v0/channels/lp` | HTTP 201 和 `ctrl.params.sid` | 带 `sid` POST `{hi}`,再 GET 等待响应 | + +现有实现对应关系: + +| 源码 | 作用 | +| --- | --- | +| `keygen/keygen.go` | 生成普通/Root API Key 和服务端 HMAC Salt | +| `server/api_key.go` | `checkAPIKey` 校验 API Key 格式与签名 | +| `server/http.go` | 按 Header、Query、Form、Cookie 顺序读取 API Key | +| `server/hdl_websock.go` | WebSocket Upgrade 前校验 API Key,无效直接 403 | +| `server/hdl_longpoll.go` | 创建 Long Polling Session 并返回 `sid` | +| `server/session.go` | 处理 `{hi}` 租户绑定、`{login}` 和登录 Token 返回 | + +现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 `{hi}.tenant` 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。 + +### 0.3 多租户强制规则 + +1. 未登录阶段只传公开的 `tenant_code`;客户端永远不接收、不提交数据库 `tenant_id`。 +2. WebSocket 第一条消息必须是 `{hi}`,其中 `tenant` 为企业码。服务端解析后把租户固定到 Session。 +3. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 `TENANT_MISMATCH`,不得自动切换。 +4. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。 +5. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 `tenant_id`、`user_id` 不能覆盖身份。 +6. 跨租户对象统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得暴露对象是否存在。 + +### 0.4 HTTP 请求与响应 + +成功响应统一为: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +失败响应统一为: + +```json +{ + "code": 30001, + "reason": "GROUP_MUTED", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 + +### 0.5 WebSocket 建连、登录和恢复顺序 + +#### 0.5.1 现有服务端可直接执行的冷启动流程 + +```text +读取客户端预置的 host 和普通 API Key +-> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 +-> 带 API Key 完成 WebSocket Upgrade +-> hi(tenant_code) +-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 +-> 有历史 Token 时 token login,否则 basic login/acc +-> 收到 login 的 200 ctrl,保存 user/token/expires +-> sub(me) 拉取本人资料和会话订阅 +-> sub(fnd) 建立搜索 Topic +-> 订阅需要在线的会话并按 seq 增量拉取 +-> 进入可发送状态 +``` + +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 + +`hi` 请求: + +```json +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}} +``` + +`login` 请求使用 `basic` 或 `token`: + +```json +{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}} +``` + +```json +{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} +``` + +`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +`hi` 成功示例: + +```json +{ + "ctrl": { + "id": "hi-1", + "code": 201, + "text": "created", + "params": { + "ver": "0.25", + "tenant": {"code": "acme", "name": "示例企业"}, + "maxFileUploadSize": 104857600, + "callTimeout": 30, + "iceServers": [] + } + } +} +``` + +客户端只使用实际响应中存在的限制字段;未知字段忽略。`hi` 返回非 2xx 时必须关闭连接,不得继续发送 `login`。 + +登录成功后发送: + +```json +{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}} +``` + +客户端按相同 `id` 接收 `{ctrl}` 和一个或多个 `{meta}`,从 `meta.desc` 取得本人资料和读写游标,从 `meta.sub` 取得会话 Topic、访问权限、`seq/read/recv` 与会话摘要。不要依赖 `{ctrl}`、`{meta}` 的固定先后顺序。 + +随后建立查找 Topic: + +```json +{"sub":{"id":"sub-fnd-1","topic":"fnd"}} +``` + +进入具体会话时订阅并增量拉取,`since` 使用客户端已完整持久化的最后 `seq + 1`: + +```json +{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} +``` + +订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 + +#### 0.5.2 Long Polling 兼容流程:先取得的是 sid + +如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 + +第一步,创建 Long Polling Session: + +```http +POST /v0/channels/lp HTTP/1.1 +Host: im.example.com +X-Tinode-APIKey: <预置的普通 API Key> +Content-Length: 0 +``` + +服务端返回 HTTP 201: + +```json +{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}} +``` + +第二步开始,所有 Long Polling 请求都同时携带 API Key 和 `sid`。发送 `hi`: + +```http +POST /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +Content-Type: application/json + +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} +``` + +POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 `{ctrl}`、`{meta}`、`{data}`、`{pres}` 或 `{info}`: + +```http +GET /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +``` + +收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 + +`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 + +#### 0.5.3 企业解析与产品流程 + +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: + +```text +读取预置 API Key +-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置 +-> 带同一 API Key 建立返回地址的 WebSocket +-> hi 再次绑定并校验 tenant_code +-> login -> sub me -> 恢复会话 +``` + +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +### 0.6 Topic 与产品消息 + +核心命令:`sub` 订阅、`pub` 发送、`get` 拉取、`set` 更新 Topic、`leave` 离开、`del` 删除、`note` 发送回执/临时状态。发送成功为 `{ctrl code:202, params:{seq}}`,服务端下行消息为 `{data}`。 + +所有产品消息在 `pub.head` 中携带: + +```json +{ + "pub": { + "id": "pub-1", + "topic": "p2p...", + "noecho": false, + "head": { + "x-im-schema": 1, + "x-im-type": "text", + "x-im-client-mid": "01K...", + "mentions": [] + }, + "content": {"text":"你好"} + } +} +``` + +`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 + +消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 + +### 0.7 HTTP 幂等、重试和安全 + +1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 +2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 +3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 +5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 + +## 1. 账号注册、登录与身份安全 + +### 1.1 企业码登录.png + +![企业码登录](images/企业码登录.png) + +**状态:企业解析接口与 WebSocket 租户握手均已实现。** + +**当前系统的执行方式:** + +- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 +- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 +- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 + +**企业解析接口:** + +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 +- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 +- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 + +成功响应示例: + +```json +{ + "code": 0, + "data": { + "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, + "endpoints": { + "websocket": "wss://im.example.com/v0/channels", + "api": "https://im.example.com/v1/im", + "file": "https://im.example.com/v0/file" + }, + "auth_methods": ["password", "sms"], + "password_policy": {"min_length": 8, "max_length": 64}, + "app_update": {"type": "none"} + }, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +### 1.2 登录页面.png + +![登录页面](images/登录页面.png) + +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** + +- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。 +- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 + +登录成功示例: + +```json +{ + "ctrl": { + "id": "login-1", + "code": 200, + "text": "ok", + "params": { + "user": "usr...", + "authlvl": "auth", + "token": "base64-opaque-token", + "expires": "2026-08-11T10:30:15.123Z" + } + } +} +``` + +失败仍是同一 `{ctrl}` 结构,使用 `code` 和改造后的稳定 `params.reason` 分支;客户端不得用 `text` 做程序判断。 + +### 1.3 登录页面-密码错误.png + +![登录页面-密码错误](images/登录页面-密码错误.png) + +**状态:已实现基础错误,错误码和限流需改造。** + +- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 +- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 +- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 +- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 + +### 1.4 注册页面.png + +![注册页面](images/注册页面.png) + +**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。** + +- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 +- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 +- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 +- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +``` +- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +### 1.5 注册页面-倒计时.png + +![注册页面-倒计时](images/注册页面-倒计时.png) + +**状态:新增。** + +- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 +- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 +- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 + +### 1.6 忘记密码.png + +![忘记密码](images/忘记密码.png) + +**状态:新增。** + +- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 +- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 +- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 +- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 + +### 1.7 身份验证(安卓).png + +![身份验证(安卓)](images/身份验证(安卓).png) + +**状态:新增。** + +- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。 +- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 +- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 +- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 + +### 1.8 实名认证.png + +![实名认证](images/实名认证.png) + +**状态:新增。** + +- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 +- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 +- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 + +### 1.9 实名认证-已实名.png + +![实名认证-已实名](images/实名认证-已实名.png) + +**状态:新增。** + +- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 +- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 + +### 1.10 实名认证(安卓).png + +![实名认证(安卓)](images/实名认证(安卓).png) + +**状态:新增。** + +- 登录后 `GET /v1/im/bootstrap` 返回 `security.realname{status,required,allow_skip,remind_after}`;只有 `required=true` 且未认证时显示弹窗。 +- 点击“去认证”进入 1.8;点击稍后仅在 `allow_skip=true` 时本地关闭,并按 `remind_after` 控制再次提示。 +- 服务端对必须实名的受限写操作仍返回 `REALNAME_REQUIRED`,前端弹窗不能作为授权依据。 + +### 1.11 设置独立密码(安卓).png + +![设置独立密码(安卓)](images/设置独立密码(安卓).png) + +**状态:新增。** + +- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。 +- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 +- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 +- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 + +## 2. 消息首页、全局导航与系统通知 + +### 2.1 消息.png + +![消息](images/消息.png) + +**状态:会话订阅已实现;产品摘要和系统入口需改造。** + +- 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 +- 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 + +### 2.2 消息-无网络状态.png + +![消息-无网络状态](images/消息-无网络状态.png) + +**状态:需改造客户端恢复流程和消息幂等。** + +- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 +- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 +- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 +- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 + +### 2.3 消息-更多.png + +![消息-更多](images/消息-更多.png) + +**状态:菜单为纯客户端;权限数据新增。** + +- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 +- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 +- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 + +### 2.4 消息页面—长按弹出.png + +![消息页面—长按弹出](images/消息页面—长按弹出.png) + +**状态:基础消息操作部分已实现;产品权限矩阵需改造。** + +- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 +- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 + +### 2.5 系统消息.png + +![系统消息](images/系统消息.png) + +**状态:需改造。** + +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 +- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 +- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 + +## 3. 单聊会话、会话建立与消息状态 + +### 3.1 个人对话框(对方的状态).png + +![个人对话框(对方的状态)](images/个人对话框(对方的状态).png) + +**状态:需改造好友关系状态。** + +- 进入会话先 `GET /v1/im/conversations/{topic}/context`,返回 `relationship=pending_inbound`、`can_send=false`、对方公开资料和申请 ID。 +- 历史申请提示由 `relationship_event` 系统消息渲染;普通消息仍通过 Topic `{data}` 下行。 +- 输入区锁定;“通过/拒绝”调用 5.2 的好友申请接口。前端不可通过直接 `pub` 绕过,服务端发布路径必须校验好友关系。 + +### 3.2 个人对话框(对方的状态)(1).png + +![个人对话框(对方的状态)(1)](images/个人对话框(对方的状态)(1).png) + +**状态:在线状态已具备基础能力;关系文案新增。** + +- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 +- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 +- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 + +### 3.3 个人对话框(我的状态).png + +![个人对话框(我的状态)](images/个人对话框(我的状态).png) + +**状态:新增好友申请状态。** + +- 发起申请使用 `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"我是...","source":"search"}`。 +- 返回 `request_id,status:"pending",created_at`;页面以该服务端状态显示“已发送”,重复点击用同一幂等键返回原申请。 +- 申请待处理时 `context.relationship=pending_outbound`、`can_send=false`,禁止重复创建和普通消息发送。 + +### 3.4 个人对话框(我的状态)(1).png + +![个人对话框(我的状态)(1)](images/个人对话框(我的状态)(1).png) + +**状态:发送和读回执已实现基础能力;送达状态映射需统一。** + +- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 +- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 +- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 + +### 3.5 开启沟通页面.png + +![开启沟通页面](images/开启沟通页面.png) + +**状态:新增好友审批与 Topic 联动。** + +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 +- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 + +### 3.6 双方沟通状态.png + +![双方沟通状态](images/双方沟通状态.png) + +**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** + +- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 +- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 +- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 + +### 3.7 个人对话—拉黑状态/已读状态.png + +![个人对话—拉黑状态/已读状态](images/个人对话—拉黑状态/已读状态.png) + +**状态:黑名单产品规则新增。** + +- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 +- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 +- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 + +### 3.8 个人对话—删除状态.png + +![个人对话—删除状态](images/个人对话—删除状态.png) + +**状态:新增好友删除后的发送约束。** + +- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 +- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 +- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 + +### 3.9 个人对话-键盘收起状态.png + +![个人对话-键盘收起状态](images/个人对话-键盘收起状态.png) + +**状态:纯客户端。** + +- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 +- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 + +### 3.10 个人对话-发各种文件状态.png + +![个人对话-发各种文件状态](images/个人对话-发各种文件状态.png) + +**状态:面板纯客户端;上传需改造。** + +- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 +- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 +- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 + +### 3.11 个人对话-发各种文件状态(1).png + +![个人对话-发各种文件状态(1)](images/个人对话-发各种文件状态(1).png) + +**状态:需改造产品消息 Schema。** + +- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 +- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 +- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 + +### 3.12 个人对话-发送名片状态.png + +![个人对话-发送名片状态](images/个人对话-发送名片状态.png) + +**状态:新增产品消息类型。** + +- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 +- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 +- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 + +## 4. 消息输入、媒体内容与消息操作 + +### 4.1 键盘展开.png + +![键盘展开](images/键盘展开.png) + +**状态:纯客户端。** + +- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 +- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 + +### 4.2 @人员—选择提醒人.png + +![@人员—选择提醒人](images/@人员—选择提醒人.png) + +**状态:新增群成员查询。** + +- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。 +- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。 +- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。 + +### 4.3 @人员—完成.png + +![@人员—完成](images/@人员—完成.png) + +**状态:纯客户端选择;发送协议需改造。** + +- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。 +- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。 +- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。 + +### 4.4 @人员状态.png + +![@人员状态](images/@人员状态.png) + +**状态:需改造提醒和 Push。** + +- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 +- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 +- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 + +### 4.5 表情包展开(发送按钮默认状态).png + +![表情包展开(发送按钮默认状态)](images/表情包展开(发送按钮默认状态).png) + +**状态:纯客户端。** + +- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 +- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 + +### 4.6 表情包发送页面.png + +![表情包发送页面](images/表情包发送页面.png) + +**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** + +- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 +- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 + +### 4.7 语音—按住说话.png + +![语音—按住说话](images/语音—按住说话.png) + +**状态:录音纯客户端;上传和语音消息需改造。** + +- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 +- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 +- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 + +### 4.8 语音按住说话.png + +![语音按住说话](images/语音按住说话.png) + +**状态:纯客户端。** + +- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 +- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 + +### 4.9 内容沟通对话3-语音录音中.png + +![内容沟通对话3-语音录音中](images/内容沟通对话3-语音录音中.png) + +**状态:录音纯客户端;临时状态可选改造。** + +- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 +- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 + +### 4.10 内容沟通对话3-图片和语音发送中.png + +![内容沟通对话3-图片和语音发送中](images/内容沟通对话3-图片和语音发送中.png) + +**状态:客户端任务管理新增;服务端上传需改造。** + +- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 +- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 +- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 + +### 4.11 内容沟通对话3-语音/视频/图片状态.png + +![内容沟通对话3-语音/视频/图片状态](images/内容沟通对话3-语音/视频/图片状态.png) + +**状态:需改造媒体 Schema 和受权下载。** + +- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 +- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 +- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 + +### 4.12 语音图片发送状态.png + +![语音图片发送状态](images/语音图片发送状态.png) + +**状态:需改造。** + +- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 +- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 + +### 4.13 上传文件.png + +![上传文件](images/上传文件.png) + +**状态:纯客户端视觉资源。** + +- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 +- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 + +### 4.14 文件发送状态.png + +![文件发送状态](images/文件发送状态.png) + +**状态:上传已实现基础能力;消息引用和 ACL 需改造。** + +- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 +- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 +- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 + +### 4.15 多选-选中对话框.png + +![多选-选中对话框](images/多选-选中对话框.png) + +**状态:选择纯客户端;批量动作新增。** + +- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 +- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 +- 不允许只因第一条可操作就假定整批可操作。 + +### 4.16 多选-选中对话内容-收藏成功.png + +![多选-选中对话内容-收藏成功](images/多选-选中对话内容-收藏成功.png) + +**状态:新增。** + +- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 +- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 +- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 + +### 4.17 转发-单选.png + +![转发-单选](images/转发-单选.png) + +**状态:新增。** + +- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 +- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 + +### 4.18 转发-多选.png + +![转发-多选](images/转发-多选.png) + +**状态:新增。** + +- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 +- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 + +### 4.19 转发—逐条转发.png + +![转发—逐条转发](images/转发—逐条转发.png) + +**状态:新增。** + +- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。 +- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 +- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 + +### 4.20 转发—合并转发—多选状态.png + +![转发—合并转发—多选状态](images/转发—合并转发—多选状态.png) + +**状态:新增。** + +- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 获取。 +- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 +- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 + +## 5. 通讯录、好友关系与黑名单 + +### 5.1 通讯录.png + +![通讯录](images/通讯录.png) + +**状态:新增产品通讯录接口。** + +- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 +- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 +- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 + +### 5.2 新朋友.png + +![新朋友](images/新朋友.png) + +**状态:新增。** + +- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 +- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 +- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 + +### 5.3 添加好友.png + +![添加好友](images/添加好友.png) + +**状态:新增。** + +- 账号搜索:`GET /v1/im/users/search?q=&type=account&cursor=`;扫码后调用 `POST /v1/im/qrcodes/resolve`,Body `{"token":"...","purpose":"add_friend"}`。 +- 结果统一返回 `relationship=none|friend|pending_inbound|pending_outbound` 和 `can_add`;隐私不允许或未找到统一为 `USER_NOT_DISCOVERABLE`。 +- 分享/二维码见 5.4;发申请见 5.6。服务端按租户配置 `allow_user_search` 和用户隐私再次拦截。 + +### 5.4 添加好友(1).png + +![添加好友(1)](images/添加好友(1).png) + +**状态:新增。** + +- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 + +### 5.5 添加好友-输入精准用户ID.png + +![添加好友-输入精准用户ID](images/添加好友-输入精准用户ID.png) + +**状态:新增。** + +- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 +- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 +- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 + +### 5.6 添加好友验证.png + +![添加好友验证](images/添加好友验证.png) + +**状态:新增。** + +- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。 +- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。 +- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。 + +### 5.7 好友个人主页.png + +![好友个人主页](images/好友个人主页.png) + +**状态:新增。** + +- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 +- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 + +### 5.8 群聊—非好友个人主页.png + +![群聊—非好友个人主页](images/群聊—非好友个人主页.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。 +- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 +- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 + +### 5.9 好友—聊天设置—未拉黑状态.png + +![好友—聊天设置—未拉黑状态](images/好友—聊天设置—未拉黑状态.png) + +**状态:部分 Topic 设置已实现;产品设置新增。** + +- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。 +- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。 +- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。 + +### 5.10 好友—聊天设置—拉黑状态.png + +![好友—聊天设置—拉黑状态](images/好友—聊天设置—拉黑状态.png) + +**状态:新增。** + +- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 + +### 5.11 删除好友提示.png + +![删除好友提示](images/删除好友提示.png) + +**状态:确认框纯客户端;删除接口新增。** + +- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 +- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 +- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 + +### 5.12 黑名单.png + +![黑名单](images/黑名单.png) + +**状态:新增。** + +- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。 +- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。 +- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。 + +### 5.13 黑名单提示(1).png + +![黑名单提示(1)](images/黑名单提示(1).png) + +**状态:确认框纯客户端;写接口新增。** + +- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 +- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 +- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 + +### 5.14 黑名单提示.png + +![黑名单提示](images/黑名单提示.png) + +**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** + +- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 +- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 + +## 6. 群聊创建、资料维护与成员治理 + +### 6.1 群聊.png + +![群聊](images/群聊.png) + +**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** + +- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 + +### 6.2 空白页.png + +![空白页](images/空白页.png) + +**状态:纯客户端。** + +- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 +- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 + +### 6.3 加入群聊.png + +![加入群聊](images/加入群聊.png) + +**状态:新增邀请卡片和加入接口。** + +- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 +- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 + +### 6.4 内容沟通对话3-开启进群验证与拉人状态.png + +![内容沟通对话3-开启进群验证与拉人状态](images/内容沟通对话3-开启进群验证与拉人状态.png) + +**状态:新增群成员事件。** + +- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 +- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 +- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 + +### 6.5 邀请进群申请.png + +![邀请进群申请](images/邀请进群申请.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 +- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 +- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 + +### 6.6 新邀请进群申请.png + +![新邀请进群申请](images/新邀请进群申请.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 +- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 + +### 6.7 群公告状态.png + +![群公告状态](images/群公告状态.png) + +**状态:新增。** + +- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 +- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 +- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 + +### 6.8 群公告状态/邀请进群申请同时存在.png + +![群公告状态/邀请进群申请同时存在](images/群公告状态/邀请进群申请同时存在.png) + +**状态:纯客户端组合;数据接口新增。** + +- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 +- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 + +### 6.9 群公告(对方查看状态).png + +![群公告(对方查看状态)](images/群公告(对方查看状态).png) + +**状态:新增。** + +- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 +- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 +- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 + +### 6.10 群聊设置.png + +![群聊设置](images/群聊设置.png) + +**状态:部分订阅设置已实现;产品菜单新增。** + +- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 +- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 +- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 + +### 6.11 群资料.png + +![群资料](images/群资料.png) + +**状态:需改造。** + +- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。 +- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。 + +### 6.12 设置群头像.png + +![设置群头像](images/设置群头像.png) + +**状态:上传基础已实现;用途绑定需改造。** + +- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 +- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 +- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 + +### 6.13 设置昵称(1).png + +![设置昵称(1)](images/设置昵称(1).png) + +**状态:新增。** + +- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 +- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 + +### 6.14 群简介.png + +![群简介](images/群简介.png) + +**状态:同 6.11,需改造。** + +- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 +- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 + +### 6.15 群标识.png + +![群标识](images/群标识.png) + +**状态:角色已有基础数据;产品渲染需统一。** + +- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 +- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 +- 客户端不得根据本地管理员列表给消息自行加可信标识。 + +### 6.16 群二维码页面.png + +![群二维码页面](images/群二维码页面.png) + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 +- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 + +### 6.17 群组管理.png + +![群组管理](images/群组管理.png) + +**状态:新增。** + +- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 +- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 +- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 + +### 6.18 设置群管理员.png + +![设置群管理员](images/设置群管理员.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。 +- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。 + +### 6.19 添加管理员.png + +![添加管理员](images/添加管理员.png) + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。 +- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。 +- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。 + +### 6.20 添加管理员-设置成功.png + +![添加管理员-设置成功](images/添加管理员-设置成功.png) + +**状态:新增。** + +- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 +- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 + +### 6.21 添加管理员-设置成功(1).png + +![添加管理员-设置成功(1)](images/添加管理员-设置成功(1).png) + +**状态:新增。** + +- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 +- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 + +### 6.22 删除管理员-设置成功.png + +![删除管理员-设置成功](images/删除管理员-设置成功.png) + +**状态:新增。** + +- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 +- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 +- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 + +### 6.23 添加禁言成员.png + +![添加禁言成员](images/添加禁言成员.png) + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。 +- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。 +- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。 + +### 6.24 禁言的群成员.png + +![禁言的群成员](images/禁言的群成员.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。 +- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。 +- 退群时清理禁言记录;过期由服务端自动失效并推送更新。 + +### 6.25 选择新群主.png + +![选择新群主](images/选择新群主.png) + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 +- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 +- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 + +### 6.26 选择新群主提示.png + +![选择新群主提示](images/选择新群主提示.png) + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 +- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 + +### 6.27 群文件.png + +![群文件](images/群文件.png) + +**状态:新增群文件索引;底层文件存储已实现。** + +- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。 +- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。 +- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。 + +### 6.28 群文件-上传状态.png + +![群文件-上传状态](images/群文件-上传状态.png) + +**状态:需改造。** + +- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 +- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 + +## 7. 文件下载、收藏管理与聊天记录检索 + +### 7.1 我的收藏.png + +![我的收藏](images/我的收藏.png) + +**状态:新增。** + +- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。 +- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。 +- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。 + +### 7.2 我的收藏(1).png + +![我的收藏(1)](images/我的收藏(1).png) + +**状态:新增。** + +- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 +- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 +- 删除收藏不删除原消息或已转发消息。 + +### 7.3 我的收藏-文件.png + +![我的收藏-文件](images/我的收藏-文件.png) + +**状态:新增。** + +- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 +- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 + +### 7.4 我的收藏-文本.png + +![我的收藏-文本](images/我的收藏-文本.png) + +**状态:新增。** + +- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 +- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 + +### 7.5 我的收藏-语音.png + +![我的收藏-语音](images/我的收藏-语音.png) + +**状态:新增。** + +- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 +- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 + +### 7.6 我的收藏—链接.png + +![我的收藏—链接](images/我的收藏—链接.png) + +**状态:新增。** + +- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。 +- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。 +- 客户端不得在未确认时自动打开或预加载高风险外链。 + +### 7.7 收藏—文件(多选).png + +![收藏—文件(多选)](images/收藏—文件(多选).png) + +**状态:新增。** + +- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 +- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 + +### 7.8 收藏文件—分享给好友页面.png + +![收藏文件—分享给好友页面](images/收藏文件—分享给好友页面.png) + +**状态:新增。** + +- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 +- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 +- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 + +### 7.9 文件-下载.png + +![文件-下载](images/文件-下载.png) + +**状态:底层下载已实现租户校验;资源 ACL 需改造。** + +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 +- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 +- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 + +### 7.10 文件-下载中.png + +![文件-下载中](images/文件-下载中.png) + +**状态:客户端任务管理;服务端需支持 Range。** + +- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。 +- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 +- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 + +### 7.11 文件-下载完成.png + +![文件-下载完成](images/文件-下载完成.png) + +**状态:纯客户端完成态。** + +- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 +- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 + +### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png + +![查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)](images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png) + +**状态:新增服务端搜索。** + +- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 +- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 +- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 + +### 7.13 查找聊天记录-文件.png + +![查找聊天记录-文件](images/查找聊天记录-文件.png) + +**状态:新增。** + +- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 +- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 + +### 7.14 查找聊天记录-链接.png + +![查找聊天记录-链接](images/查找聊天记录-链接.png) + +**状态:新增。** + +- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 +- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 + +### 7.15 查看聊天记录-图片.png + +![查看聊天记录-图片](images/查看聊天记录-图片.png) + +**状态:新增。** + +- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。 +- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。 + +### 7.16 查看聊天记录-视频.png + +![查看聊天记录-视频](images/查看聊天记录-视频.png) + +**状态:新增。** + +- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 +- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 + +## 8. 实时语音与视频通话 + +### 8.1 各种通话提示状态.png + +![各种通话提示状态](images/各种通话提示状态.png) + +**状态:已实现基础通话记录替换;产品文案映射需统一。** + +- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。 +- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。 +- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。 + +### 8.2 对方状态.png + +![对方状态](images/对方状态.png) + +**状态:在线邀请已实现;Push 与产品鉴权需改造。** + +- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 +- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 +- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 + +### 8.3 语音通话-对方待接受状态.png + +![语音通话-对方待接受状态](images/语音通话-对方待接受状态.png) + +**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** + +- 发起前查询 `capabilities.can_call`,再发布持久邀请消息: + +```json +{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}} +``` + +- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。 +- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。 + +### 8.4 语音通话-双方语音沟通中.png + +![语音通话-双方语音沟通中](images/语音通话-双方语音沟通中.png) + +**状态:WebRTC 客户端能力;信令和服务端计时已实现。** + +- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。 +- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。 +- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。 + +### 8.5 语音通话-麦克风与扬声器已关.png + +![语音通话-麦克风与扬声器已关](images/语音通话-麦克风与扬声器已关.png) + +**状态:纯客户端设备控制。** + +- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 +- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 + +### 8.6 语音通话-麦克风与扬声器已开.png + +![语音通话-麦克风与扬声器已开](images/语音通话-麦克风与扬声器已开.png) + +**状态:纯客户端设备控制。** + +- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 +- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 + +### 8.7 视频通话—等待对方接受接受邀请中.png + +![视频通话—等待对方接受接受邀请中](images/视频通话—等待对方接受接受邀请中.png) + +**状态:基础通话信令已实现;隐私约束需落实。** + +- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 +- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 + +### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png + +![视频通话—等待对方接受接受邀请-摄像头关闭](images/视频通话—等待对方接受接受邀请-摄像头关闭.png) + +**状态:纯客户端状态;媒体状态事件需改造。** + +- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 +- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 + +### 8.9 双方视频通话中.png + +![双方视频通话中](images/双方视频通话中.png) + +**状态:WebRTC 客户端能力;服务端信令已具备基础。** + +- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 +- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 +- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 + +### 8.10 双方视频通话中-对方关闭摄像头状态.png + +![双方视频通话中-对方关闭摄像头状态](images/双方视频通话中-对方关闭摄像头状态.png) + +**状态:现阶段纯客户端;可选媒体状态事件需改造。** + +- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 +- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 + +### 8.11 视频与语音通话悬浮状态中.png + +![视频与语音通话悬浮状态中](images/视频与语音通话悬浮状态中.png) + +**状态:纯客户端。** + +- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 +- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 + +## 9. 企业工作台与办公协同 + +### 9.1 工作台.png + +![工作台](images/工作台.png) + +**状态:新增。** + +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 +- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 + +### 9.2 部门组织框架.png + +![部门组织框架](images/部门组织框架.png) + +**状态:新增。** + +- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 +- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 +- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 + +### 9.3 会议通知-发起人状态.png + +![会议通知-发起人状态](images/会议通知-发起人状态.png) + +**状态:新增。** + +- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 +- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 +- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 + +### 9.4 会议通知-接收人状态.png + +![会议通知-接收人状态](images/会议通知-接收人状态.png) + +**状态:新增。** + +- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 +- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 +- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 + +### 9.5 日报.png + +![日报](images/日报.png) + +**状态:新增。** + +- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。 +- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。 +- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。 + +### 9.6 日报-普通用户页面.png + +![日报-普通用户页面](images/日报-普通用户页面.png) + +**状态:新增。** + +- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。 +- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。 +- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。 + +### 9.7 日报-管理员页面.png + +![日报-管理员页面](images/日报-管理员页面.png) + +**状态:新增。** + +- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。 +- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。 +- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。 + +### 9.8 日报-查看详情.png + +![日报-查看详情](images/日报-查看详情.png) + +**状态:新增。** + +- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 +- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 +- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 + +### 9.9 月报(1).png + +![月报(1)](images/月报(1).png) + +**状态:新增。** + +- 创建/取得当月草稿:`POST /v1/im/reports/monthly`,Body `{"month":"2026-08"}`。 +- 保存:`PUT /v1/im/reports/monthly/{id}/draft`,Body `{"work_summary":"...","next_plan":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/monthly/{id}/submit`。 +- 字段必填、最大长度、提交截止时间由响应 `validation` 提供;自动保存和冲突处理同 9.6。 + +### 9.10 月报.png + +![月报](images/月报.png) + +**状态:新增。** + +- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。 +- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。 +- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。 + +### 9.11 月报-详情页.png + +![月报-详情页](images/月报-详情页.png) + +**状态:新增。** + +- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。 +- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。 + +### 9.12 总经理信箱.png + +![总经理信箱](images/总经理信箱.png) + +**状态:新增。** + +- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 +- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 +- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 + +### 9.13 总经理信箱-文案提示.png + +![总经理信箱-文案提示](images/总经理信箱-文案提示.png) + +**状态:新增配置接口。** + +- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 +- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 + +## 10. 个人资料、通用设置、隐私与青少年模式 + +### 10.1 我的.png + +![我的](images/我的.png) + +**状态:基础用户资料已有;产品菜单新增。** + +- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 +- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 +- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 + +### 10.2 个人信息.png + +![个人信息](images/个人信息.png) + +**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** + +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 + +### 10.3 设置昵称.png + +![设置昵称](images/设置昵称.png) + +**状态:需改造。** + +- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 +- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 +- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 + +### 10.4 出生日期.png + +![出生日期](images/出生日期.png) + +**状态:新增产品字段。** + +- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 +- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 + +### 10.5 个性签名.png + +![个性签名](images/个性签名.png) + +**状态:需改造。** + +- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 +- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 + +### 10.6 二维码.png + +![二维码](images/二维码.png) + +**状态:图标纯客户端;二维码协议新增。** + +- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 +- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 + +### 10.7 设置(iOS).png + +![设置(iOS)](images/设置(iOS).png) + +**状态:菜单纯客户端;数据接口部分新增。** + +- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 +- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 + +### 10.8 设置(安卓).png + +![设置(安卓)](images/设置(安卓).png) + +**状态:菜单纯客户端;业务协议同 10.7。** + +- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 +- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 + +### 10.9 清空缓存的提示.png + +![清空缓存的提示](images/清空缓存的提示.png) + +**状态:纯客户端。** + +- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 +- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 + +### 10.10 版本更新页面.png + +![版本更新页面](images/版本更新页面.png) + +**状态:新增 App 配置接口。** + +- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 +- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 + +### 10.11 发现新版本.png + +![发现新版本](images/发现新版本.png) + +**状态:纯客户端弹窗;数据来自 10.10。** + +- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 +- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 + +### 10.12 隐私设置.png + +![隐私设置](images/隐私设置.png) + +**状态:新增。** + +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 +- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 +- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 + +### 10.13 隐私设置-添加我的方式选项.png + +![隐私设置-添加我的方式选项](images/隐私设置-添加我的方式选项.png) + +**状态:新增。** + +- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。 +- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。 +- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。 + +### 10.14 青少年模式未开启(安卓).png + +![青少年模式未开启(安卓)](images/青少年模式未开启(安卓).png) + +**状态:新增。** + +- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 +- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。 +- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 + +### 10.15 青少年模式提示(安卓).png + +![青少年模式提示(安卓)](images/青少年模式提示(安卓).png) + +**状态:弹窗纯客户端;文案由服务端配置。** + +- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 +- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 + +### 10.16 青少年模式已开启(安卓).png + +![青少年模式已开启(安卓)](images/青少年模式已开启(安卓).png) + +**状态:新增。** + +- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 +- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 +- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 + +### 10.17 关闭青少年模式(安卓).png + +![关闭青少年模式(安卓)](images/关闭青少年模式(安卓).png) + +**状态:新增。** + +- 先调用 1.11 的独立密码验证,获得 `action_token`;再 `POST /v1/im/settings/youth-mode/disable`,Header `X-Action-Token`,带幂等键。 +- 成功返回 `enabled:false,updated_at` 并广播;连续密码错误按服务端 `retry_after_seconds` 锁定。 +- 忘记密码走 1.7 身份验证后重置,不能绕过密码直接修改本地状态。 + +### 10.18 获取推送权限(安卓).png + +![获取推送权限(安卓)](images/获取推送权限(安卓).png) + +**状态:权限引导纯客户端;设备注册需改造。** + +- 用户确认后调用 Android 系统权限;拒绝状态本地记录,最终以系统回调为准。 +- 获取 Push Token 后 `PUT /v1/im/devices/{device_id}/push-token`,Body `{"provider":"fcm","token":"...","app_version":"..."}`;Token 轮换时覆盖。 +- 退出登录删除/解绑该用户设备 Token。Push Token 属敏感凭证,不写日志。 + +### 10.19 提醒(安卓).png + +![提醒(安卓)](images/提醒(安卓).png) + +**状态:纯客户端。** + +- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 +- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 + +### 10.20 相机说明(安卓).png + +![相机说明(安卓)](images/相机说明(安卓).png) + +**状态:纯客户端。** + +- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 +- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 + +### 10.21 麦克风说明(安卓).png + +![麦克风说明(安卓)](images/麦克风说明(安卓).png) + +**状态:纯客户端。** + +- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 +- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 反诈弹窗(安卓).png + +![反诈弹窗(安卓)](images/反诈弹窗(安卓).png) + +**状态:新增风控决策协议。** + +- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 +- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 +- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 + +### 11.2 反诈弹窗(二次提示安卓).png + +![反诈弹窗(二次提示安卓)](images/反诈弹窗(二次提示安卓).png) + +**状态:新增。** + +- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。 +- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。 +- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。 + +### 11.3 举报.png + +![举报](images/举报.png) + +**状态:新增。** + +- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。 +- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。 +- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。 + +### 11.4 举报(1).png + +![举报(1)](images/举报(1).png) + +**状态:新增。** + +- 证据使用 `purpose=abuse_evidence` 上传;提交 `POST /v1/im/reports/abuse`,Body `{"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}`。 +- 服务端从目标消息保存必要、受控的审核快照,校验证据数量/MIME/大小,返回 `report_id,status:"received"`。 +- 重复举报按策略合并并返回原受理号或 `REPORT_RATE_LIMITED`;证据使用独立严格 ACL 和保留期。 + +### 11.5 意见反馈.png + +![意见反馈](images/意见反馈.png) + +**状态:新增。** + +- 配置:`GET /v1/im/feedback/config` 返回类别、最大正文、附件规则、是否允许联系方式和日志授权说明。 +- 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 +- 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + +## 12. 联调实施清单 + +### 12.1 当前可直接联调的基础能力 + +1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 +2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 +4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 +5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 + +### 12.2 必须优先完成的服务端阻塞项 + +| 优先级 | 改造项 | 阻塞图片章节 | +| --- | --- | --- | +| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | +| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | +| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | +| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | +| P1 | 群成员、角色、禁言、公告、邀请审批和配额 | 第 6 章 | +| P1 | 收藏、转发和聊天记录搜索 | 第 4、7 章 | +| P1 | 通话媒体状态、结果消息、Push 来电 | 第 8 章 | +| P2 | 工作台、组织、会议、日报、月报、信箱 | 第 9 章 | +| P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 | + +### 12.3 前后端联调最低验收用例 + +| 编号 | 场景 | 预期结果 | +| --- | --- | --- | +| T-01 | A 企业 Token 连接 B 企业 Session | 登录失败 `TENANT_MISMATCH`,不产生用户/Topic 数据 | +| T-02 | 企业停用后使用旧 Token 登录和请求 HTTP | WS/HTTP 均拒绝,客户端清除凭证并回企业入口 | +| T-03 | 同 `client_mid` 断线重发同一消息 | 只落一条消息并返回同一 `seq` | +| T-04 | 相同 HTTP 幂等键、相同请求体重复提交 | 返回第一次结果;不同请求体返回 `IDEMPOTENCY_CONFLICT` | +| T-05 | 被拉黑或删除好友后绕过按钮直接 `pub` | 服务端拒绝,对方不收到消息 | +| T-06 | 普通用户构造 `system_event` 或向系统 Topic 发布 | 服务端拒绝,系统消息页不展示可信样式 | +| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | +| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | +| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | +| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | +| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | + +### 12.4 统一错误 reason + +首版客户端至少实现:`TENANT_UNAVAILABLE`、`TENANT_MISMATCH`、`AUTH_FAILED`、`TOKEN_EXPIRED`、`ACCOUNT_DISABLED`、`RATE_LIMITED`、`VALIDATION_FAILED`、`IDEMPOTENCY_CONFLICT`、`RESOURCE_NOT_FOUND`、`FORBIDDEN`、`FRIEND_RELATION_REQUIRED`、`MESSAGE_NOT_ALLOWED`、`GROUP_MUTED`、`GROUP_MEMBER_LIMIT_EXCEEDED`、`TENANT_USER_QUOTA_EXCEEDED`、`TENANT_GROUP_QUOTA_EXCEEDED`、`FILE_TOO_LARGE`、`FILE_FORBIDDEN`、`FILE_UNAVAILABLE`、`STORAGE_QUOTA_EXCEEDED`、`REALNAME_REQUIRED`、`YOUTH_MODE_RESTRICTED`、`RISK_CONFIRMATION_REQUIRED`、`RISK_ACTION_BLOCKED`。 + +未知 `reason` 使用通用失败文案并记录 `request_id`,不得把服务端 `message` 原样展示给用户。 + +### 12.5 完成定义 + +每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。 diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index dc4290f..269744c 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -165,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 {"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} ``` -`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 +`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。 `hi` 成功示例: @@ -295,7 +297,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。 4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 @@ -350,10 +352,11 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 - 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 - 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer `;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 -- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。 +- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 登录成功示例: @@ -391,18 +394,35 @@ X-Tinode-APIKey: <预置的普通 API Key> 注册页面 -**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。** +**状态:注册原型和服务端注册分流均已完成;手机号非空时优先走手机号方式,服务端已接入 username、邀请码和短信开关逻辑。** -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会把已校验手机号写入 `im_users.phone`,并保留验证凭证记录;用户标签已废弃,客户端设置 `tags` 会返回 `403 params.what=tags`。 -- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须统一使用 E.164 格式,如中国手机号 `+86138...`。服务端默认按 `CN` 解析未带国家码的号码并规范化,但客户端不得依赖该兼容行为;数据库 `im_users.phone` 始终保存带 `+` 和国家码的 E.164 值。 +- 页面规则:手机号非空时以手机号方式为准并忽略用户名输入;只有手机号为空时才使用用户名。两个字段同时为空时拒绝注册。 +- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 +- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 +- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 +- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。手机号非空时忽略用户名输入,也不校验被忽略用户名的格式。 +- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 +- 短信开关开启:手机号方式必须完成验证码验证,邀请码不能绕过短信验证;验证成功后用户名字段和手机号字段均保存规范化的 E.164 手机号。 +- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号方式提供有效邀请码后可无验证码注册,规范化手机号同时写入 `username` 和 `phone`,但不建立已完成的 `tel credential`。 +- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`secret` 使用最终选择值与密码。 +- 服务端判定:`phone` 非空时忽略 `username`,并校验 `secret` 登录名与规范化手机号一致;`phone` 为空时使用 `username` 并校验 `^[a-z0-9]{1,10}$`。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 +- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 + +用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} +``` + +手机号验证方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"ignored-if-present","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} ``` - 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 +- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 ### 1.5 注册页面-倒计时.png @@ -420,11 +440,14 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** +- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。 - 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 - 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 - 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。 +- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。 - 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 +- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。 ### 1.7 身份验证(安卓).png @@ -889,7 +912,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。 - 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 ### 5.5 添加好友-输入精准用户ID.png @@ -1363,7 +1386,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 - ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 +- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。 ### 7.11 文件-下载完成.png @@ -1727,7 +1750,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:菜单纯客户端;数据接口部分新增。** - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png @@ -1930,7 +1953,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | 优先级 | 改造项 | 阻塞图片章节 | | --- | --- | --- | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | -| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | | P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | | P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | @@ -1954,7 +1977,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | | T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | | T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | -| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | | T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | | T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | diff --git a/docs/IM-后台需求文档.md b/docs/IM-后台需求文档.md index aebf01e..2802988 100644 --- a/docs/IM-后台需求文档.md +++ b/docs/IM-后台需求文档.md @@ -223,10 +223,11 @@ 1. 用户列表、搜索、详情。 2. 创建、启用、停用、离职、恢复用户。 -3. 重置密码、撤销 Token、踢下线。 +3. 重置密码、撤销 Token、踢下线;无手机号账号忘记密码时只能由当前租户管理员执行重置。 4. 查看登录设备、登录 Session、安全事件。 5. 查看用户实名状态、青少年状态、隐私设置摘要。 6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。 +7. 支持注册邀请码单个/批量生成、有效期设置、撤销、明文精确查询和导出;邀请码固定为 5 位大小写英文字母,区分大小写且租户内不可重复。 建议接口: @@ -244,8 +245,10 @@ 验收点: 1. 停用用户不影响其他租户相同手机号或相同设备用户。 -2. 重置密码必须递增 `auth_version` 并撤销旧 Token。 -3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。 +2. 管理员只能重置当前租户用户,不得根据用户名跨租户查找或操作。 +3. 重置密码必须递增 `auth_version`,撤销旧 Token 和在线 Session,并写入包含操作者、目标用户、时间和结果的安全审计。 +4. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。 +5. 邀请码明文查看、查询和导出必须校验独立权限并写审计;普通日志不得记录邀请码。 ### 5.3 组织架构管理 diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 50db136..64aa5d9 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -581,13 +581,87 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles ( -- Existing database migration (the application performs the same migration -- idempotently at startup): --- 1. Add im_users.phone VARCHAR(32) NULL and a UNIQUE index. --- 2. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''. --- 3. Backfill phone/email from validated im_credentials first, then use +-- 1. Add im_users.phone VARCHAR(32) NULL and im_users.username VARCHAR(32). +-- 2. Backfill username from im_auth.uname after removing the basic: prefix; +-- use phone or reserved_ only when no Basic login exists. +-- 3. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''. +-- 4. Backfill phone/email from validated im_credentials first, then use -- tel:/email: rows in im_usertags as a legacy fallback. --- 4. Drop im_usertags and im_users.tags only after the backfill is verified. +-- 5. Verify uniqueness, then make username NOT NULL and add UNIQUE(username). +-- 6. Drop im_auth.uname and im_auth.scheme; add UNIQUE(userid). +-- 7. Drop im_usertags and im_users.tags only after the backfill is verified. -- im_topics.tags and im_topictags are topic discovery data and must be retained. +CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_batches ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + quantity INT UNSIGNED NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key), + INDEX idx_im_registration_invite_batch_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS hotgo.im_registration_invite_codes ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + batch_id BIGINT, + code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + expires_at DATETIME(3) NOT NULL, + usage_count INT UNSIGNED NOT NULL DEFAULT 0, + last_used_at DATETIME(3), + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code), + INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at), + INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id), + INDEX idx_im_registration_invite_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_registration_invite_uses ( + id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + used_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + INDEX idx_im_registration_invite_use_code (invite_code_id), + UNIQUE INDEX uk_im_registration_invite_use_user (user_id), + INDEX idx_im_registration_invite_use_request (request_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Legacy default-tenant migration. For additional tenant databases, repeat +-- this copy with that database name and its hotgo.im_tenant.id before drop. +SET @im_sql := IF( + (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_batches') > 0, + 'INSERT INTO hotgo.im_registration_invite_batches (id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at) SELECT id,1,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at FROM tinode.im_registration_invite_batches ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql := IF( + (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_registration_invite_codes') > 0, + 'INSERT INTO hotgo.im_registration_invite_codes (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) SELECT c.id,1,c.batch_id,c.code,CASE WHEN c.status=''used'' THEN IF(c.expires_at<=NOW(3),''expired'',''active'') ELSE c.status END,c.expires_at,COUNT(u.id),MAX(u.used_at),c.created_by,c.remark,c.created_at,c.updated_at FROM tinode.im_registration_invite_codes c LEFT JOIN tinode.im_registration_invite_uses u ON u.invite_code_id=c.id GROUP BY c.id ON DUPLICATE KEY UPDATE usage_count=VALUES(usage_count),last_used_at=VALUES(last_used_at),updated_at=VALUES(updated_at)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +DROP TABLE IF EXISTS tinode.im_registration_invite_codes; +DROP TABLE IF EXISTS tinode.im_registration_invite_batches; + CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, diff --git a/docs/IM-注册、邀请码与认证数据模型改造需求文档.md b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md new file mode 100644 index 0000000..715403b --- /dev/null +++ b/docs/IM-注册、邀请码与认证数据模型改造需求文档.md @@ -0,0 +1,529 @@ +# IM 注册、邀请码与认证数据模型改造需求文档 + +> 版本:V1.0 评审稿 +> 编写日期:2026-08-18 +> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL +> 关联文档:IM-前后端对接文档.md 1.4、IM-系统设计文档.md + +## 1. 文档目的 + +本文统一定义以下改造: + +1. 注册账号支持“用户名或手机号”。 +2. 手机号注册支持短信验证,后端固定补中国国家号 +86。 +3. HotGo 后台增加注册短信发送开关。 +4. 邀请码作为无短信验证时的注册放行凭证。 +5. 邀请码支持单个生成、批量生成、自定义有效期、撤销和使用审计。 +6. im_users 增加非空 username,phone 改为可空。 +7. im_auth 删除数据库字段 scheme 和 uname,只按 userid 保存 Basic 密码凭据。 + +## 2. 核心业务定义 + +| 名称 | 定义 | +| --- | --- | +| 用户名输入 | 注册页面独立的用户名输入框 | +| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 | +| 选择优先级 | 手机号非空时优先走手机号方式;只有手机号为空时才使用用户名 | +| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 | +| 用户名注册 | 不产生已验证 phone,依靠有效邀请码放行的注册 | +| 手机号注册 | 将规范化手机号同时写入 username 和 phone;短信验证状态由 tel credential 区分 | +| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 | +| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 | + +## 3. 用户账号数据规则 + +### 3.1 im_users 字段 + +目标字段: + +| 字段 | 约束 | 说明 | +| --- | --- | --- | +| username | 非空、租户内唯一 | 主登录账号;普通用户名必须匹配 `^[a-z0-9]{1,10}$`,手机号方式保存 E.164 号码 | +| phone | 可空、租户内唯一 | 手机号方式的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 | + +落库规则: + +| 注册方式 | username | phone | +| --- | --- | --- | +| 手机号验证注册 | +8613800138000 | +8613800138000 | +| 普通用户名加邀请码注册 | alice | NULL | +| 短信关闭,手机号加邀请码注册 | +8613800138000 | +8613800138000 | + +普通用户名注册规则: + +1. 必须匹配 `^[a-z0-9]{1,10}$`,长度为 1~10 位。 +2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。 +3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。 +4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。 +5. 该规则只约束用户填写的普通用户名。手机号方式写入 `username` 的 E.164 号码,以及存量迁移生成的内部保留账号,不适用此正则,也不能作为普通用户名注册。 + +### 3.2 选择优先级和手机号规范化 + +1. 页面分别展示用户名输入框和手机号输入框。 +2. 手机号字段非空时,无论用户名是否填写,服务端都选择手机号方式并忽略用户名输入。 +3. 只有手机号字段为空时,服务端才使用用户名方式。 +4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。 +5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。 +6. 手机号方式将规范化手机号同时写入 username 和 phone。 +7. 用户名方式只写 username,phone 保持 NULL。 +8. 不提供国家和区号选择,本期固定中国 +86。 +9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。 +10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。 +11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;手机号字段非空时忽略用户名输入,不因同时填写的用户名格式错误而拒绝手机号注册。 + +## 4. 注册放行规则 + +### 4.1 决策表 + +| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 | +| --- | --- | --- | --- | --- | --- | +| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,按用户名注册,phone=NULL | +| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 | +| 任意 | 11 位 | 开 | 有 | 未填 | 放行,按手机号注册,手机号覆盖用户名输入 | +| 任意 | 11 位 | 开 | 有 | 有效 | 放行,按手机号注册并记录邀请码使用关系 | +| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 | +| 任意 | 11 位 | 关 | 无 | 有效 | 放行,按手机号注册,username 和 phone 均写手机号 | +| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 | +| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 | +| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 | + +补充规则: + +1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。 +2. 短信开关开启时,邀请码不能绕过手机号验证。 +3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。 +4. 手机号验证注册时邀请码可不填;用户名方式以及短信关闭的手机号方式必须填写有效邀请码。 +5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。 +6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。 + +### 4.2 客户端页面行为 + +1. 页面分别显示“请输入用户名”和“请输入手机号”。 +2. 手机号非空时以手机号方式为准,用户名输入不参与注册。 +3. 手机号为空时才使用用户名方式。 +4. 手机号为 11 位数字时允许点击“获取验证码”。 +5. 用户名方式不发送短信,验证码字段不作为提交项。 +6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。 +7. 短信开关关闭时,发码接口仍返回正常倒计时,前端进入与开关开启时相同的倒计时状态。 +8. 前端不自行决定最终是否可以免验证码注册,最终放行完全由服务端判断。 +9. 用户名方式注册前提示:未绑定手机号时无法自助找回密码,只能联系企业管理员重置。 + +## 5. 注册短信发送开关 + +### 5.1 HotGo 配置 + +HotGo 后台增加全局配置: + +~~~text +group = im +key = register_sms_enabled +type = bool +value = 1 / 0 +~~~ + +实现约定: + +1. 管理界面的开关选项复用 HotGo 已有 sys_switch 字典。 +2. 当前开关值保存到 hg_sys_config;字典只负责提供开/关选项,不作为配置值真源。 +3. 只有平台管理员可以修改。 +4. 修改操作写 HotGo 后台审计日志。 +5. IM 服务端在可控缓存时间内读取最新值。 +6. 本期为全局开关;未来需要按企业控制时再迁移到 im_tenant_config。 + +### 5.2 开关开启 + +1. 服务端生成强随机验证码。 +2. 创建 register 场景挑战并执行限频。 +3. 调用短信服务商发送验证码。 +4. 短信服务商失败时返回稳定错误 SMS_DELIVERY_FAILED。 +5. 禁止继续使用固定测试码 123456。 + +### 5.3 开关关闭 + +1. 发码接口仍创建限频挑战。 +2. 返回 challenge_id、expires_in_seconds 和 resend_after_seconds。 +3. 不调用短信服务商。 +4. 不生成客户端可猜测或可验证的固定验证码。 +5. 前端仍正常显示倒计时,但用户不会收到短信。 +6. 记录内部 delivery_suppressed 审计事件,不向客户端暴露开关状态。 + +## 6. 邀请码管理 + +### 6.1 基本规则 + +1. 邀请码按租户隔离,不能跨企业使用。 +2. 邀请码固定为 5 位 ASCII 大小写英文字母,格式为 `^[A-Za-z]{5}$`,不包含数字和符号。 +3. 邀请码区分大小写,`AbCdE` 与 `abcde` 是两个不同的邀请码;客户端和服务端不得自动转换大小写。 +4. 邀请码由服务端使用密码学安全随机数从 `A-Z`、`a-z` 字符集中生成,不能使用递增数字、用户 ID 或批次 ID。 +5. 同一租户内邀请码不能重复,数据库唯一索引是最终约束;单个或批量生成发生冲突时必须重新生成,不得返回重复码。 +6. 邀请码以明文落库,并使用完整明文进行大小写敏感的精确查询和注册校验。 +7. 每个邀请码在有效期内可以成功注册多个账号,不设置使用次数上限。 +8. 管理员创建时自行设置 expires_at,必须晚于当前时间。 +9. 状态包括 active、expired、revoked,不存在 used 状态。 +10. expired、revoked 状态不可恢复为 active。 +11. 邀请码校验必须同时检查租户、完整明文、状态和到期时间,不因已有其他用户使用记录而失效。 + +### 6.2 单个生成 + +请求示例: + +~~~json +{ + "expires_at": "2026-09-01T00:00:00+08:00", + "remark": "新员工入职" +} +~~~ + +服务端返回一个 5 位邀请码。具备邀请码查看权限的租户管理员可在后续列表和详情中查看完整明文。 + +### 6.3 批量生成 + +请求示例: + +~~~json +{ + "quantity": 100, + "expires_at": "2026-09-01T00:00:00+08:00", + "remark": "2026年8月新员工入职" +} +~~~ + +规则: + +1. 一次批量生成共用 batch_id 和 expires_at。 +2. 每个邀请码均为独立生成的 5 位大小写英文字母,租户内唯一且区分大小写。 +3. 批量请求必须支持幂等键,重试不能生成重复批次。 +4. 单批数量由后台配置限制,建议默认上限 1000。 +5. 生成成功后支持明文导出;后续再次查询或导出必须校验邀请码查看/导出权限并写审计。 + +### 6.4 查询、撤销和审计 + +管理后台支持: + +1. 按完整邀请码进行大小写敏感的精确查询。 +2. 按状态、批次、创建人、创建时间、到期时间查询。 +3. 列表和详情返回邀请码明文,接口必须校验当前租户的邀请码查看权限。 +4. 单个撤销仍处于 active 的邀请码;撤销不删除历史使用记录。 +5. 按 batch_id 批量撤销仍处于 active 的邀请码。 +6. 查看邀请码的累计使用次数、使用用户、使用时间和请求 ID。 +7. 所有生成、查询、查看、导出、撤销操作写后台审计日志。 + +### 6.5 HotGo 管理接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | /imInvite/create | 单个生成 | +| POST | /imInvite/batchCreate | 批量生成 | +| POST | /imInvite/list | 分页或明文精确查询 | +| POST | /imInvite/revoke | 单个撤销 | +| POST | /imInvite/batchRevoke | 批量撤销 | +| GET | /imInvite/batchView | 批次详情 | +| POST | /imInvite/export | 按过滤条件导出 CSV | + +HotGo 根据可信登录上下文取得 `im_tenant.id`,通过 `im_tenant.db_dsn/db_name` 选择租户业务库并直接维护邀请码管理数据。浏览器不能提交可信 DSN 或决定实际租户库。 + +IM 不再提供 `/admin/v1/im/*` HMAC 管理接口。注册校验和 `im_registration_invite_uses` 写入仍由 IM 在用户注册事务中完成,HotGo 不参与注册事务。 + +管理员重置密码时,HotGo 在同一个租户库事务中更新 `im_auth.secret`、递增 `im_user_security.auth_version`,并写入 `im_business_events_outbox` 的 `security.password_reset` 事件。IM 当前轮询此事件并清退在线会话;后续迁移 Kafka 时保持事件类型和 payload 不变,只替换投递与消费通道。 + +## 7. 客户端注册协议 + +### 7.1 发码 + +~~~http +POST /v1/im/auth/verification-codes +~~~ + +~~~json +{ + "scene": "register", + "phone": "13800138000" +} +~~~ + +后端补 +86 后发送并保存手机号哈希。开关关闭时返回字段和倒计时保持一致。 + +### 7.2 提交注册 + +在现有 WebSocket acc 消息中增加 username、phone 和 invite_code: + +~~~json +{ + "acc": { + "id": "acc-1", + "user": "new", + "scheme": "basic", + "username": "alice", + "phone": "", + "secret": "base64(account:password)", + "invite_code": "AbCdE", + "login": true, + "cred": [] + } +} +~~~ + +用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。 + +手机号方式时,phone 非空,secret 使用 phone:password,服务端忽略同时提交的 username。短信验证成功时 cred 传递一次性 verification_token;短信关闭时 cred 为空,必须提供有效 invite_code。两种手机号路径都把规范化手机号同时保存到 username 和 phone;短信关闭路径不建立已完成的 tel credential。 + +服务端必须校验 secret 中的登录名与最终选择值一致:phone 非空时必须匹配规范化手机号,phone 为空时必须匹配 username。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。 + +客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。 + +## 8. im_auth 改造 + +### 8.1 改造边界 + +当前只有 Basic 密码凭据写入 im_auth: + +1. token 是无状态签名,不写 im_auth。 +2. code 使用临时缓存,不写 im_auth。 +3. anonymous 不保存凭据。 +4. rest 调用外部认证服务。 + +因此数据库中的 scheme=basic 是冗余信息。登录账号真源迁移到 im_users 后,uname 和 basic: 前缀同样冗余。 + +协议层仍保留 acc.scheme 和 login.scheme,用于选择 basic 或 token 认证处理器。本次只删除数据库字段。 + +这里删除的 uname 仅指 `im_auth.uname` 数据库列。Basic 协议仍从 `secret` 中解析登录账号和密码,登录账号改为查询 `im_users.username` 或 `im_users.phone`;除非后续同时改造客户端和协议消息结构,否则 `account:password` 格式及用户名禁止冒号的技术约束继续保留。 + +### 8.2 目标表结构 + +~~~sql +CREATE TABLE im_auth ( + id INT NOT NULL AUTO_INCREMENT, + userid BIGINT NOT NULL, + authlvl INT NOT NULL, + secret VARCHAR(255) NOT NULL, + expires DATETIME, + PRIMARY KEY (id), + UNIQUE INDEX auth_userid (userid), + CONSTRAINT fk_im_auth_user + FOREIGN KEY (userid) REFERENCES im_users(id) +); +~~~ + +变更内容: + +1. 删除 scheme。 +2. 删除 uname。 +3. 删除 auth_uname 和 auth_userid_scheme。 +4. 增加 UNIQUE(userid)。 +5. 保留 authlvl,以兼容普通用户和 ROOT 账号。 +6. 保留 secret 和 expires。 + +### 8.3 登录和密码处理 + +1. Basic 登录先规范化账号输入。 +2. 先从 im_users.username 查询 userid。 +3. 未命中且输入为 11 位数字时,补 +86 后从 im_users.phone 查询 userid。 +4. 按 userid 从 im_auth 读取 bcrypt 密码摘要。 +5. 修改密码、重置密码、删除认证和 ROOT 提升都按 userid 操作。 +6. Store 和 Adapter 接口不再传递 scheme、unique 或 basic: 前缀。 + +### 8.4 密码找回 + +1. im_users.phone 非空的账号可以使用短信找回密码。 +2. 找回流程向 im_users.phone 发送 reset_password 场景验证码,成功消费验证码后才允许设置新密码。 +3. 如果该手机号注册时未建立完成状态的 tel credential,成功完成密码找回验证后可以补建该凭证。 +4. im_users.phone 为 NULL 的用户名账号不提供短信或其他自助找回方式。 +5. 无手机号账号忘记密码后,只能联系当前租户管理员执行后台密码重置。 +6. 管理员重置密码必须递增 auth_version、撤销旧 Token 和在线 Session,并写安全审计。 +7. 管理员只能重置当前租户用户,不能通过用户名跨租户查找或操作。 + +### 8.5 密码与登录 Token + +1. 客户端按现有 Basic 协议提交 `base64(account + ":" + password)`。Base64 只是协议编码,不是密码加密;账号注册和登录必须使用 HTTPS/WSS。 +2. 服务端解析出密码后使用 bcrypt 单向哈希,当前成本参数为 `bcrypt.DefaultCost=10`;`im_auth.secret` 只保存 bcrypt 哈希,不保存可逆密文或明文。 +3. 登录 Token 继续沿用现有 Tinode HMAC 签名 Token,默认有效期为 14 天,服务端不保存 Token 明文。 +4. 客户端保存登录响应中的 `params.token` 和 `params.expires`,Token 必须放入 iOS Keychain 或 Android Keystore,不得写入普通数据库、日志或埋点。 +5. 每次 Basic 登录或有效 Token 登录成功后,服务端都返回新的 14 天 Token;客户端必须用新 Token 覆盖旧 Token。 +6. 当前不增加 Refresh Token 和独立刷新接口。Token 未过期时可通过 `login.scheme=token` 登录并取得新 Token;Token 已过期或校验失败时必须重新进行账号密码登录。 +7. Token 内绑定 `tenant_id`、`uid`、`auth_version` 和过期时间。密码重置、管理员重置或账号安全状态变化后递增 `auth_version`,使旧 Token 失效。 + +## 9. 数据库设计 + +### 9.1 邀请码批次 + +~~~sql +CREATE TABLE im_registration_invite_batches ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + quantity INT UNSIGNED NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key) +); +~~~ + +### 9.2 邀请码 + +~~~sql +CREATE TABLE im_registration_invite_codes ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + batch_id BIGINT, + code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) NOT NULL, + expires_at DATETIME(3) NOT NULL, + usage_count INT UNSIGNED NOT NULL DEFAULT 0, + last_used_at DATETIME(3), + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code), + INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at), + INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id) +); +~~~ + +### 9.3 邀请码使用记录 + +~~~sql +CREATE TABLE im_registration_invite_uses ( + id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + used_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + INDEX idx_im_registration_invite_use_code (invite_code_id), + UNIQUE INDEX uk_im_registration_invite_use_user (user_id) +); +~~~ + +`im_registration_invite_batches` 和 `im_registration_invite_codes` 位于 HotGo 控制库,使用 `tenant_id` 隔离租户,便于平台统一查询、导出和管理。`usage_count`、`last_used_at` 是租户使用明细的查询投影,不是使用事实源。 + +`im_registration_invite_uses` 位于对应租户业务库,是邀请码使用事实源。注册成功时,IM 在同一租户库事务中写入使用明细和 `registration.invite_used` outbox 事件;当前由 IM 数据库轮询器按该邀请码的租户明细重新聚合并覆盖控制库统计,重试不会重复计数。后续切换 Kafka 时保持事件类型和 payload 不变。三张表的 BIGINT 主键均使用服务端雪花 ID。 + +邀请码以明文保存在 HotGo 控制库中。`ascii_bin` 保证查询和唯一索引区分大小写;所有校验必须同时使用 `tenant_id` 和完整 5 位邀请码精确匹配,禁止 `LOWER(code)`、`UPPER(code)` 或模糊查询。 + +## 10. 主要错误码 + +| 业务码 | 含义 | +| --- | --- | +| ACCOUNT_REQUIRED | 账号输入为空 | +| ACCOUNT_POLICY_VIOLATION | 账号违反最低技术约束 | +| ACCOUNT_ALREADY_EXISTS | 用户名或手机号命名空间冲突 | +| PHONE_VERIFICATION_REQUIRED | 手机号注册缺少有效验证票据 | +| INVITE_CODE_REQUIRED | 用户名注册缺少邀请码 | +| INVITE_CODE_INVALID | 邀请码不存在 | +| INVITE_CODE_EXPIRED | 邀请码已过期 | +| INVITE_CODE_REVOKED | 邀请码已撤销 | +| INVITE_CODE_RATE_LIMITED | 邀请码校验过于频繁 | +| SMS_DELIVERY_FAILED | 短信服务商发送失败 | +| USERNAME_INVALID_FORMAT | 用户名不是 1~10 位小写英文字母或数字 | + +邀请码无效、过期、已撤销可对客户端统一显示“邀请码无效”,详细原因仅写安全审计,降低枚举风险。 + +## 11. 存量迁移 + +采用“扩容、回填、切换、收缩”方式: + +1. 为 im_users 增加可空 username。 +2. 去除 im_auth.uname 中的 basic: 前缀后回填 im_users.username。 +3. 将现有 phone 统一规范化为 E.164。 +4. 对无 Basic 认证记录的内部、已删除或异常用户生成 reserved_,并输出迁移报告供人工复核。 +5. 检查重复 username、重复 phone 和跨字段冲突。 +6. 新版本短期双读旧 im_auth 和新 im_users 账号字段。 +7. 切换注册、登录、修改密码、密码重置和 ROOT 工具链。 +8. 将 username 改为非空并增加唯一索引。 +9. 观察期结束后删除 im_auth.scheme、im_auth.uname 和旧索引。 + +回滚时在观察期内继续保留旧列和旧读路径,不允许通过重新拼接 basic: 猜测恢复数据。 + +## 12. 安全与已知风险 + +1. 5 位邀请码属于短授权码,必须使用密码学安全随机数生成、设置有效期、可撤销并可审计;注册校验必须严格限频以降低枚举风险。邀请码允许多人使用意味着泄露后的影响面更大,应缩短有效期并及时撤销异常邀请码。 +2. 邀请码校验按租户、IP、设备和账号输入限频。 +3. 邀请码虽按业务要求明文落库和授权查询,但不得写入普通日志、埋点或错误响应;密码、短信验证码和 verification_token 同样禁止记录。 +4. 邀请码使用记录、用户创建、票据消费和租户配额扣减应在同一事务完成;邀请码本身不核销、不切换为 used。 +5. 短信开关关闭时不能保留固定测试验证码。 +6. 批量生成、批量导出和批量撤销必须限制数量并单独授权。 + +### 12.1 邀请码手机号注册风险 + +短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号同时写入 username 和 phone。 + +为避免把“已填写手机号”和“已验证手机号”混为一谈: + +1. im_users.phone 保存手机号方式注册时的号码。 +2. 只有成功消费 verification_token 时才建立完成状态的 tel credential。 +3. 短信关闭加邀请码注册不建立完成状态的 tel credential。 +4. phone 非空账号可以通过 reset_password 短信挑战证明当前持有手机号后自助重置密码。 +5. phone=NULL 的账号不能自助找回密码,只能由当前租户管理员重置。 +6. 其他要求“已验证手机号”的敏感操作必须检查 tel credential,不能只检查 phone 非空。 +7. 后续完成短信验证后,可为同一用户补建 tel credential。 +8. 邀请码不证明手机号所有权,允许该路径意味着平台接受手机号被提前占用的业务风险。 + +## 13. 验收用例 + +### 13.1 注册 + +1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 和 phone 均为规范化手机号。 +2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。 +3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 和 phone 均为规范化手机号。 +4. 只填写手机号,短信关闭且不填邀请码不能注册。 +5. 只填写用户名并提供有效邀请码可以注册,phone=NULL。 +6. 只填写用户名但不填邀请码不能注册。 +7. 用户名和手机号同时填写时走手机号方式,username 最终保存规范化手机号。 +8. 手机号非空但格式错误时拒绝,不能回退到用户名方式。 +9. 用户名和手机号同时为空时拒绝注册。 +10. 过期、已撤销邀请码均不能注册。 +11. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。 +12. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。 +13. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。 +14. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。 +15. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。 +16. 用户名格式不合法但手机号合法且非空时,仍按手机号方式注册并忽略用户名输入。 + +### 13.2 短信开关 + +1. 开关关闭后发码接口不调用短信服务商。 +2. 开关关闭后发码接口仍返回正常倒计时。 +3. 开关从关闭改为开启后,新请求恢复调用短信服务商。 +4. 开关修改在规定缓存时间内对 IM 服务生效。 + +### 13.3 邀请码管理 + +1. 单个生成可设置有效期。 +2. 单个和批量生成的邀请码均匹配 `^[A-Za-z]{5}$`,不包含数字或符号。 +3. 批量生成数量正确,同一租户所有邀请码互不重复;并发生成冲突时由唯一索引拦截并重试。 +4. `AbCdE` 和 `abcde` 按两个不同邀请码精确查询和校验,不得自动转换大小写。 +5. 有权限的管理员可以按完整邀请码查询,并在列表、详情和导出中取得明文。 +6. 批量请求重试不会生成第二批数据。 +7. 单个和批量撤销立即生效,历史使用用户不受影响。 +8. 无权限用户不能查看、查询或导出邀请码明文。 +9. 管理员可以查看批次使用统计和单码使用记录。 + +### 13.4 认证回归 + +1. 存量 Basic 用户迁移后可使用原账号和密码登录。 +2. 登录、注册、修改密码、密码重置和 ROOT 提升不再依赖 im_auth.scheme、im_auth.uname 或 basic: 前缀。 +3. 同一 username 或 phone 在不同租户仍可独立注册。 +4. im_auth 每个 userid 最多保留一条记录。 + +## 14. 评审结论 + +本方案的核心决策为: + +1. 注册页的用户名和手机号是两个独立输入框,手机号非空时优先使用手机号方式。 +2. username 是所有用户非空的主登录账号。 +3. 手机号方式同时写 username 和 phone,用户名方式只写 username。 +4. 用户名和手机号同时填写时忽略用户名输入,以手机号方式为准。 +5. 短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册,但不建立已完成的 tel credential。 +6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。 +7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。 +8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。 +9. phone=NULL 的用户名账号不能自助找回密码,只能由租户管理员重置。 +10. HotGo 与 IM 当前以共享租户业务库集成,不再使用内部 HMAC 管理 HTTP;跨进程安全事件先经数据库 outbox,后续迁移 Kafka。 + +评审通过后,实施工作拆分为数据库迁移、IM 注册与认证、HotGo 后台、客户端对接和全量回归五个任务。 diff --git a/docs/IM-系统设计文档.md b/docs/IM-系统设计文档.md index a67057c..e885405 100644 --- a/docs/IM-系统设计文档.md +++ b/docs/IM-系统设计文档.md @@ -236,7 +236,9 @@ HTTP API 通用规则: MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。 -用户身份字段约定:`im_users.phone` 是注册、密码找回和敏感操作验证使用的手机号真源,新注册必须提供已验证手机号;历史无手机号账号迁移后允许暂时为 `NULL`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。 +用户身份字段约定:`im_users.username` 是非空的主登录账号,`im_users.phone` 是手机号方式的注册号码,允许为 `NULL`;是否完成短信验证由已完成的 `tel credential` 判断。用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`,服务端按原始输入校验;手机号方式写入的 E.164 账号和迁移生成的内部保留账号属于系统值例外。注册页面分别展示用户名和手机号,手机号非空时优先走手机号方式并忽略用户名输入:规范化后的 E.164 手机号同时写入 `username` 和 `phone`;只有手机号为空时才使用用户名方式并只写 `username`。短信关闭时,手机号输入搭配有效邀请码仍按手机号方式注册并写两个字段,但不建立已完成的 `tel credential`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。 + +注册邀请码固定为 5 位 ASCII 大小写英文字母,使用密码学安全随机数生成并区分大小写。邀请码明文存入租户业务库,`code` 字段使用 `ascii_bin` 排序规则和唯一索引;注册时按完整明文精确查询,不允许大小写转换或模糊匹配。邀请码查看和导出必须授权并审计,普通日志不得记录邀请码。 ### 7.3 企业码与登录流程 @@ -250,6 +252,8 @@ MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键 验证码按 `register`、`login`、`reset_password`、`sensitive_action` 用途隔离,相同验证码不能跨用途使用。数据库只保存验证码强哈希和手机号规范化哈希,明文只在发送时短暂存在。 +`im_users.phone` 非空的账号可以通过 `reset_password` 短信挑战自助找回密码;成功验证即证明当前持有该号码,不要求注册时已经存在完成状态的 `tel credential`,并可在重置成功后补建凭证。`phone=NULL` 的用户名账号不提供自助找回,只能由当前租户管理员重置。管理员不得跨租户操作,重置行为必须进入安全审计。 + 密码重置必须在同一事务中完成: 1. 消费有效验证码或二次验证挑战。 diff --git a/docs/images/注册页面.png b/docs/images/注册页面.png index 025a6a0..ee93d31 100644 Binary files a/docs/images/注册页面.png and b/docs/images/注册页面.png differ diff --git a/docs/images/注册页面2.png b/docs/images/注册页面2.png new file mode 100644 index 0000000..025a6a0 Binary files /dev/null and b/docs/images/注册页面2.png differ diff --git a/server/auth/auth.go b/server/auth/auth.go index fd22df0..01f753c 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -278,7 +278,7 @@ type AuthHandler interface { // continue the authentication process to the next step, or return an error code. // The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for // additional validation. The stock authenticators don't use it. - // store.Users.GetAuthRecord("scheme", "unique") + // The selected handler resolves its account through the tenant-scoped auth store. // Returns: user auth record, challenge, error. Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error) diff --git a/server/auth/basic/auth_basic.go b/server/auth/basic/auth_basic.go index 87946c3..b8c42df 100644 --- a/server/auth/basic/auth_basic.go +++ b/server/auth/basic/auth_basic.go @@ -17,7 +17,7 @@ import ( // Define default constraints on login and password const ( - defaultMinLoginLength = 2 + defaultMinLoginLength = 1 defaultMaxLoginLength = 32 defaultMinPasswordLength = 3 @@ -26,7 +26,7 @@ const ( // Token suitable as a login: either an E.164 phone number or a name which starts // and ends with a Unicode letter (class L) or number (class N), and contains // Unicode letters, numbers, dot and underscore. -var loginPattern = regexp.MustCompile(`^(\+[\pN]+|[\pL\pN][_.\pL\pN]*[\pL\pN]+)$`) +var loginPattern = regexp.MustCompile(`^(\+[0-9]+|[\pL\pN](?:[_.\pL\pN]*[\pL\pN])?)$`) // authenticator is the type to map authentication methods to. type authenticator struct { @@ -143,7 +143,7 @@ func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret [] authLevel = auth.LevelAuth } - err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires) + err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, passhash, expires) if err != nil { return nil, err } @@ -165,7 +165,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret return nil, err } - login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name) + login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid) if err != nil { return nil, err } @@ -179,7 +179,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret uname = login } else if err = a.checkLoginPolicy(uname); err != nil { return nil, err - } else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil { + } else if uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname); err != nil { return nil, err } else if !uid.IsZero() { // The (new) user name already exists. Report an error. @@ -198,7 +198,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret if rec.Lifetime > 0 { expires = types.TimeNow().Add(time.Duration(rec.Lifetime)) } - err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires) + err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, uname, passhash, expires) if err != nil { return nil, err } @@ -228,7 +228,7 @@ func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth return nil, nil, err } - uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname) + uid, authLvl, passhash, expires, err := store.Users.GetAuthByLogin(ctx.TenantID, uname) if err != nil { return nil, nil, err } @@ -287,7 +287,7 @@ func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, err return false, err } - uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname) + uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname) if err != nil { return false, err } @@ -305,7 +305,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) { // DelRecords deletes saved authentication records of the given user. func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error { - return store.Users.DelAuthRecords(tenantID, uid, a.name) + return store.Users.DeleteAuthRecord(tenantID, uid) } // RestrictedTags returns tag namespaces (prefixes) restricted by this adapter. @@ -319,7 +319,7 @@ func (a *authenticator) RestrictedTags() ([]string, error) { // GetResetParams returns authenticator parameters passed to password reset handler. func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) { - login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name) + login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid) if err != nil { return nil, err } diff --git a/server/auth/basic/auth_basic_test.go b/server/auth/basic/auth_basic_test.go index d1d692c..63062bb 100644 --- a/server/auth/basic/auth_basic_test.go +++ b/server/auth/basic/auth_basic_test.go @@ -26,9 +26,9 @@ func TestAuthenticateScopesLoginByTenant(t *testing.T) { if err != nil { t.Fatal(err) } - users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice"). + users.EXPECT().GetAuthByLogin(types.TenantID(7), "alice"). Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil) - users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice"). + users.EXPECT().GetAuthByLogin(types.TenantID(8), "alice"). Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil) a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength, @@ -73,7 +73,7 @@ func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) { }) users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth, - "basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil) + gomock.Any(), time.Time{}).Return(nil) a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength, minPasswordLength: 6} diff --git a/server/datamodel.go b/server/datamodel.go index c121e22..36c2399 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -146,6 +146,12 @@ type MsgClientAcc struct { Scheme string `json:"scheme,omitempty"` // Shared secret Secret []byte `json:"secret,omitempty"` + // Registration login name. Ignored when Phone is provided. + Username string `json:"username,omitempty"` + // Registration mobile number. An 11-digit mainland China number takes precedence over Username. + Phone string `json:"phone,omitempty"` + // Optional case-sensitive registration invitation code. + InviteCode string `json:"invite_code,omitempty"` // Authenticate session with the newly created account Login bool `json:"login,omitempty"` // Indexable tags for user discovery diff --git a/server/db/adapter.go b/server/db/adapter.go index e0d29d2..ba244be 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -216,6 +216,11 @@ type TenantAdapter interface { TenantGetByID(id t.TenantID) (*t.Tenant, error) } +// RegistrationConfigAdapter reads global registration policy from the control database. +type RegistrationConfigAdapter interface { + RegistrationSMSEnabled() (bool, error) +} + // TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the // "one tenant, one database" deployment model. type TenantRoutedAdapter interface { @@ -233,15 +238,29 @@ type TenantHTTPInfrastructureAdapter interface { TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error } +// TenantRegistrationInviteAdapter stores invitation definitions in the +// registry database and usage details in the selected tenant database. +type TenantRegistrationInviteAdapter interface { + TenantRegistrationInviteCreate(t.TenantID, *t.RegistrationInviteCode) error + TenantRegistrationInviteCreateBatch(t.TenantID, *t.RegistrationInviteBatch, []t.RegistrationInviteCode) error + TenantRegistrationInviteGetByCode(t.TenantID, string) (*t.RegistrationInviteCode, error) + TenantRegistrationInviteList(t.TenantID, t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error) + TenantRegistrationInviteBatchGet(t.TenantID, int64) (*t.RegistrationInviteBatch, error) + TenantRegistrationInviteBatchGetByIdempotency(t.TenantID, string) (*t.RegistrationInviteBatch, error) + TenantRegistrationInviteRevoke(t.TenantID, int64, time.Time) error + TenantRegistrationInviteBatchRevoke(t.TenantID, int64, time.Time) (int, error) + TenantRegistrationInviteRecordUse(t.TenantID, string, t.Uid, string, time.Time) error + TenantRegistrationInviteSyncUsage(t.TenantID, int64) error +} + // TenantAuthAdapter is the tenant-aware authentication and credential capability. // It is separate from Adapter while the remaining persistence domains are migrated. type TenantAuthAdapter interface { - TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error) - TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) - TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level, - secret []byte, expires time.Time) error - TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error - TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level, + TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error) + TenantAuthGetRecord(tenantID t.TenantID, user t.Uid) (string, auth.Level, []byte, time.Time, error) + TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error + TenantAuthDeleteRecord(tenantID t.TenantID, user t.Uid) error + TenantAuthUpdateRecord(tenantID t.TenantID, user t.Uid, username string, authLvl auth.Level, secret []byte, expires time.Time) error TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error) @@ -263,6 +282,7 @@ type TenantBusinessAdapter interface { TenantUserCreate(t.TenantID, *t.User) error TenantUserGet(t.TenantID, t.Uid) (*t.User, error) TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error) + TenantUserGetByPhone(t.TenantID, string) (t.Uid, error) TenantUserDelete(t.TenantID, t.Uid, bool) error TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error) diff --git a/server/db/common/test_data/test_data.go b/server/db/common/test_data/test_data.go index d487026..2b8413b 100644 --- a/server/db/common/test_data/test_data.go +++ b/server/db/common/test_data/test_data.go @@ -30,6 +30,7 @@ func initUsers(now time.Time) []*types.User { Id: "3ysxkod5hNM", }, UserAgent: "SomeAgent v1.2.3", + Username: "alice", Tags: []string{"alice"}, }) users = append(users, &types.User{ @@ -37,6 +38,7 @@ func initUsers(now time.Time) []*types.User { Id: "9AVDamaNCRY", }, UserAgent: "Tinode Web v111.222.333", + Username: "bob", Tags: []string{"bob"}, }) users = append(users, &types.User{ @@ -44,6 +46,7 @@ func initUsers(now time.Time) []*types.User { Id: "0QLrX3WPS2o", }, UserAgent: "Tindroid v1.2.3", + Username: "carol", Tags: []string{"carol"}, }) for _, user := range users { diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index b0129e4..1c007ca 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -239,6 +239,15 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { return errors.New("mysql adapter failed to open tenant registry database: " + err.Error()) } } + if a.registryDB != nil || !databaseMissing { + controlDB := a.db + if a.registryDB != nil { + controlDB = a.registryDB + } + if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil { + return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error()) + } + } if !databaseMissing { if err = ensureIMGroupBusinessTables(a.db); err != nil { return errors.New("mysql adapter failed to ensure group business schema: " + err.Error()) @@ -252,6 +261,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { if err = ensureUserIdentitySchema(a.db); err != nil { return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error()) } + if err = ensureIMRegistrationInviteUseTable(a.db); err != nil { + return errors.New("mysql adapter failed to ensure registration invite usage schema: " + err.Error()) + } } } return err @@ -454,8 +466,17 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } + if err = ensureIMRegistrationInviteUseTable(db); err != nil { + db.Close() + return nil, nil, err + } + registryDB := a.registryDB + if registryDB == nil { + registryDB = a.db + } tenantAdp := &adapter{ db: db, + registryDB: registryDB, dsn: mysqlDefaultString(dsn, a.dsn), dbName: resolvedDBName, maxResults: a.maxResults, @@ -629,6 +650,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_users( id BIGINT NOT NULL, + username VARCHAR(32) NOT NULL, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -640,6 +662,7 @@ func (a *adapter) CreateDb(reset bool) error { public JSON, trusted JSON, PRIMARY KEY(id), + UNIQUE INDEX users_username(username), UNIQUE INDEX users_phone(phone), INDEX users_state_stateat(state, stateat), INDEX users_lastseen_updatedat(lastseen, updatedat) @@ -667,6 +690,14 @@ func (a *adapter) CreateDb(reset bool) error { if err = createIMNotificationTables(tx); err != nil { return err } + if err = ensureIMRegistrationInviteUseTable(tx); err != nil { + return err + } + if a.registryDB == nil { + if err = ensureIMRegistrationInviteControlTables(tx); err != nil { + return err + } + } // Indexed devices. Normalized into a separate table. if _, err = tx.Exec( @@ -689,16 +720,13 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_auth( id INT NOT NULL AUTO_INCREMENT, - uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, - scheme VARCHAR(16) NOT NULL, authlvl INT NOT NULL, secret VARCHAR(255) NOT NULL, expires DATETIME, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES im_users(id), - UNIQUE INDEX auth_uname(uname), - UNIQUE INDEX auth_userid_scheme(userid, scheme) + UNIQUE INDEX auth_userid(userid) )`); err != nil { return err } @@ -1659,7 +1687,7 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e // UserCreate creates a new user. Returns error and true if error is due to duplicate user name, // false for any other error func (a *adapter) UserCreate(user *t.User) error { - if user == nil { + if user == nil || strings.TrimSpace(user.Username) == "" { return t.ErrMalformed } ctx, cancel := a.getContextForTx() @@ -1691,8 +1719,9 @@ func (a *adapter) UserCreate(user *t.User) error { user.Phone = canonicalPhone phone = canonicalPhone } - if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)", + if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)", decoded_uid, + user.Username, user.CreatedAt, user.UpdatedAt, user.State, @@ -1700,6 +1729,9 @@ func (a *adapter) UserCreate(user *t.User) error { phone, common.ToJSON(user.Public), common.ToJSON(user.Trusted)); err != nil { + if isDupe(err) { + return t.ErrDuplicate + } return err } @@ -1712,46 +1744,57 @@ func (a *adapter) UserCreate(user *t.User) error { return tx.Commit() } -// TenantAuthAddRecord adds an authentication record scoped to a tenant. -func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string, +// TenantAuthAddRecord adds the user's Basic authentication record. +func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { - return tenantAdp.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires) + return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires) } - return a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires) + var username string + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil { + if err == sql.ErrNoRows { + return t.ErrNotFound + } + return err + } + return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires) } -// TenantAuthDelScheme deletes an authentication scheme only in the selected tenant. -func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error { +// TenantAuthDeleteRecord deletes the Basic authentication record. +func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { - return tenantAdp.AuthDelScheme(uid, scheme) + return tenantAdp.TenantAuthDeleteRecord(tenantID, uid) } - return a.AuthDelScheme(uid, scheme) + return a.AuthDelScheme(uid, "basic") } -// TenantAuthUpdRecord updates an authentication record only in the selected tenant. -func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string, +// TenantAuthUpdateRecord updates the Basic authentication record and optional username. +func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, username string, authLvl auth.Level, secret []byte, expires time.Time) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { - return tenantAdp.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires) + return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires) } - return a.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires) + return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires) } -// TenantAuthGetRecord retrieves a user's authentication record in one tenant. -func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string, +// TenantAuthGetRecord retrieves a user's Basic authentication record. +func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string, auth.Level, []byte, time.Time, error) { var expires time.Time tenantAdp, err := a.routedTenantAdapter(tenantID) @@ -1759,13 +1802,13 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str return "", auth.LevelNone, nil, expires, t.ErrMalformed } if tenantAdp != a { - return tenantAdp.AuthGetRecord(uid, scheme) + return tenantAdp.TenantAuthGetRecord(tenantID, uid) } - return a.AuthGetRecord(uid, scheme) + return a.AuthGetRecord(uid, "basic") } -// TenantAuthGetUniqueRecord retrieves an authentication record by tenant-local login. -func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, +// TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login. +func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error) { var expires time.Time tenantAdp, err := a.routedTenantAdapter(tenantID) @@ -1773,9 +1816,9 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed } if tenantAdp != a { - return tenantAdp.AuthGetUniqueRecord(unique) + return tenantAdp.TenantAuthGetByLogin(tenantID, login) } - return a.AuthGetUniqueRecord(unique) + return a.AuthGetUniqueRecord(login) } // Add user's authentication record @@ -1791,8 +1834,17 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L defer cancel() } - if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)", - unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil { + var storedUsername string + if err := a.db.GetContext(ctx, &storedUsername, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil { + if err == sql.ErrNoRows { + return t.ErrNotFound + } + return err + } else if unique != storedUsername { + return t.ErrMalformed + } + if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)", + store.DecodeUid(uid), authLvl, secret, exp); err != nil { if isDupe(err) { return t.ErrDuplicate } @@ -1807,7 +1859,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error { if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme) + _, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user)) return err } @@ -1829,14 +1881,8 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) { // Update user's authentication unique, secret, auth level. func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { - params := []string{"authLvl=?"} args := []any{authLvl} - - if unique != "" { - params = append(params, "uname=?") - args = append(args, unique) - } if len(secret) > 0 { params = append(params, "secret=?") args = append(args, secret) @@ -1845,23 +1891,46 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L params = append(params, "expires=?") args = append(args, expires) } - args = append(args, store.DecodeUid(uid), scheme) + decodedUID := store.DecodeUid(uid) + args = append(args, decodedUID) - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - sql := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?" - resp, err := a.db.ExecContext(ctx, sql, args...) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if unique != "" { + if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE id=?", unique, t.TimeNow(), decodedUID); err != nil { + if isDupe(err) { + return t.ErrDuplicate + } + return err + } + } + query := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=?" + resp, err := tx.ExecContext(ctx, query, args...) if isDupe(err) { return t.ErrDuplicate } - - if count, _ := resp.RowsAffected(); count <= 0 { - return t.ErrNotFound + if err != nil { + return err } - return err + if count, _ := resp.RowsAffected(); count <= 0 { + var exists int + if err = tx.GetContext(ctx, &exists, "SELECT COUNT(*) FROM im_auth WHERE userid=?", decodedUID); err != nil { + return err + } + if exists == 0 { + return t.ErrNotFound + } + } + + return tx.Commit() } // Retrieve user's authentication record @@ -1869,18 +1938,18 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [ var expires time.Time var record struct { - Uname string - Authlvl auth.Level - Secret []byte - Expires *time.Time + Username string + Authlvl auth.Level + Secret []byte + Expires *time.Time } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=? AND scheme=?", - store.DecodeUid(uid), scheme); err != nil { + if err := a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl + FROM im_auth AS a JOIN im_users AS u ON u.id=a.userid WHERE a.userid=?`, store.DecodeUid(uid)); err != nil { if err == sql.ErrNoRows { // Nothing found - use standard error. err = t.ErrNotFound @@ -1892,7 +1961,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [ expires = *record.Expires } - return record.Uname, record.Authlvl, record.Secret, expires, nil + return record.Username, record.Authlvl, record.Secret, expires, nil } // Retrieve user's authentication record @@ -1910,7 +1979,14 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, if cancel != nil { defer cancel() } - if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=?", unique); err != nil { + lookup := unique + if len(unique) == 11 && asciiDigits(unique) { + lookup = "+86" + unique + } + if err := a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl + FROM im_users AS u JOIN im_auth AS a ON a.userid=u.id + WHERE u.username=? OR (u.phone IS NOT NULL AND u.phone=?) ORDER BY (u.username=?) DESC LIMIT 1`, + unique, lookup, unique); err != nil { if err == sql.ErrNoRows { // Nothing found - clear the error err = nil @@ -1932,7 +2008,7 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) { defer cancel() } var user t.User - err := a.db.GetContext(ctx, &user, `SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted) if err == nil { user.SetUid(uid) @@ -1963,7 +2039,7 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { if cancel != nil { defer cancel() } - q, uids, _ := sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...) if err != nil { diff --git a/server/db/mysql/registration_config.go b/server/db/mysql/registration_config.go new file mode 100644 index 0000000..c40b7a3 --- /dev/null +++ b/server/db/mysql/registration_config.go @@ -0,0 +1,48 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "database/sql" + "errors" + "strconv" + "strings" + + t "github.com/tinode/chat/server/store/types" +) + +func (a *adapter) RegistrationSMSEnabled() (bool, error) { + db := a.registryDB + if db == nil { + db = a.db + } + if db == nil { + return true, t.ErrInternal + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var value string + err := db.GetContext(ctx, &value, `SELECT value FROM hg_sys_config + WHERE `+"`group`"+`='im' AND `+"`key`"+`='register_sms_enabled' AND status=1 LIMIT 1`) + if errors.Is(err, sql.ErrNoRows) { + return true, t.ErrNotFound + } + if err != nil { + return true, err + } + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case "1", "true": + return true, nil + case "0", "false": + return false, nil + default: + if parsed, parseErr := strconv.ParseBool(value); parseErr == nil { + return parsed, nil + } + return true, t.ErrMalformed + } +} diff --git a/server/db/mysql/registration_invite_schema.go b/server/db/mysql/registration_invite_schema.go new file mode 100644 index 0000000..46133a6 --- /dev/null +++ b/server/db/mysql/registration_invite_schema.go @@ -0,0 +1,87 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" +) + +func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS im_registration_invite_batches( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + quantity INT UNSIGNED NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency(tenant_id,idempotency_key), + INDEX idx_im_registration_invite_batch_tenant_created(tenant_id,created_at) + )`, + `CREATE TABLE IF NOT EXISTS im_registration_invite_codes( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + batch_id BIGINT, + code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) NOT NULL, + expires_at DATETIME(3) NOT NULL, + usage_count INT UNSIGNED NOT NULL DEFAULT 0, + last_used_at DATETIME(3) NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code), + INDEX idx_im_registration_invite_tenant_status_expires(tenant_id,status,expires_at), + INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id), + INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at) + )`, + } + for _, stmt := range statements { + if _, err := exec.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func ensureIMRegistrationInviteUseTable(exec sqlExecutor) error { + if _, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_registration_invite_uses( + id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + used_at DATETIME(3) NOT NULL, + PRIMARY KEY(id), + INDEX idx_im_registration_invite_use_code(invite_code_id), + UNIQUE INDEX uk_im_registration_invite_use_user(user_id), + INDEX idx_im_registration_invite_use_request(request_id) + )`); err != nil { + return err + } + for _, migration := range []struct { + statement string + ignored uint16 + }{ + {`ALTER TABLE im_registration_invite_uses DROP INDEX uk_im_registration_invite_use_code`, 1091}, + {`ALTER TABLE im_registration_invite_uses ADD INDEX idx_im_registration_invite_use_code(invite_code_id)`, 1061}, + } { + if _, err := exec.Exec(migration.statement); err != nil && !isMySQLErrorNumber(err, migration.ignored) { + return err + } + } + return nil +} + +func isMySQLErrorNumber(err error, number uint16) bool { + var mysqlErr *mysqldriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == number +} diff --git a/server/db/mysql/registration_invites.go b/server/db/mysql/registration_invites.go new file mode 100644 index 0000000..e9e3fb7 --- /dev/null +++ b/server/db/mysql/registration_invites.go @@ -0,0 +1,457 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "database/sql" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" +) + +func (a *adapter) routedRegistrationInviteAdapter(id t.TenantID) (*adapter, error) { + if err := validTenant(id); err != nil { + return nil, err + } + if a.tenantScopedDB { + return a, nil + } + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + return tenantAdp, nil +} + +func (a *adapter) registrationInviteControlDB() *sqlx.DB { + if a.registryDB != nil { + return a.registryDB + } + return a.db +} + +func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.RegistrationInviteCode) error { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteCreate(id, code) + } + if code == nil || len(code.Code) != 5 || code.ExpiresAt.IsZero() || code.CreatedBy <= 0 { + return t.ErrMalformed + } + if code.ID == 0 { + code.ID, err = nextSnowflakeID() + if err != nil { + return err + } + } + now := t.TimeNow() + code.TenantID = id + code.Status = t.RegistrationInviteActive + code.CreatedAt, code.UpdatedAt = now, now + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + _, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes + (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt, + code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt) + if isDupe(err) { + return t.ErrDuplicate + } + return err +} + +func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.RegistrationInviteBatch, codes []t.RegistrationInviteCode) (err error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteCreateBatch(id, batch, codes) + } + if batch == nil || batch.Quantity != len(codes) || len(codes) == 0 || strings.TrimSpace(batch.IdempotencyKey) == "" || + batch.ExpiresAt.IsZero() || batch.CreatedBy <= 0 { + return t.ErrMalformed + } + if batch.ID == 0 { + batch.ID, err = nextSnowflakeID() + if err != nil { + return err + } + } + now := t.TimeNow() + batch.TenantID = id + batch.CreatedAt, batch.UpdatedAt = now, now + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.registrationInviteControlDB().BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_batches + (id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, batch.ID, id, batch.Quantity, batch.IdempotencyKey, batch.ExpiresAt, + batch.CreatedBy, batch.Remark, batch.CreatedAt, batch.UpdatedAt); err != nil { + if isDupe(err) { + return t.ErrDuplicate + } + return err + } + for i := range codes { + if len(codes[i].Code) != 5 { + return t.ErrMalformed + } + codes[i].ID, err = nextSnowflakeID() + if err != nil { + return err + } + codes[i].BatchID = &batch.ID + codes[i].TenantID = id + codes[i].Status = t.RegistrationInviteActive + codes[i].ExpiresAt = batch.ExpiresAt + codes[i].CreatedBy = batch.CreatedBy + codes[i].Remark = batch.Remark + codes[i].CreatedAt, codes[i].UpdatedAt = now, now + if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes + (id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at) + VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status, + codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil { + if isDupe(err) { + return t.ErrDuplicate + } + return err + } + } + return tx.Commit() +} + +func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string) (*t.RegistrationInviteCode, error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteGetByCode(id, value) + } + if len(value) != 5 { + return nil, t.ErrInviteInvalid + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var code t.RegistrationInviteCode + err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at, + c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at + FROM im_registration_invite_codes AS c + WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value) + if err == sql.ErrNoRows { + return nil, t.ErrInviteInvalid + } + if err != nil { + return nil, err + } + if code.Status == t.RegistrationInviteActive && !t.TimeNow().Before(code.ExpiresAt) { + code.Status = t.RegistrationInviteExpired + } + return &code, nil +} + +func (a *adapter) TenantRegistrationInviteList(id t.TenantID, opts t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteList(id, opts) + } + if opts.Limit <= 0 || opts.Limit > 100 { + opts.Limit = 20 + } + if opts.Offset < 0 { + opts.Offset = 0 + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + controlDB := a.registrationInviteControlDB() + _, _ = controlDB.ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=? + WHERE tenant_id=? AND status=? AND expires_at<=?`, t.RegistrationInviteExpired, now, id, t.RegistrationInviteActive, now) + where := []string{"c.tenant_id=?"} + args := []any{id} + if opts.Code != "" { + where, args = append(where, "c.code=?"), append(args, opts.Code) + } + if opts.Status != "" { + where, args = append(where, "c.status=?"), append(args, opts.Status) + } + if opts.BatchID > 0 { + where, args = append(where, "c.batch_id=?"), append(args, opts.BatchID) + } + if opts.CreatedBy > 0 { + where, args = append(where, "c.created_by=?"), append(args, opts.CreatedBy) + } + if opts.CreatedAtStart != nil { + where, args = append(where, "c.created_at>=?"), append(args, *opts.CreatedAtStart) + } + if opts.CreatedAtEnd != nil { + where, args = append(where, "c.created_at<=?"), append(args, *opts.CreatedAtEnd) + } + if opts.ExpiresAtStart != nil { + where, args = append(where, "c.expires_at>=?"), append(args, *opts.ExpiresAtStart) + } + if opts.ExpiresAtEnd != nil { + where, args = append(where, "c.expires_at<=?"), append(args, *opts.ExpiresAtEnd) + } + filter := strings.Join(where, " AND ") + var total int + if err = controlDB.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_registration_invite_codes AS c WHERE "+filter, args...); err != nil { + return nil, err + } + queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset) + var items []t.RegistrationInviteCode + err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at, + c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at + FROM im_registration_invite_codes AS c + WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + return nil, err + } + return &t.RegistrationInvitePage{Items: items, Total: total}, nil +} + +func (a *adapter) registrationInviteBatchGet(id t.TenantID, where string, arg any) (*t.RegistrationInviteBatch, error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + if where == "b.id=?" { + return tenantAdp.TenantRegistrationInviteBatchGet(id, arg.(int64)) + } + return tenantAdp.TenantRegistrationInviteBatchGetByIdempotency(id, arg.(string)) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var batch t.RegistrationInviteBatch + err = a.registrationInviteControlDB().GetContext(ctx, &batch, `SELECT b.id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.created_by,b.remark,b.created_at,b.updated_at, + COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) AS active_count, + COALESCE(SUM(c.usage_count),0) AS usage_count, + COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) AS expired_count, + COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) AS revoked_count + FROM im_registration_invite_batches AS b + LEFT JOIN im_registration_invite_codes AS c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id + WHERE b.tenant_id=? AND `+where+` GROUP BY b.id`, t.TimeNow(), t.TimeNow(), id, arg) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return &batch, err +} + +func (a *adapter) TenantRegistrationInviteBatchGet(id t.TenantID, batchID int64) (*t.RegistrationInviteBatch, error) { + if batchID <= 0 { + return nil, t.ErrMalformed + } + return a.registrationInviteBatchGet(id, "b.id=?", batchID) +} + +func (a *adapter) TenantRegistrationInviteBatchGetByIdempotency(id t.TenantID, key string) (*t.RegistrationInviteBatch, error) { + if strings.TrimSpace(key) == "" { + return nil, t.ErrMalformed + } + return a.registrationInviteBatchGet(id, "b.idempotency_key=?", key) +} + +func (a *adapter) TenantRegistrationInviteRevoke(id t.TenantID, codeID int64, now time.Time) error { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteRevoke(id, codeID, now) + } + if codeID <= 0 || now.IsZero() { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=? + WHERE tenant_id=? AND id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked, now, + id, codeID, t.RegistrationInviteActive, now) + if err != nil { + return err + } + if count, _ := res.RowsAffected(); count == 0 { + return t.ErrNotFound + } + return nil +} + +func (a *adapter) TenantRegistrationInviteBatchRevoke(id t.TenantID, batchID int64, now time.Time) (int, error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return 0, err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteBatchRevoke(id, batchID, now) + } + if batchID <= 0 || now.IsZero() { + return 0, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=? + WHERE tenant_id=? AND batch_id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked, + now, id, batchID, t.RegistrationInviteActive, now) + if err != nil { + return 0, err + } + count, _ := res.RowsAffected() + return int(count), nil +} + +func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, uid t.Uid, requestID string, now time.Time) (err error) { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteRecordUse(id, value, uid, requestID, now) + } + if len(value) != 5 || uid.IsZero() || strings.TrimSpace(requestID) == "" || now.IsZero() { + return t.ErrMalformed + } + var row struct { + ID int64 `db:"id"` + Status t.RegistrationInviteStatus `db:"status"` + ExpiresAt time.Time `db:"expires_at"` + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at + FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value) + if err == sql.ErrNoRows { + return t.ErrInviteInvalid + } + if err != nil { + return err + } + if row.Status == t.RegistrationInviteRevoked { + return t.ErrInviteRevoked + } + if row.Status == t.RegistrationInviteExpired || !now.Before(row.ExpiresAt) { + return t.ErrInviteExpired + } + if row.Status != t.RegistrationInviteActive { + return t.ErrInviteInvalid + } + + txCtx, txCancel := a.getContextForTx() + if txCancel != nil { + defer txCancel() + } + tx, err := a.db.BeginTxx(txCtx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + useID, err := nextSnowflakeID() + if err != nil { + return err + } + if _, err = tx.ExecContext(txCtx, `INSERT INTO im_registration_invite_uses + (id,invite_code_id,user_id,request_id,used_at) VALUES(?,?,?,?,?)`, useID, row.ID, + store.DecodeUid(uid), requestID, now); err != nil { + if isDupe(err) { + var existingCodeID int64 + if lookupErr := tx.GetContext(txCtx, &existingCodeID, + "SELECT invite_code_id FROM im_registration_invite_uses WHERE user_id=?", store.DecodeUid(uid)); lookupErr == nil && existingCodeID == row.ID { + err = nil + } else { + return t.ErrDuplicate + } + } else { + return err + } + } + eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid)) + if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{ + "tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid), + "request_id": requestID, "used_at": now, + }); err != nil { + return err + } + return tx.Commit() +} + +// TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the +// control database. The overwrite is idempotent, so outbox retries are safe. +func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error { + tenantAdp, err := a.routedRegistrationInviteAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRegistrationInviteSyncUsage(id, codeID) + } + if codeID <= 0 { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var aggregate struct { + UsageCount int `db:"usage_count"` + LastUsedAt *time.Time `db:"last_used_at"` + } + if err = a.db.GetContext(ctx, &aggregate, `SELECT COUNT(*) AS usage_count,MAX(used_at) AS last_used_at + FROM im_registration_invite_uses WHERE invite_code_id=?`, codeID); err != nil { + return err + } + res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes + SET usage_count=?,last_used_at=? WHERE tenant_id=? AND id=?`, aggregate.UsageCount, aggregate.LastUsedAt, id, codeID) + if err != nil { + return err + } + if count, _ := res.RowsAffected(); count == 0 { + var exists int + if lookupErr := a.registrationInviteControlDB().GetContext(ctx, &exists, + "SELECT 1 FROM im_registration_invite_codes WHERE tenant_id=? AND id=?", id, codeID); lookupErr != nil { + if lookupErr == sql.ErrNoRows { + return t.ErrNotFound + } + return lookupErr + } + } + return nil +} diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 32e38b5..78aeff5 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -152,7 +152,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) { defer cancel() } var user t.User - err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent, + err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`, id, decodeUID(uid), t.StateDeleted) if err == sql.ErrNoRows { @@ -165,6 +165,43 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) { return &user, nil } +func (a *adapter) TenantUserGetByPhone(id t.TenantID, phone string) (t.Uid, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || phone == "" { + return t.ZeroUid, t.ErrMalformed + } + if tenantAdp != a || a.tenantScopedDB { + ctx, cancel := tenantAdp.getContext() + if cancel != nil { + defer cancel() + } + var decoded int64 + err = tenantAdp.db.GetContext(ctx, &decoded, + "SELECT id FROM im_users WHERE phone=? AND state!=?", phone, t.StateDeleted) + if err == sql.ErrNoRows { + return t.ZeroUid, nil + } + if err != nil { + return t.ZeroUid, err + } + return store.EncodeUid(decoded), nil + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var decoded int64 + err = a.db.GetContext(ctx, &decoded, + "SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", id, phone, t.StateDeleted) + if err == sql.ErrNoRows { + return t.ZeroUid, nil + } + if err != nil { + return t.ZeroUid, err + } + return store.EncodeUid(decoded), nil +} + func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -201,10 +238,10 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena var q string var params []any if a.tenantScopedDB { - q, params, _ = sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + q, params, _ = sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted) } else { - q, params, _ = sqlx.In(`SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent, + q, params, _ = sqlx.In(`SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent, COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted) } query := a.db.Rebind(q) @@ -7052,8 +7089,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") args = append(args, like, like, like, userIDNumericArg(queryText)) case "exact_account": - joins = append(joins, "LEFT JOIN im_auth AS account_auth ON account_auth.userid=u.id AND account_auth.scheme='basic'") - where = append(where, "(profile.public_account=? OR account_auth.uname=? OR u.id=?)") + where = append(where, "(profile.public_account=? OR u.username=? OR u.id=?)") args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText)) case "phone": addMethod = "phone" @@ -7072,7 +7108,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent, + rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent, COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+` WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...) if err != nil { diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index 45346c2..10e795b 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -126,6 +126,20 @@ func TestUserIdentitySchema(t *testing.T) { if count != 1 { t.Fatal("im_users.phone is missing") } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='username' AND is_nullable='NO'`); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("im_users.username is missing or nullable") + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_auth' AND column_name IN ('uname','scheme')`); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("legacy im_auth.uname or im_auth.scheme still exists") + } if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil { t.Fatal(err) @@ -275,12 +289,12 @@ func TestTenantAuthIsolation(t *testing.T) { userBID := userAID + 1 for index, userID := range []int64{userAID, userBID} { tenantDB := quoteIdent(dbNames[index]) - if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,phone,public,trusted) "+ - "VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+ + "VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil { t.Fatal(err) } - if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)", - "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)", + userID, auth.LevelAuth, []byte("hash")); err != nil { t.Fatal(err) } if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ @@ -289,11 +303,11 @@ func TestTenantAuthIsolation(t *testing.T) { } } - uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice") + uidA, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantA, "alice") if err != nil || uidA != store.EncodeUid(userAID) { t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err) } - uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice") + uidB, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantB, "alice") if err != nil || uidB != store.EncodeUid(userBID) { t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err) } @@ -306,7 +320,7 @@ func TestTenantAuthIsolation(t *testing.T) { t.Fatalf("tenant B credential lookup: uid=%v err=%v", credB, err) } - if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed", + if err = tenantAdp.TenantAuthUpdateRecord(tenantA, uidB, "changed", auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound { t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound) } @@ -382,7 +396,7 @@ func TestCredUpsert(t *testing.T) { func TestAuthAddRecord(t *testing.T) { for _, rec := range testData.Recs { - err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique, + err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"), rec.AuthLvl, rec.Secret, rec.Expires) if err != nil { t.Fatal(err) @@ -390,12 +404,74 @@ func TestAuthAddRecord(t *testing.T) { } //Test duplicate err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme, - testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires) + strings.TrimPrefix(testData.Recs[0].Unique, "basic:"), testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires) if err != types.ErrDuplicate { t.Fatal("Should be duplicate error but got", err) } } +func TestRegistrationInviteLifecycle(t *testing.T) { + inviteAdapter, ok := adp.(adapter.TenantRegistrationInviteAdapter) + if !ok { + t.Fatal("MySQL adapter does not implement TenantRegistrationInviteAdapter") + } + var tenantID types.TenantID + if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil { + t.Fatal(err) + } + now := types.TimeNow() + invite := &types.RegistrationInviteCode{ + Code: "AbCdE", + ExpiresAt: now.Add(time.Hour), + CreatedBy: 10001, + Remark: "integration test", + } + if err := inviteAdapter.TenantRegistrationInviteCreate(tenantID, invite); err != nil { + t.Fatal(err) + } + stored, err := inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE") + if err != nil || stored.Code != "AbCdE" || stored.Status != types.RegistrationInviteActive { + t.Fatalf("stored invite=%#v err=%v", stored, err) + } + if _, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "abcde"); err != types.ErrInviteInvalid { + t.Fatalf("case-insensitive invite lookup was accepted: %v", err) + } + firstUID := types.ParseUserId("usr" + testData.Users[0].Id) + if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", firstUID, "req-invite-test", now); err != nil { + t.Fatal(err) + } + secondUID := types.ParseUserId("usr" + testData.Users[1].Id) + if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", secondUID, "req-invite-second-user", now); err != nil { + t.Fatalf("second user could not reuse invite: %v", err) + } + if err = inviteAdapter.TenantRegistrationInviteSyncUsage(tenantID, invite.ID); err != nil { + t.Fatalf("invite usage projection failed: %v", err) + } + stored, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE") + if err != nil || stored.Status != types.RegistrationInviteActive || stored.UsageCount != 2 { + t.Fatalf("reusable invite=%#v err=%v", stored, err) + } +} + +func TestRegistrationInviteReusableSchema(t *testing.T) { + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_registration_invite_codes' AND column_name IN ('used_by','used_at')`); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("single-use columns still exist: %d", count) + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='im_registration_invite_uses' + AND index_name='idx_im_registration_invite_use_code' AND non_unique=1`); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("reusable invite use index count: got %d, want 1", count) + } +} + func TestTopicCreate(t *testing.T) { err := adp.TopicCreate(testData.Topics[0]) if err != nil { @@ -610,7 +686,7 @@ func TestCredGetAll(t *testing.T) { } func TestAuthGetUniqueRecord(t *testing.T) { - uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice") + uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("alice") if err != nil { t.Fatal(err) } @@ -636,13 +712,14 @@ func TestAuthGetRecord(t *testing.T) { if err != nil { t.Fatal(err) } - if recId != testData.Recs[0].Unique || + wantLogin := strings.TrimPrefix(testData.Recs[0].Unique, "basic:") + if recId != wantLogin || authLvl != testData.Recs[0].AuthLvl || !reflect.DeepEqual(secret, testData.Recs[0].Secret) || expires != testData.Recs[0].Expires { got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires) - want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires) + want := fmt.Sprintf("%v %v %v %v", wantLogin, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires) t.Error(mismatchErrorString("Auth record", got, want)) } @@ -957,13 +1034,13 @@ func TestCredConfirm(t *testing.T) { func TestAuthUpdRecord(t *testing.T) { rec := testData.Recs[1] newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'} - err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique, + err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"), rec.AuthLvl, newSecret, rec.Expires) if err != nil { t.Fatal(err) } var got []byte - err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got) + err = db.QueryRow("SELECT secret FROM im_auth WHERE userid=?", decodeUid(rec.UserId)).Scan(&got) if err != nil { t.Fatal(err) } @@ -972,7 +1049,7 @@ func TestAuthUpdRecord(t *testing.T) { } // Test with auth ID (unique) change - newId := "basic:bob12345" + newId := "bob12345" err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId, rec.AuthLvl, newSecret, rec.Expires) if err != nil { @@ -980,7 +1057,7 @@ func TestAuthUpdRecord(t *testing.T) { } // Test if old ID deleted var count int - err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=? AND username=?", decodeUid(rec.UserId), strings.TrimPrefix(rec.Unique, "basic:")).Scan(&count) if err != nil { t.Fatal(err) } diff --git a/server/db/mysql/tests/test.conf b/server/db/mysql/tests/test.conf index 93745dd..ca6695c 100644 --- a/server/db/mysql/tests/test.conf +++ b/server/db/mysql/tests/test.conf @@ -1,10 +1,12 @@ { "reset_db_data": true, "adapters": { - "mysql": { - "dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci", + "mysql": { + "dsn": "root:root@tcp(127.0.0.1:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci", // Name of the main database. - "database": "tinode_test" + "database": "tinode_test", + "tenant_registry_db": "tinode_test", + "tenant_db_routing": false } } } diff --git a/server/db/mysql/user_identity_schema.go b/server/db/mysql/user_identity_schema.go index 2dda9dd..99e46a8 100644 --- a/server/db/mysql/user_identity_schema.go +++ b/server/db/mysql/user_identity_schema.go @@ -81,6 +81,13 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { return err } } + if exists, err := identityColumnExists(ctx, conn, "im_users", "username"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN username VARCHAR(32) NULL AFTER id"); err != nil { + return err + } + } if profilesExist { if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil { return err @@ -94,6 +101,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { if err = backfillUserPhones(ctx, conn, usersScoped); err != nil { return err } + if err = backfillUsernames(ctx, conn, usersScoped); err != nil { + return err + } if profilesExist { if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil { return err @@ -115,6 +125,25 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { return fmt.Errorf("create unique phone index: %w", err) } } + usernameIndex := "users_username" + usernameColumns := "username" + if usersScoped { + usernameIndex = "uk_im_users_tenant_username" + usernameColumns = "tenant_id,username" + } + if exists, err := identityIndexExists(ctx, conn, "im_users", usernameIndex); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(usernameIndex)+" ON im_users("+usernameColumns+")"); err != nil { + return fmt.Errorf("create unique username index: %w", err) + } + } + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil { + return err + } + if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil { + return err + } if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil { return err @@ -133,6 +162,154 @@ func ensureUserIdentitySchema(db *sqlx.DB) error { return nil } +func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error { + prefix := "0 AS tenant_id,u.id AS user_id," + join := "a.userid=u.id" + orderBy := "u.id" + if scoped { + prefix = "u.tenant_id AS tenant_id,u.id AS user_id," + join = "a.tenant_id=u.tenant_id AND a.userid=u.id" + orderBy = "u.tenant_id,u.id" + } + authExists, err := identityTableExists(ctx, conn, "im_auth") + if err != nil { + return err + } + selectAuth := "NULL" + authJoin := "" + if authExists { + hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname") + if err != nil { + return err + } + hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme") + if err != nil { + return err + } + if hasUname { + selectAuth = "CASE WHEN a.uname LIKE 'basic:%' THEN SUBSTRING(a.uname,7) ELSE a.uname END" + authJoin = " LEFT JOIN im_auth AS a ON " + join + if hasScheme { + authJoin += " AND a.scheme='basic'" + } + } + } + var rows []identityValue + query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " ORDER BY " + orderBy + if err = conn.SelectContext(ctx, &rows, query); err != nil { + return err + } + seen := make(map[string]bool, len(rows)) + for _, row := range rows { + value := strings.TrimSpace(row.Value) + if strings.HasPrefix(value, "+") || (len(value) == 11 && asciiDigits(value)) { + if canonical, normalizeErr := normalizeUserPhone(value); normalizeErr == nil { + value = canonical + } + } + if value == "" || len(value) > 32 || seen[identityUniqueKey(row.TenantID, value, scoped)] { + value = fmt.Sprintf("reserved_%d", row.UserID) + } + seen[identityUniqueKey(row.TenantID, value, scoped)] = true + update := "UPDATE im_users SET username=? WHERE id=?" + args := []any{value, row.UserID} + if scoped { + update = "UPDATE im_users SET username=? WHERE tenant_id=? AND id=?" + args = []any{value, row.TenantID, row.UserID} + } + if _, err = conn.ExecContext(ctx, update, args...); err != nil { + return err + } + } + return nil +} + +func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error { + exists, err := identityTableExists(ctx, conn, "im_auth") + if err != nil || !exists { + return err + } + if hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme"); err != nil { + return err + } else if hasScheme { + var unsupported int + if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM im_auth WHERE scheme<>'basic'").Scan(&unsupported); err != nil { + return err + } + if unsupported > 0 { + return fmt.Errorf("mysql: im_auth contains %d non-basic records", unsupported) + } + } + legacyComposite, err := identityIndexExists(ctx, conn, "im_auth", "auth_userid_scheme") + if err != nil { + return err + } + if legacyComposite { + // The legacy composite index may currently support the userid foreign key. + // Give the FK a replacement before dropping that index. + if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "CREATE INDEX auth_userid_fk ON im_auth(userid)"); err != nil { + return err + } + } + } + for _, index := range []string{"auth_uname", "auth_userid_scheme"} { + if exists, err = identityIndexExists(ctx, conn, "im_auth", index); err != nil { + return err + } else if exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX "+quoteIdent(index)); err != nil { + return err + } + } + } + for _, column := range []string{"uname", "scheme"} { + if exists, err = identityColumnExists(ctx, conn, "im_auth", column); err != nil { + return err + } else if exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP COLUMN "+quoteIdent(column)); err != nil { + return err + } + } + } + indexName := "auth_userid" + columns := "userid" + if scoped { + indexName = "auth_tenant_userid" + columns = "tenant_id,userid" + } + if exists, err = identityIndexExists(ctx, conn, "im_auth", indexName); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_auth("+columns+")"); err != nil { + return err + } + } + if !scoped { + if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil { + return err + } else if exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid_fk"); err != nil { + return err + } + } + } + return nil +} + +func asciiDigits(value string) bool { + if value == "" { + return false + } + for i := range value { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return true +} + func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error { candidates, err := identityCandidates(ctx, conn, scoped, "tel") if err != nil { @@ -277,6 +454,17 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil { return nil, err } else if exists { + hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname") + if err != nil { + return nil, err + } + hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme") + if err != nil { + return nil, err + } + if !hasUname || !hasScheme { + return out, nil + } authJoin := "a.userid=u.id" if scoped { authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id" diff --git a/server/im_business_events.go b/server/im_business_events.go index 0262711..b1eaa64 100644 --- a/server/im_business_events.go +++ b/server/im_business_events.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "strings" + "sync" "time" "github.com/tinode/chat/server/logs" @@ -10,6 +11,10 @@ import ( "github.com/tinode/chat/server/store/types" ) +const imExternalBusinessEventPollInterval = time.Second + +var imBusinessEventDispatchLocks sync.Map + func stableIMEventID(parts ...any) string { return types.StableEventID(parts...) } @@ -87,6 +92,11 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) { if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil { return } + lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{}) + lock := lockValue.(*sync.Mutex) + lock.Lock() + defer lock.Unlock() + now := time.Now().UTC().Round(time.Millisecond) events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now) if err != nil { @@ -94,6 +104,39 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) { return } for _, event := range events { + if event.EventType == "registration.invite_used" { + var payload struct { + InviteCodeID int64 `json:"invite_code_id"` + } + if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 { + logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err) + return + } + if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil { + logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err) + return + } + if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil { + logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err) + return + } + continue + } + if event.EventType == "security.password_reset" { + var payload struct { + UserID int64 `json:"user_id"` + } + if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 { + logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err) + } else if globals.sessionStore != nil { + globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "") + } + if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil { + logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err) + return + } + continue + } from := "" if !event.ActorUserID.IsZero() { from = event.ActorUserID.UserId() @@ -118,3 +161,34 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) { } } } + +// startIMExternalBusinessEventPoller bridges direct database writers until the +// same event contract is transported by Kafka. +func startIMExternalBusinessEventPoller() func() { + stop := make(chan struct{}) + go func() { + ticker := time.NewTicker(imExternalBusinessEventPollInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if globals.sessionStore == nil { + continue + } + tenantIDs := make(map[types.TenantID]struct{}) + globals.sessionStore.Range(func(_ string, session *Session) bool { + if session != nil && !session.tenantID.IsZero() { + tenantIDs[session.tenantID] = struct{}{} + } + return true + }) + for tenantID := range tenantIDs { + dispatchIMBusinessEvents(tenantID) + } + case <-stop: + return + } + } + }() + return func() { close(stop) } +} diff --git a/server/im_business_events_test.go b/server/im_business_events_test.go new file mode 100644 index 0000000..de9583d --- /dev/null +++ b/server/im_business_events_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "testing" + "time" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type passwordResetEventStore struct { + testHTTPInfrastructureStore + delivered []string +} + +func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error { + s.delivered = append(s.delivered, eventID) + return nil +} + +func TestDispatchPasswordResetEvent(t *testing.T) { + oldInfrastructure := store.HTTPInfrastructure + oldHub, oldSessions := globals.hub, globals.sessionStore + defer func() { + store.HTTPInfrastructure = oldInfrastructure + globals.hub, globals.sessionStore = oldHub, oldSessions + }() + + infrastructure := &passwordResetEventStore{} + infrastructure.events = []types.BusinessEvent{{ + EventID: "password-reset-1", EventType: "security.password_reset", + TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`), + }} + store.HTTPInfrastructure = infrastructure + globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)} + globals.sessionStore = nil + + dispatchIMBusinessEvents(types.TenantID(1)) + + if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" { + t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered) + } + if len(globals.hub.routeSrv) != 0 { + t.Fatal("password reset event was routed as a regular client event") + } +} + +func TestDispatchRegistrationInviteUsageEvent(t *testing.T) { + oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites + oldHub := globals.hub + defer func() { + store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites + globals.hub = oldHub + }() + + infrastructure := &passwordResetEventStore{} + infrastructure.events = []types.BusinessEvent{{ + EventID: "invite-use-1", EventType: "registration.invite_used", + TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`), + }} + invites := &testRegistrationInviteStore{} + store.HTTPInfrastructure = infrastructure + store.RegistrationInvites = invites + globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)} + + dispatchIMBusinessEvents(types.TenantID(1)) + + if invites.syncedCode != 99 { + t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode) + } + if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" { + t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered) + } + if len(globals.hub.routeSrv) != 0 { + t.Fatal("invite usage event was routed as a regular client event") + } +} diff --git a/server/im_http.go b/server/im_http.go index 82cd62c..7bbe4bd 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -37,6 +37,7 @@ const ( imReasonChallengeExpired = "CHALLENGE_EXPIRED" imReasonCodeInvalid = "CODE_INVALID" imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS" + imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED" imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION" imReasonAuthRequired = "AUTH_REQUIRED" imReasonRealNameRequired = "REALNAME_REQUIRED" @@ -51,7 +52,6 @@ const ( const ( imVerificationCodeLength = 6 - imVerificationTestCode = "123456" imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 @@ -523,6 +523,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { return } + endpoints := imPublicEndpoints(req) writeIMSuccess(wrt, req, map[string]any{ "tenant": map[string]any{ "code": tenant.Code, @@ -530,11 +531,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { "tenant_desc": tenant.TenantDesc, "branding": map[string]any{}, }, - "endpoints": map[string]any{ - "websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels", - "api": strings.TrimRight(globals.servingAt, "/") + "/v1/im", - "file": strings.TrimRight(globals.servingAt, "/") + "/v0/file", - }, + "endpoints": endpoints, "auth_methods": []string{"password", "sms"}, "password_policy": map[string]any{ "min_length": 8, @@ -544,6 +541,75 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { }) } +func imPublicEndpoints(req *http.Request) map[string]any { + base := imPublicHTTPBase(req) + wsBase := base + if strings.HasPrefix(wsBase, "https://") { + wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://") + } else if strings.HasPrefix(wsBase, "http://") { + wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://") + } + return map[string]any{ + "websocket": strings.TrimRight(wsBase, "/") + "/v0/channels", + "api": strings.TrimRight(base, "/") + "/v1/im", + "file": strings.TrimRight(base, "/") + "/v0/file", + } +} + +func imPublicHTTPBase(req *http.Request) string { + configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/") + parsed, _ := url.Parse(configured) + basePath := "" + if parsed != nil { + basePath = strings.TrimRight(parsed.Path, "/") + if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) { + return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/") + } + } + if basePath == "" && req != nil && req.URL != nil { + if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 { + basePath = strings.TrimRight(req.URL.Path[:idx], "/") + } + } + + scheme := "http" + if req != nil && req.TLS != nil { + scheme = "https" + } + if req != nil { + if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" { + scheme = forwarded + } + } + + host := "" + if req != nil { + host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host")) + if host == "" { + host = strings.TrimSpace(req.Host) + } + } + if host == "" && parsed != nil { + host = parsed.Host + } + if host == "" { + return configured + } + return strings.TrimRight(scheme+"://"+host+basePath, "/") +} + +func isPublicEndpointHost(host string) bool { + host = strings.TrimSpace(host) + return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:") +} + +func firstForwardedHeader(value string) string { + if idx := strings.Index(value, ","); idx >= 0 { + value = value[:idx] + } + return strings.TrimSpace(strings.ToLower(value)) +} + func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -598,8 +664,12 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - // Temporary fixed code for app integration until the SMS provider is connected. - code := imVerificationTestCode + code, err := randomIMNumericCode(imVerificationCodeLength) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + deliveryEnabled := scene != "register" || registrationSMSEnabled(now) vc := &types.VerificationCode{ TenantID: tenant.ID, ChallengeID: challengeID, @@ -613,8 +683,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second), } if scene == "reset_password" { - if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil { - logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err) + if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil { + logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } else { @@ -626,8 +696,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } + if deliveryEnabled { + if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil { + _ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired) + logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err) + writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil) + return + } + } else { + logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask) + } - logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask) + logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask) writeIMSuccess(wrt, req, map[string]any{ "challenge_id": vc.ChallengeID, "phone_mask": vc.PhoneMask, @@ -794,7 +874,7 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) return } - login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic") + login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser) if err != nil || login == "" { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return @@ -830,6 +910,19 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil) return } + if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" { + if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{ + ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, + User: vc.TargetUser.String(), + Method: "tel", + Value: account.Phone, + Done: true, + }); credErr != nil { + logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr) + } + } else if getErr != nil { + logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr) + } if globals.sessionStore != nil { globals.sessionStore.EvictUser(vc.TargetUser, "") } @@ -4892,8 +4985,11 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - // Temporary fixed code for app integration until the SMS provider is connected. - code := imVerificationTestCode + code, err := randomIMNumericCode(imVerificationCodeLength) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } vc := &types.VerificationCode{ TenantID: principal.TenantID, ChallengeID: challengeID, @@ -4912,7 +5008,13 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - logs.Info.Println("im security verification code generated:", action, vc.PhoneMask) + if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil { + _ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired) + logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err) + writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil) + return + } + logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask) writeIMSuccess(wrt, req, map[string]any{ "challenge_id": vc.ChallengeID, "phone_mask": vc.PhoneMask, diff --git a/server/im_http_test.go b/server/im_http_test.go index 6141c8c..38cb13d 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -1074,11 +1074,48 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) { t.Fatalf("tenant payload must not expose internal id: %#v", tenant) } endpoints := data["endpoints"].(map[string]any) - if endpoints["websocket"] != "https://im.example.com/v0/channels" { + if endpoints["websocket"] != "wss://im.example.com/v0/channels" { t.Fatalf("unexpected websocket endpoint: %#v", endpoints) } } +func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) { + oldTenants := store.Tenants + oldServingAt := globals.servingAt + store.Tenants = testTenantStore{tenant: &types.Tenant{ + ID: 7, + Code: "acme", + Name: "Acme Corp", + State: types.TenantStateActive, + }} + defer func() { + store.Tenants = oldTenants + globals.servingAt = oldServingAt + }() + globals.servingAt = "http://:6060/" + + req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) + req.Host = "internal:6060" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "api.118im.com") + req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-Tenant-Code", "acme") + rec := httptest.NewRecorder() + + serveIMTenantResolve(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + endpoints := data["endpoints"].(map[string]any) + if endpoints["websocket"] != "wss://api.118im.com/v0/channels" || + endpoints["api"] != "https://api.118im.com/v1/im" || + endpoints["file"] != "https://api.118im.com/v0/file" { + t.Fatalf("unexpected endpoints: %#v", endpoints) + } +} + func TestServeIMTenantResolveSuccessFromBody(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ @@ -1204,6 +1241,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) { func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications + oldSender := sendIMVerificationCode store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", @@ -1212,9 +1250,20 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { }} verifications := &testVerificationStore{} store.Verifications = verifications + setRegistrationSMSConfigForTest(true) + var deliveredCode string + sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error { + if recipient != "+8613800138000" || scene != "register" { + t.Fatalf("unexpected delivery: recipient=%q scene=%q", recipient, scene) + } + deliveredCode = code + return nil + } defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications + sendIMVerificationCode = oldSender + resetRegistrationSMSConfigForTest() }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", @@ -1237,6 +1286,11 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) { t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String()) } + if len(deliveredCode) != imVerificationCodeLength || + verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID, + verifications.created.PhoneHash, deliveredCode) { + t.Fatalf("stored challenge does not match delivered code %q", deliveredCode) + } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 { @@ -1244,6 +1298,113 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { } } +func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) { + oldTenants := store.Tenants + oldVerifications := store.Verifications + oldSender := sendIMVerificationCode + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} + verifications := &testVerificationStore{} + store.Verifications = verifications + setRegistrationSMSConfigForTest(false) + sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { + t.Fatal("SMS sender must not be called when registration SMS is disabled") + return nil + } + defer func() { + store.Tenants = oldTenants + store.Verifications = oldVerifications + sendIMVerificationCode = oldSender + resetRegistrationSMSConfigForTest() + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", + bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) + req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-Tenant-Code", "acme") + rec := httptest.NewRecorder() + + serveIMVerificationCodes(rec, req) + + if rec.Code != http.StatusOK || verifications.created == nil { + t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created) + } +} + +func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + oldTenants := store.Tenants + oldVerifications := store.Verifications + oldUsers := store.Users + oldSender := sendIMVerificationCode + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} + verifications := &testVerificationStore{} + store.Verifications = verifications + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil) + store.Users = users + sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error { + if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength { + t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code) + } + return nil + } + defer func() { + store.Tenants = oldTenants + store.Verifications = oldVerifications + store.Users = oldUsers + sendIMVerificationCode = oldSender + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", + bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`)) + req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-Tenant-Code", "acme") + rec := httptest.NewRecorder() + + serveIMVerificationCodes(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) { + t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created) + } +} + +func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) { + oldTenants := store.Tenants + oldVerifications := store.Verifications + oldSender := sendIMVerificationCode + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} + verifications := &testVerificationStore{} + store.Verifications = verifications + setRegistrationSMSConfigForTest(true) + sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal } + defer func() { + store.Tenants = oldTenants + store.Verifications = oldVerifications + sendIMVerificationCode = oldSender + resetRegistrationSMSConfigForTest() + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", + bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) + req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) + req.Header.Set("X-Tenant-Code", "acme") + rec := httptest.NewRecorder() + + serveIMVerificationCodes(rec, req) + + if rec.Code != http.StatusBadGateway || !verifications.failed { + t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String()) + } + if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed { + t.Fatalf("reason=%q", resp.Reason) + } +} + func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) diff --git a/server/main.go b/server/main.go index 010e4c3..09c81a7 100644 --- a/server/main.go +++ b/server/main.go @@ -450,7 +450,6 @@ func main() { // API key signing secret globals.apiKeySalt = config.APIKeySalt - err = store.InitAuthLogicalNames(config.Auth["logical_names"]) if err != nil { logs.Err.Fatal(err) @@ -674,6 +673,8 @@ func main() { globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second) // The hub (the main message router) globals.hub = newHub() + stopExternalBusinessEvents := startIMExternalBusinessEventPoller() + defer stopExternalBusinessEvents() // Start accepting cluster traffic. if globals.cluster != nil { diff --git a/server/registration_verification.go b/server/registration_verification.go new file mode 100644 index 0000000..53767cf --- /dev/null +++ b/server/registration_verification.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "sync" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" + "github.com/tinode/chat/server/validate" +) + +const registrationSMSConfigCacheTTL = 30 * time.Second + +var registrationSMSConfigCache struct { + sync.Mutex + value bool + loaded bool + loadedAt time.Time + loadValue func() (bool, error) +} + +func registrationSMSEnabled(now time.Time) bool { + registrationSMSConfigCache.Lock() + defer registrationSMSConfigCache.Unlock() + if registrationSMSConfigCache.loaded && now.Sub(registrationSMSConfigCache.loadedAt) < registrationSMSConfigCacheTTL { + return registrationSMSConfigCache.value + } + loader := registrationSMSConfigCache.loadValue + if loader == nil { + loader = func() (bool, error) { + if store.RegistrationConfig == nil { + return true, types.ErrUnsupported + } + return store.RegistrationConfig.SMSEnabled() + } + } + value, err := loader() + if err != nil { + if registrationSMSConfigCache.loaded { + logs.Warn.Println("registration SMS config read failed; using cached value:", err) + return registrationSMSConfigCache.value + } + logs.Warn.Println("registration SMS config read failed; defaulting to enabled:", err) + return true + } + registrationSMSConfigCache.value = value + registrationSMSConfigCache.loaded = true + registrationSMSConfigCache.loadedAt = now + return value +} + +func isValidIMInviteCode(value string) bool { + if len(value) != 5 { + return false + } + for i := range value { + if (value[i] < 'A' || value[i] > 'Z') && (value[i] < 'a' || value[i] > 'z') { + return false + } + } + return true +} + +var sendIMVerificationCode = func(tenantID types.TenantID, recipient, lang, scene, code string) error { + if store.Store == nil { + return types.ErrInternal + } + validatorImpl := store.Store.GetValidator("tel") + sender, ok := validatorImpl.(validate.CodeSender) + if !ok || !validatorImpl.IsInitialized() { + return types.ErrUnsupported + } + if err := sender.SendCode(tenantID, recipient, lang, scene, code); err != nil { + return fmt.Errorf("send verification code: %w", err) + } + return nil +} diff --git a/server/registration_verification_test.go b/server/registration_verification_test.go new file mode 100644 index 0000000..580fa87 --- /dev/null +++ b/server/registration_verification_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "time" + + "github.com/tinode/chat/server/store/types" +) + +type testRegistrationInviteStore struct { + invite *types.RegistrationInviteCode + created *types.RegistrationInviteCode + page *types.RegistrationInvitePage + batch *types.RegistrationInviteBatch + err error + useRecorded bool + syncedCode int64 +} + +func (s *testRegistrationInviteStore) Create(_ types.TenantID, code *types.RegistrationInviteCode) error { + s.created = code + return s.err +} +func (s *testRegistrationInviteStore) CreateBatch(types.TenantID, *types.RegistrationInviteBatch, []types.RegistrationInviteCode) error { + return s.err +} +func (s *testRegistrationInviteStore) GetByCode(types.TenantID, string) (*types.RegistrationInviteCode, error) { + return s.invite, s.err +} +func (s *testRegistrationInviteStore) List(types.TenantID, types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) { + return s.page, s.err +} +func (s *testRegistrationInviteStore) GetBatch(types.TenantID, int64) (*types.RegistrationInviteBatch, error) { + return s.batch, s.err +} +func (s *testRegistrationInviteStore) GetBatchByIdempotency(types.TenantID, string) (*types.RegistrationInviteBatch, error) { + return s.batch, s.err +} +func (s *testRegistrationInviteStore) Revoke(types.TenantID, int64, time.Time) error { return s.err } +func (s *testRegistrationInviteStore) RevokeBatch(types.TenantID, int64, time.Time) (int, error) { + return 0, s.err +} +func (s *testRegistrationInviteStore) RecordUse(types.TenantID, string, types.Uid, string, time.Time) error { + s.useRecorded = true + return s.err +} + +func (s *testRegistrationInviteStore) SyncUsage(_ types.TenantID, codeID int64) error { + s.syncedCode = codeID + return s.err +} + +func setRegistrationSMSConfigForTest(enabled bool) { + registrationSMSConfigCache.Lock() + defer registrationSMSConfigCache.Unlock() + registrationSMSConfigCache.value = false + registrationSMSConfigCache.loaded = false + registrationSMSConfigCache.loadedAt = time.Time{} + registrationSMSConfigCache.loadValue = func() (bool, error) { return enabled, nil } +} + +func resetRegistrationSMSConfigForTest() { + registrationSMSConfigCache.Lock() + defer registrationSMSConfigCache.Unlock() + registrationSMSConfigCache.value = false + registrationSMSConfigCache.loaded = false + registrationSMSConfigCache.loadedAt = time.Time{} + registrationSMSConfigCache.loadValue = nil +} diff --git a/server/session_test.go b/server/session_test.go index 8f871f8..cd7c0c0 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -1237,20 +1237,26 @@ func TestDispatchAccNew(t *testing.T) { uid := types.Uid(1) oldSecurity := store.Security oldVerifications := store.Verifications + oldInvites := store.RegistrationInvites store.Store = ss store.Users = uu store.Security = &testSecurityStore{version: 1} store.Verifications = nil + invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{ + Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour), + }} + store.RegistrationInvites = invites defer func() { store.Store = nil store.Users = nil store.Security = oldSecurity store.Verifications = oldVerifications + store.RegistrationInvites = oldInvites ctrl.Finish() }() remoteAddr := "192.168.0.1" - secret := "<==auth-secret==>" + secret := "alice:password" authRec := &auth.Rec{ TenantID: 1, Uid: uid, @@ -1289,11 +1295,13 @@ func TestDispatchAccNew(t *testing.T) { public := "public name" msg := &ClientComMessage{ Acc: &MsgClientAcc{ - Id: "123", - User: "newXYZ", - Scheme: "basic", - Secret: []byte(secret), - Desc: &MsgSetDesc{Public: public}, + Id: "123", + User: "newXYZ", + Scheme: "basic", + Username: "alice", + InviteCode: "AbCdE", + Secret: []byte(secret), + Desc: &MsgSetDesc{Public: public}, }, } @@ -1304,6 +1312,9 @@ func TestDispatchAccNew(t *testing.T) { if len(r.messages) != 1 { t.Errorf("responses: expected 1, received %d.", len(r.messages)) } + if !invites.useRecorded { + t.Error("registration invite use was not recorded") + } resp := r.messages[0].(*ServerComMessage) if resp == nil { t.Fatal("Response must be ServerComMessage") diff --git a/server/store/mock_store/mock_store.go b/server/store/mock_store/mock_store.go index cc4458f..96efb26 100644 --- a/server/store/mock_store/mock_store.go +++ b/server/store/mock_store/mock_store.go @@ -354,17 +354,17 @@ func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceM } // AddAuthRecord mocks base method. -func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error { +func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires) + ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, secret, expires) ret0, _ := ret[0].(error) return ret0 } // AddAuthRecord indicates an expected call of AddAuthRecord. -func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call { +func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, secret, expires interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, secret, expires) } // ConfirmCred mocks base method. @@ -396,18 +396,18 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, priv return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private) } -// DelAuthRecords mocks base method. -func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error { +// DeleteAuthRecord mocks base method. +func (m *MockUsersPersistenceInterface) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme) + ret := m.ctrl.Call(m, "DeleteAuthRecord", tenantID, uid) ret0, _ := ret[0].(error) return ret0 } -// DelAuthRecords indicates an expected call of DelAuthRecords. -func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call { +// DeleteAuthRecord indicates an expected call of DeleteAuthRecord. +func (mr *MockUsersPersistenceInterfaceMockRecorder) DeleteAuthRecord(tenantID, uid interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DeleteAuthRecord), tenantID, uid) } // DelCred mocks base method. @@ -532,6 +532,21 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...) } +// GetByPhone mocks base method. +func (m *MockUsersPersistenceInterface) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByPhone", tenantID, phone) + ret0, _ := ret[0].(types.Uid) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByPhone indicates an expected call of GetByPhone. +func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByPhone(tenantID, phone interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPhone", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByPhone), tenantID, phone) +} + // GetAllCreds mocks base method. func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) { m.ctrl.T.Helper() @@ -548,9 +563,9 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, m } // GetAuthRecord mocks base method. -func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) { +func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme) + ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user) ret0, _ := ret[0].(string) ret1, _ := ret[1].(auth.Level) ret2, _ := ret[2].([]byte) @@ -560,15 +575,15 @@ func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, u } // GetAuthRecord indicates an expected call of GetAuthRecord. -func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call { +func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user) } -// GetAuthUniqueRecord mocks base method. -func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) { +// GetAuthByLogin mocks base method. +func (m *MockUsersPersistenceInterface) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique) + ret := m.ctrl.Call(m, "GetAuthByLogin", tenantID, login) ret0, _ := ret[0].(types.Uid) ret1, _ := ret[1].(auth.Level) ret2, _ := ret[2].([]byte) @@ -577,10 +592,10 @@ func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.Tenan return ret0, ret1, ret2, ret3, ret4 } -// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord. -func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call { +// GetAuthByLogin indicates an expected call of GetAuthByLogin. +func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthByLogin(tenantID, login interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthByLogin", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthByLogin), tenantID, login) } // GetByCred mocks base method. @@ -723,17 +738,17 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, updat } // UpdateAuthRecord mocks base method. -func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error { +func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires) + ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, username, secret, expires) ret0, _ := ret[0].(error) return ret0 } // UpdateAuthRecord indicates an expected call of UpdateAuthRecord. -func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call { +func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, username, secret, expires interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, username, secret, expires) } // UpdateLastSeen mocks base method. diff --git a/server/store/store.go b/server/store/store.go index d695eba..950fc2e 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -137,6 +137,24 @@ type tenantsMapper struct{} // Tenants is the tenant registry access object. var Tenants TenantsPersistenceInterface +// RegistrationConfigPersistenceInterface exposes global registration policy. +type RegistrationConfigPersistenceInterface interface { + SMSEnabled() (bool, error) +} + +type registrationConfigMapper struct{} + +// RegistrationConfig reads registration policy from the control database. +var RegistrationConfig RegistrationConfigPersistenceInterface + +func (registrationConfigMapper) SMSEnabled() (bool, error) { + configAdp, ok := adp.(adapter.RegistrationConfigAdapter) + if !ok { + return true, types.ErrUnsupported + } + return configAdp.RegistrationSMSEnabled() +} + // GetByCode resolves a tenant by its public enterprise code. func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) { tenantAdp, ok := adp.(adapter.TenantAdapter) @@ -368,13 +386,14 @@ func (s storeObj) DbStats() func() any { // UsersPersistenceInterface is an interface which defines methods for persistent storage of user records. type UsersPersistenceInterface interface { Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error) - GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) - GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) - AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error - UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error - DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error + GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) + GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) + AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error + UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error + DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error Get(tenantID types.TenantID, uid types.Uid) (*types.User, error) GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error) + GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error) Delete(tenantID types.TenantID, id types.Uid, hard bool) error UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error @@ -455,61 +474,50 @@ func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any // GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and // authentication secret. -func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) { +func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) { tenantAdp, err := tenantAuthAdapter(tenantID) if err != nil { return "", auth.LevelNone, nil, time.Time{}, err } - unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme) - if err == nil { - parts := strings.Split(unique, ":") - if len(parts) > 1 { - unique = parts[1] - } else { - err = types.ErrInternal - } - } - - return unique, authLvl, secret, expires, err + return tenantAdp.TenantAuthGetRecord(tenantID, user) } -// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and -// authentication secret. -func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) { +// GetAuthByLogin fetches a Basic authentication record by username or phone. +func (usersMapper) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) { tenantAdp, err := tenantAuthAdapter(tenantID) if err != nil { return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err } - return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique) + return tenantAdp.TenantAuthGetByLogin(tenantID, login) } // AddAuthRecord creates a new authentication record for the given user. -func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, +func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error { tenantAdp, err := tenantAuthAdapter(tenantID) if err != nil { return err } - return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires) + return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires) } // UpdateAuthRecord updates authentication record with a new secret and expiration time. -func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, +func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error { tenantAdp, err := tenantAuthAdapter(tenantID) if err != nil { return err } - return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires) + return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires) } -// DelAuthRecords deletes user's auth records of the given scheme. -func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error { +// DeleteAuthRecord deletes the user's Basic authentication record. +func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error { tenantAdp, err := tenantAuthAdapter(tenantID) if err != nil { return err } - return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme) + return tenantAdp.TenantAuthDeleteRecord(tenantID, uid) } // Get returns a user object for the given user ID or nil if the user is not found. @@ -552,6 +560,16 @@ func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (typ return tenantAdp.TenantUserGetByCred(tenantID, method, value) } +// GetByPhone resolves an account by the phone identity stored on im_users. +// It intentionally does not require a completed tel credential. +func (usersMapper) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return types.ZeroUid, err + } + return tenantAdp.TenantUserGetByPhone(tenantID, phone) +} + // Delete deletes user records. func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error { tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -3062,6 +3080,25 @@ type verificationMapper struct{} // Verifications is a singleton for product verification challenges. var Verifications VerificationPersistenceInterface +// RegistrationInvitePersistenceInterface manages tenant-scoped registration invitations. +type RegistrationInvitePersistenceInterface interface { + Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error + CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error + GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) + List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) + GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) + GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) + Revoke(tenantID types.TenantID, codeID int64, now time.Time) error + RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) + RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error + SyncUsage(tenantID types.TenantID, codeID int64) error +} + +type registrationInviteMapper struct{} + +// RegistrationInvites is the tenant-scoped registration invitation store. +var RegistrationInvites RegistrationInvitePersistenceInterface + // UserSecurityPersistenceInterface defines user security state used to // invalidate previously issued login tokens. type UserSecurityPersistenceInterface interface { @@ -3267,6 +3304,111 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt) } +func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) { + if !tenantID.IsValid() { + return nil, types.ErrMalformed + } + if router, ok := adp.(adapter.TenantRoutedAdapter); ok { + business, _, err := router.TenantAdapterFor(tenantID) + if err != nil { + return nil, err + } + if inviteAdp, ok := business.(adapter.TenantRegistrationInviteAdapter); ok { + return inviteAdp, nil + } + } + if inviteAdp, ok := adp.(adapter.TenantRegistrationInviteAdapter); ok { + return inviteAdp, nil + } + return nil, types.ErrUnsupported +} + +func (registrationInviteMapper) Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error { + if code == nil { + return types.ErrMalformed + } + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return err + } + return inviteAdp.TenantRegistrationInviteCreate(tenantID, code) +} + +func (registrationInviteMapper) CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error { + if batch == nil || len(codes) == 0 { + return types.ErrMalformed + } + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return err + } + return inviteAdp.TenantRegistrationInviteCreateBatch(tenantID, batch, codes) +} + +func (registrationInviteMapper) GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return nil, err + } + return inviteAdp.TenantRegistrationInviteGetByCode(tenantID, code) +} + +func (registrationInviteMapper) List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return nil, err + } + return inviteAdp.TenantRegistrationInviteList(tenantID, opts) +} + +func (registrationInviteMapper) GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return nil, err + } + return inviteAdp.TenantRegistrationInviteBatchGet(tenantID, batchID) +} + +func (registrationInviteMapper) GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return nil, err + } + return inviteAdp.TenantRegistrationInviteBatchGetByIdempotency(tenantID, key) +} + +func (registrationInviteMapper) Revoke(tenantID types.TenantID, codeID int64, now time.Time) error { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return err + } + return inviteAdp.TenantRegistrationInviteRevoke(tenantID, codeID, now) +} + +func (registrationInviteMapper) RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return 0, err + } + return inviteAdp.TenantRegistrationInviteBatchRevoke(tenantID, batchID, now) +} + +func (registrationInviteMapper) RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return err + } + return inviteAdp.TenantRegistrationInviteRecordUse(tenantID, code, uid, requestID, now) +} + +func (registrationInviteMapper) SyncUsage(tenantID types.TenantID, codeID int64) error { + inviteAdp, err := tenantRegistrationInviteAdapter(tenantID) + if err != nil { + return err + } + return inviteAdp.TenantRegistrationInviteSyncUsage(tenantID, codeID) +} + // Get reads a persistent cache entry. func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -3310,6 +3452,7 @@ func SetTestUidGenerator(g types.UidGenerator) { func init() { Store = storeObj{} Tenants = tenantsMapper{} + RegistrationConfig = registrationConfigMapper{} Users = usersMapper{} Topics = topicsMapper{} Subs = subsMapper{} @@ -3330,6 +3473,7 @@ func init() { Files = fileMapper{} PCache = pcacheMapper{} Verifications = verificationMapper{} + RegistrationInvites = registrationInviteMapper{} Security = userSecurityMapper{} Relations = relationsMapper{} Favorites = favoritesMapper{} diff --git a/server/store/types/registration_invite.go b/server/store/types/registration_invite.go new file mode 100644 index 0000000..6c64779 --- /dev/null +++ b/server/store/types/registration_invite.go @@ -0,0 +1,70 @@ +package types + +import ( + "errors" + "time" +) + +var ( + ErrInviteInvalid = errors.New("registration invite invalid") + ErrInviteExpired = errors.New("registration invite expired") + ErrInviteRevoked = errors.New("registration invite revoked") + ErrInviteIdempotencyConflict = errors.New("registration invite idempotency conflict") +) + +type RegistrationInviteStatus string + +const ( + RegistrationInviteActive RegistrationInviteStatus = "active" + RegistrationInviteExpired RegistrationInviteStatus = "expired" + RegistrationInviteRevoked RegistrationInviteStatus = "revoked" +) + +type RegistrationInviteBatch struct { + ID int64 `db:"id" json:"batch_id"` + TenantID TenantID `db:"tenant_id" json:"tenant_id"` + Quantity int `db:"quantity" json:"quantity"` + IdempotencyKey string `db:"idempotency_key" json:"-"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedBy int64 `db:"created_by" json:"created_by"` + Remark string `db:"remark" json:"remark,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ActiveCount int `db:"active_count" json:"active_count"` + UsageCount int `db:"usage_count" json:"usage_count"` + ExpiredCount int `db:"expired_count" json:"expired_count"` + RevokedCount int `db:"revoked_count" json:"revoked_count"` +} + +type RegistrationInviteCode struct { + ID int64 `db:"id" json:"id"` + TenantID TenantID `db:"tenant_id" json:"tenant_id"` + BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"` + Code string `db:"code" json:"code"` + Status RegistrationInviteStatus `db:"status" json:"status"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + UsageCount int `db:"usage_count" json:"usage_count"` + LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"` + CreatedBy int64 `db:"created_by" json:"created_by"` + Remark string `db:"remark" json:"remark,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type RegistrationInviteListOptions struct { + Code string + Status RegistrationInviteStatus + BatchID int64 + CreatedBy int64 + CreatedAtStart *time.Time + CreatedAtEnd *time.Time + ExpiresAtStart *time.Time + ExpiresAtEnd *time.Time + Offset int + Limit int +} + +type RegistrationInvitePage struct { + Items []RegistrationInviteCode + Total int +} diff --git a/server/store/types/types.go b/server/store/types/types.go index d9577d0..6dc0d63 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -521,6 +521,8 @@ type User struct { UserAgent string // Canonical mobile number used for registration and account recovery. Phone string + // Primary login account. Ordinary accounts are lowercase ASCII names; phone accounts use E.164. + Username string Public any Trusted any diff --git a/server/tinode.conf b/server/tinode.conf index 4cfb92e..35a6d69 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -46,7 +46,7 @@ "logical_names": [], "basic": { "add_to_tags": false, - "min_login_length": 4, + "min_login_length": 1, "min_password_length": 6 }, "token": { diff --git a/server/user.go b/server/user.go index 38ba5d1..359c9a2 100644 --- a/server/user.go +++ b/server/user.go @@ -34,13 +34,27 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { // Find authenticator for the requested scheme. authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme) - if authhdl == nil { + if authhdl == nil || msg.Acc.Scheme != "basic" { // New accounts must have an authentication scheme s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) logs.Warn.Println("create user: unknown auth handler, sid=", s.sid) return } + account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone) + if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) { + logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid) + s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, + map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"})) + return + } + inviteCode := strings.TrimSpace(msg.Acc.InviteCode) + if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) { + s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, + map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"})) + return + } + // Check if login is unique and compliance with the policy (not too long or too short). authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr} if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok { @@ -52,6 +66,8 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { var user types.User user.TenantID = s.tenantID + user.Username = account + user.Phone = phone var private any // If account state is being assigned, make sure the sender is a root user. @@ -85,9 +101,15 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { // consequently cannot check presence of required credentials. Must do that later. creds := normalizeCredentials(msg.Acc.Cred, true) var registrationPhone string + var registrationTelCredential bool for i := range creds { cr := &creds[i] vld := store.Store.GetValidator(cr.Method) + if vld == nil || !vld.IsInitialized() { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"what": cr.Method})) + return + } normalized, err := vld.PreCheck(cr.Value, cr.Params) if err != nil { logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid) @@ -105,15 +127,40 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { return } registrationPhone = phone + registrationTelCredential = true } } - if registrationPhone == "" && store.Verifications != nil { - logs.Warn.Println("create user: verified phone is required, sid=", s.sid) + if !phoneRegistration && registrationTelCredential { + logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid) s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, - map[string]any{"creds": []string{"tel"}})) + map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"})) return } - user.Phone = registrationPhone + if phoneRegistration && registrationTelCredential && registrationPhone != phone { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"})) + return + } + smsEnabled := registrationSMSEnabled(time.Now().UTC()) + if phoneRegistration && smsEnabled && !registrationTelCredential { + logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid) + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"})) + return + } + inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential) + if inviteRequired && inviteCode == "" { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"})) + return + } + if inviteCode != "" { + if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)})) + return + } + } // Assign default access values in case the acc creator has not provided them user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | @@ -155,7 +202,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { } // Add authentication record. - rec, err := authhdl.AddRecord(authCtx, + rec, err = authhdl.AddRecord(authCtx, &auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret) if err != nil { logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid) @@ -201,6 +248,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { } return } + if registrationTelCredential && !slices.Contains(validated, "tel") { + logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid) + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"})) + if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil { + logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid) + } + return + } + if inviteCode != "" { + requestID := strings.TrimSpace(msg.Id) + if requestID == "" { + requestID = "acc:" + s.sid + } + if len(requestID) > 64 { + requestID = requestID[:64] + } + if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil { + logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid) + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)})) + if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil { + logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid) + } + return + } + dispatchIMBusinessEvents(s.tenantID) + } if msg.Extra != nil && len(msg.Extra.Attachments) > 0 { if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil { @@ -240,6 +315,79 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { pluginAccount(&user, plgActCreate) } +func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) { + if phone != "" { + if len(phone) != 11 || !asciiRegistrationDigits(phone) { + return "", "", false, types.ErrPolicy + } + canonicalPhone, err = normalizeIMPhone(phone, "CN") + if err != nil { + return "", "", false, err + } + return canonicalPhone, canonicalPhone, true, nil + } + if username == "" || len(username) > 10 { + return "", "", false, types.ErrPolicy + } + for i := range username { + if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') { + return "", "", false, types.ErrPolicy + } + } + return username, "", false, nil +} + +func asciiRegistrationDigits(value string) bool { + for i := range value { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return value != "" +} + +func registrationSecretMatches(secret []byte, account string) bool { + separator := strings.IndexByte(string(secret), ':') + return separator > 0 && string(secret[:separator]) == account +} + +func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error { + if store.RegistrationInvites == nil { + return types.ErrInternal + } + invite, err := store.RegistrationInvites.GetByCode(tenantID, code) + if err != nil { + return err + } + if invite == nil { + return types.ErrInviteInvalid + } + switch invite.Status { + case types.RegistrationInviteRevoked: + return types.ErrInviteRevoked + case types.RegistrationInviteExpired: + return types.ErrInviteExpired + case types.RegistrationInviteActive: + if !now.Before(invite.ExpiresAt) { + return types.ErrInviteExpired + } + return nil + default: + return types.ErrInviteInvalid + } +} + +func registrationInviteReason(err error) string { + switch err { + case types.ErrInviteExpired: + return "INVITE_CODE_EXPIRED" + case types.ErrInviteRevoked: + return "INVITE_CODE_REVOKED" + default: + return "INVITE_CODE_INVALID" + } +} + // Process update to an account: // * Authentication update, i.e. login/password change // * Credentials update diff --git a/server/user_registration_test.go b/server/user_registration_test.go new file mode 100644 index 0000000..ab18f33 --- /dev/null +++ b/server/user_registration_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "testing" + "time" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +func TestResolveRegistrationAccount(t *testing.T) { + tests := []struct { + name string + username string + phone string + wantAccount string + wantPhone string + wantPhoneFlow bool + wantErr bool + }{ + {name: "username", username: "alice1", wantAccount: "alice1"}, + {name: "numeric username", username: "12345", wantAccount: "12345"}, + {name: "phone wins", username: "INVALID", phone: "13800138000", wantAccount: "+8613800138000", wantPhone: "+8613800138000", wantPhoneFlow: true}, + {name: "uppercase rejected", username: "Alice", wantErr: true}, + {name: "long username rejected", username: "abcdefghijk", wantErr: true}, + {name: "malformed phone does not fall back", username: "alice", phone: "1380013800", wantErr: true}, + {name: "phone whitespace rejected", username: "alice", phone: " 13800138000", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + account, phone, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone) + if (err != nil) != tc.wantErr || account != tc.wantAccount || phone != tc.wantPhone || phoneFlow != tc.wantPhoneFlow { + t.Fatalf("got account=%q phone=%q phoneFlow=%v err=%v", account, phone, phoneFlow, err) + } + }) + } +} + +func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) { + if !registrationSecretMatches([]byte("alice:password:with-colon"), "alice") { + t.Fatal("matching account was rejected") + } + if registrationSecretMatches([]byte("bob:password"), "alice") || registrationSecretMatches([]byte("password"), "alice") { + t.Fatal("mismatched or malformed secret was accepted") + } +} + +func TestValidateRegistrationInviteStates(t *testing.T) { + oldInvites := store.RegistrationInvites + defer func() { store.RegistrationInvites = oldInvites }() + now := time.Now().UTC() + tests := []struct { + status types.RegistrationInviteStatus + expires time.Time + want error + }{ + {types.RegistrationInviteActive, now.Add(time.Minute), nil}, + {types.RegistrationInviteActive, now, types.ErrInviteExpired}, + {types.RegistrationInviteRevoked, now.Add(time.Minute), types.ErrInviteRevoked}, + {types.RegistrationInviteExpired, now.Add(time.Minute), types.ErrInviteExpired}, + } + for _, tc := range tests { + store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{Status: tc.status, ExpiresAt: tc.expires}} + if got := validateRegistrationInvite(7, "AbCdE", now); got != tc.want { + t.Fatalf("status=%q: got %v want %v", tc.status, got, tc.want) + } + } +} diff --git a/server/user_verification_test.go b/server/user_verification_test.go index e750b02..60f9ce0 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -173,8 +173,17 @@ func TestServeIMPasswordResetConfirm(t *testing.T) { ExpiresAt: time.Now().UTC().Add(time.Minute), }} users := mock_store.NewMockUsersPersistenceInterface(ctrl) - users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic"). + users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)). Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil) + users.EXPECT().Get(types.TenantID(7), types.Uid(42)). + Return(&types.User{Phone: "+8613800138000"}, nil) + users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})). + DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) { + if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done { + t.Fatalf("unexpected reset credential: %#v", cred) + } + return true, nil + }) store.Users = users basicAuth := mock_auth.NewMockAuthHandler(ctrl) basicAuth.EXPECT().IsInitialized().Return(true) diff --git a/server/validate/tel/validate.go b/server/validate/tel/validate.go index 6a371dd..4030ab1 100644 --- a/server/validate/tel/validate.go +++ b/server/validate/tel/validate.go @@ -214,6 +214,31 @@ func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string, return nil } +// SendCode synchronously delivers a product verification code. Unlike the +// legacy credential flow, it fails when no real SMS provider is configured. +func (v *validator) SendCode(_ t.TenantID, phone, lang, scene, code string) error { + if v.Twilio == nil { + return t.ErrUnsupported + } + var template *textt.Template + if v.langMatcher != nil { + _, idx := i18n.MatchStrings(v.langMatcher, lang) + template = v.universalTempl[idx] + } else if len(v.universalTempl) > 0 { + template = v.universalTempl[0] + } + if template == nil { + return t.ErrInternal + } + content, err := validate.ExecuteTemplate(template, nil, map[string]any{ + "Code": code, "HostUrl": v.HostUrl, "Scene": scene, + }) + if err != nil { + return err + } + return twilioSend(v.Sender, phone, content[""]) +} + // Check checks validity of user's response. func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) { cred, err := store.Users.GetActiveCred(tenantID, user, validatorName) diff --git a/server/validate/validator.go b/server/validate/validator.go index c948f8c..76f2ce2 100644 --- a/server/validate/validator.go +++ b/server/validate/validator.go @@ -58,6 +58,12 @@ type Validator interface { TempAuthScheme() (string, error) } +// CodeSender is an optional synchronous delivery capability used by product +// verification challenges which exist before a user account is created. +type CodeSender interface { + SendCode(tenantID t.TenantID, recipient, lang, scene, code string) error +} + func ValidateHostURL(origUrl string) (string, error) { hostUrl, err := url.Parse(origUrl) if err != nil { diff --git a/tinode-db/gendb.go b/tinode-db/gendb.go index def321f..082ace0 100644 --- a/tinode-db/gendb.go +++ b/tinode-db/gendb.go @@ -27,7 +27,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { // Add authentication record authHandler := store.Store.GetAuthHandler("basic") - authHandler.Init([]byte(`{"add_to_tags": true}`), "basic") + authHandler.Init([]byte(`{"add_to_tags": false}`), "basic") nameIndex := make(map[string]string, len(data.Users)) @@ -40,12 +40,13 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { } user := types.User{ TenantID: tenantID, + Username: uu.Username, + Phone: uu.Tel, State: state, Access: types.DefaultAccess{ Auth: types.ModeCAuth, Anon: types.ModeNone, }, - Tags: uu.Tags, Public: parsePublic(&uu.Public, data.datapath), } if !uu.Trusted.IsZero() { @@ -53,14 +54,6 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { } user.CreatedAt = getCreatedTime(uu.CreatedAt) - user.Tags = append(user.Tags, "basic:"+uu.Username) - if uu.Email != "" { - user.Tags = append(user.Tags, "email:"+uu.Email) - } - if uu.Tel != "" { - user.Tags = append(user.Tags, "tel:"+uu.Tel) - } - // store.Users.Create will subscribe user to !me topic but won't create a !me topic if _, err := store.Users.Create(tenantID, &user, uu.Private); err != nil { log.Fatal(err) diff --git a/tinode-db/main.go b/tinode-db/main.go index e809c69..06e34e2 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -321,17 +321,17 @@ func main() { if userId.IsZero() { log.Fatalf("Must specify a valid user ID '%s' to promote to ROOT", *makeRoot) } - login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId, "basic") + login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId) if err != nil { log.Fatalln("Failed to read user authentication record", err) } tenantAdapter, ok := adapter.(interface { - TenantAuthUpdRecord(types.TenantID, types.Uid, string, string, auth.Level, []byte, time.Time) error + TenantAuthUpdateRecord(types.TenantID, types.Uid, string, auth.Level, []byte, time.Time) error }) if !ok { log.Fatalln("Configured adapter does not support tenant authentication") } - if err = tenantAdapter.TenantAuthUpdRecord(tenantID, userId, "basic", "basic:"+login, + if err = tenantAdapter.TenantAuthUpdateRecord(tenantID, userId, login, auth.LevelRoot, nil, time.Time{}); err != nil { log.Fatalln("Failed to promote user to ROOT", err) }