From c89c4663871b3ffe96dfa3466cffe78a5a03dc29 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 21 Aug 2026 11:42:45 +0800 Subject: [PATCH] feat: implement first batch im enhancements --- docs/IM-增强项第一批开发.md | 638 +++++++++++++++++++++++ docs/IM-未实现后续增强项.md | 259 +++++---- server/auth/token/auth_token_test.go | 8 + server/db/adapter.go | 12 + server/db/mysql/adapter.go | 18 + server/db/mysql/infrastructure_schema.go | 27 + server/db/mysql/tenant_adapter.go | 349 +++++++++++++ server/db/mysql/tenant_groups.go | 242 +++++++++ server/im_http.go | 533 ++++++++++++++++++- server/im_http_test.go | 384 ++++++++++++++ server/main.go | 4 + server/store/store.go | 199 +++++++ server/store/types/conversations.go | 37 ++ server/store/types/groups.go | 22 + server/store/types/security.go | 15 + server/user_verification_test.go | 12 + 16 files changed, 2629 insertions(+), 130 deletions(-) create mode 100644 docs/IM-增强项第一批开发.md create mode 100644 server/store/types/conversations.go diff --git a/docs/IM-增强项第一批开发.md b/docs/IM-增强项第一批开发.md new file mode 100644 index 0000000..ea8b1bd --- /dev/null +++ b/docs/IM-增强项第一批开发.md @@ -0,0 +1,638 @@ +# IM 增强项第一批开发 + +> 编写日期:2026-08-21 +> 来源依据:`docs/IM-未实现后续增强项.md`、`docs/IM-前后端对接文档.md`、当前 `server/main.go` 和 `server/im_http.go` 路由实现。 +> 目标:从后续增强项中筛选出当前可以直接开发、且不依赖短信服务商、FCM/APNs、TURN/STUN、SFU、病毒扫描服务等第三方能力的内容,作为第一批排期范围。 + +## 1. 筛选原则 + +第一批只纳入满足以下条件的增强项: + +1. 只依赖当前 IM 服务、Tinode/IM 租户库、HotGo 控制库或已有 Redis/MySQL 基础设施。 +2. 不依赖真实短信供应商、离线推送供应商、音视频媒体服务、病毒扫描引擎等第三方服务。 +3. 优先补齐前后端对接文档中已定义,但当前代码未实现或实现不完整的接口。 +4. 优先选择能直接提升前端联调完整度、生产可用性和接口一致性的项目。 + +以下内容不进入第一批: + +1. 真实短信发送、短信模板、供应商回执和供应商失败重试。 +2. FCM/APNs 离线 Push 投递和供应商错误码处理。 +3. TURN/STUN 实际服务接入、SFU、群音视频媒体房间。 +4. 第三方病毒扫描或文件内容安全服务。 +5. 必须依赖 HotGo 管理后台页面完整改造才能闭环的运营后台功能。 + +## 2. 第一批开发范围总览 + +### 2.1 当前开发进度 + +> 进度更新:2026-08-21 + +| 开发项 | 状态 | 备注 | +| --- | --- | --- | +| 组织全局成员接口 | 已实现 | 新增 `GET /v1/im/organization/members`,支持部门、关键词、分页和 `has_more`。 | +| 月报 reopen | 已实现 | 新增 `POST /v1/im/reports/monthly/{id}/reopen`,按本人报告和 version 回退草稿。 | +| 实名更正申诉 | 已实现 | 新增 `POST /v1/im/security/realname/appeals` 和 `im_realname_appeals` 表。 | +| 群邀请外部接受 | 已实现 | 新增 `POST /v1/im/group-invitations/{id}/accept`。 | +| 主动创建/获取 P2P 会话 | 已实现 | 新增 `POST /v1/im/conversations/p2p`,复用好友/黑名单策略。 | +| 会话设置 | 已实现 | 新增 `GET/PATCH /v1/im/conversations/{topic}/settings` 和 `im_conversation_settings` 表。 | +| 多端云草稿 | 已实现 | 新增 `GET/PUT /v1/im/topics/{topic}/draft` 和 `im_topic_drafts` 表。 | +| 群文件列表、登记、删除 | 已实现 | 新增 `GET/POST/DELETE /v1/im/groups/{topic}/files`,复用现有群文件表。 | +| 普通消息转发 | 未实现 | 待下一步接入消息快照、目标权限校验和消息落库。 | +| 合并转发 Bundle 详情 | 未实现 | 待下一步新增 bundle 表和详情接口。 | +| 登出与指定会话撤销 | 未实现 | 当前 token 缺少独立 session 标识,需要先统一 session 模型。 | +| 列表 `has_more` 统一 | 部分实现 | 新增/触达接口已补,历史接口需继续横向清理。 | +| 风控 token 单次消费接入业务接口 | 未实现 | 待和具体业务动作一起接入。 | + +### 2.2 范围表 + +| 模块 | 优先级 | 开发项 | 是否依赖第三方 | 当前状态 | +| --- | --- | --- | --- | --- | +| 账号安全 | P0 | 登出与指定会话撤销 | 否 | 文档有接口,代码未见路由 | +| 消息转发 | P0 | 普通消息转发 | 否 | 仅转发目标接口已实现 | +| 消息转发 | P0 | 合并转发 Bundle 详情 | 否 | 文档有接口,代码未见路由 | +| 群聊 | P0 | 群文件列表、登记、删除 | 否 | 路径已不误兜底,但业务未实现 | +| 单聊 | P1 | 主动创建/获取 P2P 会话 | 否 | 文档有接口,代码未见路由 | +| 会话 | P1 | 会话设置 | 否 | 文档有接口,代码未见路由 | +| 会话 | P1 | 多端云草稿 | 否 | 文档有接口,代码未见路由 | +| 群聊 | P1 | 群邀请外部接受 | 否 | 群内邀请已有,外部接受接口未见路由 | +| 安全实名 | P1 | 实名更正申诉 | 否 | 实名提交/查询已有,申诉未见路由 | +| 组织架构 | P1 | 组织全局成员接口 | 否 | 部门成员和组织搜索已有,独立全局成员接口未见路由 | +| 办公协同 | P1 | 月报 reopen | 否 | 月报草稿/提交/详情已有,reopen 未见路由 | +| 协议一致性 | P1 | 列表 `has_more` 统一 | 否 | 部分列表仍只返回 `items,next_cursor` | +| 安全风控 | P1 | 风控 token 单次消费接入业务接口 | 否 | 可签发 `risk_action_token`,业务消费仍需补齐 | + +## 3. 详细需求 + +### 3.1 登出与指定会话撤销 + +#### 目标 + +补齐客户端退出登录和安全页踢出指定设备能力,避免只能通过 `auth_version` 做全量失效。 + +#### 接口 + +1. `POST /v1/im/auth/logout` + - 退出当前登录设备。 + - 需要使当前 token 对应 session 失效。 + - 不影响同账号其他设备。 + +2. `DELETE /v1/im/security/sessions/{id}` + - 撤销指定 session。 + - 只能撤销本人 session。 + - 撤销后该 session 对应 token 立即不可用。 + +#### 数据建议 + +如当前没有独立 session 表,建议新增: + +```sql +CREATE TABLE im_user_sessions ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + session_id VARCHAR(64) NOT NULL, + device_id VARCHAR(128) NOT NULL DEFAULT '', + platform VARCHAR(32) NOT NULL DEFAULT '', + token_hash VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + last_seen_at DATETIME(3) NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_im_user_sessions_session (tenant_id, session_id), + KEY idx_im_user_sessions_user_status (tenant_id, user_id, status) +); +``` + +主键使用已有雪花 ID 工具生成。 + +#### 验收点 + +1. 当前设备登出后,当前 token 访问登录态接口返回未登录或 token 失效。 +2. 其他设备 token 不受影响。 +3. 删除指定 session 后,被删除设备无法继续访问。 +4. 重复登出或重复删除返回幂等成功或稳定错误码。 + +### 3.2 普通消息转发 + +#### 目标 + +补齐普通聊天消息转发能力。现有 `GET /v1/im/forward-targets` 只解决目标选择,不等同于真正转发。 + +#### 接口 + +`POST /v1/im/messages/forward` + +建议请求字段: + +```json +{ + "source_topic": "grp_xxx", + "message_seqs": [101, 102], + "target_topics": ["usrA-usrB", "grp_yyy"], + "mode": "single", + "client_mid": "uuid" +} +``` + +`mode` 建议支持: + +1. `single`:逐条转发。 +2. `bundle`:合并转发,生成转发卡片和 bundle。 + +#### 关键规则 + +1. 校验当前用户对源消息有查看权限。 +2. 校验当前用户对每个目标会话有发送权限。 +3. 不允许转发已被策略禁止转发的消息类型。 +4. 文件消息需要重新校验文件访问权限。 +5. 使用 HTTP 幂等能力,避免重复转发。 + +#### 验收点 + +1. 单条消息可转发到好友、群和文件助手。 +2. 多条消息可逐条转发。 +3. 无权限源消息、无权限目标会话返回稳定错误。 +4. 同一幂等键重试不会重复投递。 + +### 3.3 合并转发 Bundle + +#### 目标 + +支持合并转发卡片详情查看,使客户端可以打开“聊天记录”样式的转发包。 + +#### 接口 + +`GET /v1/im/forward-bundles/{id}` + +建议响应结构: + +```json +{ + "id": "fb_123", + "title": "聊天记录", + "source_summary": { + "topic": "grp_xxx", + "message_count": 12 + }, + "items": [], + "next_cursor": "", + "has_more": false +} +``` + +#### 数据建议 + +建议新增: + +```sql +CREATE TABLE im_forward_bundles ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + bundle_no VARCHAR(64) NOT NULL, + owner_user_id BIGINT NOT NULL, + source_topic VARCHAR(128) NOT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + message_count INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_im_forward_bundles_no (tenant_id, bundle_no), + KEY idx_im_forward_bundles_owner (tenant_id, owner_user_id, created_at) +); + +CREATE TABLE im_forward_bundle_items ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + bundle_id BIGINT NOT NULL, + source_topic VARCHAR(128) NOT NULL, + source_seq INT NOT NULL, + sender_user_id BIGINT NOT NULL, + content_json JSON NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + KEY idx_im_forward_bundle_items_bundle (tenant_id, bundle_id, id) +); +``` + +#### 验收点 + +1. 合并转发后生成 bundle。 +2. 接收方可分页查看 bundle 内容。 +3. Bundle 内容使用生成时快照,不因源消息后续不可见而展示异常。 +4. 原始会话无权限用户不能直接借 bundle 访问源会话。 + +### 3.4 群文件 + +#### 目标 + +补齐群文件列表、上传登记和删除管理能力。当前 `/groups/{topic}/files` 已不再误返回群详情,但业务仍缺失。 + +#### 接口 + +1. `GET /v1/im/groups/{topic}/files` + - 支持分页、关键词、文件类型筛选。 + +2. `POST /v1/im/groups/{topic}/files` + - 将已上传文件登记为群文件。 + +3. `DELETE /v1/im/groups/{topic}/files/{file_id}` + - 删除或移除群文件。 + +#### 数据建议 + +可复用现有文件上传表,并新增群文件索引表: + +```sql +CREATE TABLE im_group_files ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + topic VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + uploader_user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + mime VARCHAR(128) NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + KEY idx_im_group_files_topic (tenant_id, topic, status, created_at), + UNIQUE KEY uk_im_group_files_file (tenant_id, topic, file_id) +); +``` + +#### 权限规则 + +1. 只有群成员可查看群文件。 +2. 只有群成员可登记群文件。 +3. 删除权限建议第一批按群主、管理员、上传者放行。 +4. 退群后不可继续通过群文件入口访问。 + +#### 验收点 + +1. 群成员可分页查看群文件。 +2. 上传者登记文件后列表可见。 +3. 普通成员不能删除其他人的文件,管理员和群主可以删除。 +4. 非群成员访问返回无权限。 + +### 3.5 主动创建/获取 P2P 会话 + +#### 目标 + +补齐前端从好友资料页或通讯录进入单聊时的显式会话创建入口。 + +#### 接口 + +`POST /v1/im/conversations/p2p` + +建议请求: + +```json +{ + "peer_user_id": "usr_xxx" +} +``` + +#### 规则 + +1. 必须校验双方好友关系。 +2. 必须校验黑名单状态。 +3. 如果 P2P topic 已存在,直接返回已有 topic。 +4. 如果不存在,创建或确保 Tinode P2P 会话关系存在。 + +#### 验收点 + +1. 好友之间调用返回稳定 P2P topic。 +2. 非好友或黑名单关系返回不可发起会话。 +3. 重复调用返回同一个 topic。 + +### 3.6 会话设置 + +#### 目标 + +支持置顶、免打扰、会话备注等跨端同步设置。 + +#### 接口 + +1. `GET /v1/im/conversations/{topic}/settings` +2. `PATCH /v1/im/conversations/{topic}/settings` + +建议字段: + +```json +{ + "pinned": true, + "muted": false, + "remark": "", + "version": 3 +} +``` + +#### 数据建议 + +```sql +CREATE TABLE im_conversation_settings ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + topic VARCHAR(128) NOT NULL, + pinned TINYINT(1) NOT NULL DEFAULT 0, + muted TINYINT(1) NOT NULL DEFAULT 0, + remark VARCHAR(128) NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_im_conversation_settings_user_topic (tenant_id, user_id, topic) +); +``` + +#### 验收点 + +1. 设置后同账号其他端读取一致。 +2. 版本冲突返回稳定错误或按后写覆盖策略处理。 +3. 非会话参与者不可设置。 + +### 3.7 多端云草稿 + +#### 目标 + +支持同账号多端同步草稿。 + +#### 接口 + +`PUT /v1/im/topics/{topic}/draft` + +建议请求: + +```json +{ + "content": "draft text", + "entities": [], + "version": 1 +} +``` + +#### 数据建议 + +```sql +CREATE TABLE im_topic_drafts ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + topic VARCHAR(128) NOT NULL, + content TEXT NULL, + entities_json JSON NULL, + version BIGINT NOT NULL DEFAULT 1, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_im_topic_drafts_user_topic (tenant_id, user_id, topic) +); +``` + +#### 验收点 + +1. 保存草稿后其他端可读取。 +2. 清空草稿后其他端同步清空。 +3. 非会话参与者不可写入草稿。 + +### 3.8 群邀请外部接受 + +#### 目标 + +支持被邀请人从邀请卡片或外部入口接受群邀请。 + +#### 接口 + +`POST /v1/im/group-invitations/{id}/accept` + +#### 规则 + +1. 校验邀请存在、未过期、未失效。 +2. 校验当前登录用户是被邀请人。 +3. 按群治理策略决定直接入群或进入审批。 +4. 重复接受应幂等返回当前状态。 + +#### 验收点 + +1. 被邀请人可接受有效邀请。 +2. 非被邀请人不可接受。 +3. 已过期或已撤销邀请返回稳定错误。 +4. 需要审批的群不会绕过审批。 + +### 3.9 实名更正申诉 + +#### 目标 + +实名审核驳回或用户发现实名信息有误时,可提交更正申诉。 + +#### 接口 + +`POST /v1/im/security/realname/appeals` + +建议请求: + +```json +{ + "reason": "证件姓名填写错误", + "attachments": ["file_1"] +} +``` + +#### 数据建议 + +```sql +CREATE TABLE im_realname_appeals ( + id BIGINT NOT NULL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + attachments_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + reviewer_id BIGINT NULL, + review_comment VARCHAR(500) NOT NULL DEFAULT '', + reviewed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + KEY idx_im_realname_appeals_user (tenant_id, user_id, created_at), + KEY idx_im_realname_appeals_status (tenant_id, status, created_at) +); +``` + +#### 验收点 + +1. 用户可提交申诉。 +2. 重复待处理申诉按策略限制。 +3. 申诉记录可供后续后台审核使用。 + +### 3.10 组织全局成员接口 + +#### 目标 + +补齐前端需要全局组织成员列表时的统一入口,避免只能从部门成员或搜索接口拼装。 + +#### 接口 + +`GET /v1/im/organization/members` + +建议查询参数: + +1. `department_id` +2. `keyword` +3. `cursor` +4. `limit` + +#### 规则 + +1. 复用组织成员表和组织搜索能力。 +2. 按租户、部门、成员状态过滤。 +3. 字段返回遵循现有隐私策略。 + +#### 验收点 + +1. 不传部门时返回全局成员分页。 +2. 传部门时返回指定部门成员。 +3. keyword 可搜索姓名、账号、手机号等允许搜索字段。 +4. 返回 `items,next_cursor,has_more`。 + +### 3.11 月报 reopen + +#### 目标 + +支持已提交月报按权限退回草稿,便于用户修正后重新提交。 + +#### 接口 + +`POST /v1/im/reports/monthly/{id}/reopen` + +#### 规则 + +1. 只能 reopen 本人的月报,或由具备管理权限的用户 reopen。 +2. 已归档、已锁定周期不可 reopen。 +3. reopen 后状态回到草稿或待修改状态。 +4. 保留审计记录。 + +#### 验收点 + +1. 已提交月报可按权限 reopen。 +2. reopen 后可以继续保存草稿并重新提交。 +3. 无权限用户不可 reopen 他人月报。 + +### 3.12 列表 `has_more` 统一 + +#### 目标 + +统一所有 HTTP 列表接口分页协议,减少前端针对每个接口单独判断。 + +#### 范围 + +第一批建议优先覆盖新增接口,以及以下已有高频接口: + +1. 好友列表、新朋友列表、黑名单列表。 +2. 群列表、群成员、群申请、群公告。 +3. 会议列表、日报列表、月报列表。 +4. 系统通知、举报/反馈相关列表。 +5. 搜索类接口。 + +#### 响应要求 + +```json +{ + "items": [], + "next_cursor": "", + "has_more": false +} +``` + +#### 验收点 + +1. 空列表返回 `items: []`、`has_more: false`。 +2. 最后一页返回 `has_more: false`。 +3. 有下一页时返回非空 `next_cursor` 和 `has_more: true`。 + +### 3.13 风控 token 单次消费 + +#### 目标 + +当前风控确认接口可签发 `risk_action_token`,第一批补齐业务接口消费该 token 的闭环。 + +#### 范围 + +优先接入以下业务动作: + +1. 打开或发送高风险 URL。 +2. 高频添加好友。 +3. 高频群邀请或入群申请。 +4. 举报提交中的高风险目标。 + +#### 规则 + +1. token 必须绑定租户、用户、动作、目标和过期时间。 +2. token 只能消费一次。 +3. token 只能用于原业务动作,不可跨动作复用。 + +#### 验收点 + +1. 未确认风险时业务接口返回 challenge。 +2. 确认后携带 token 可继续原动作。 +3. token 重复使用返回已消费。 +4. token 用于其他动作返回无效。 + +## 4. 建议开发顺序 + +### 第一阶段:低风险补齐路由 + +1. 组织全局成员接口。 +2. 月报 reopen。 +3. 实名更正申诉。 +4. 群邀请外部接受。 + +这些接口业务边界相对清楚,可以最快完成前端缺口补齐。 + +### 第二阶段:会话能力 + +1. 主动创建/获取 P2P 会话。 +2. 会话设置。 +3. 多端云草稿。 +4. 登出与指定会话撤销。 + +这些能力涉及用户状态和多端一致性,建议一起设计数据表和事件同步。 + +### 第三阶段:消息与文件增强 + +1. 普通消息转发。 +2. 合并转发 Bundle。 +3. 群文件。 +4. 收藏分享权限二次校验。 +5. 文件下载短时签名 URL。 + +这些能力涉及消息权限、文件权限和幂等,建议放在同一阶段统一处理。 + +### 第四阶段:协议一致性与风控闭环 + +1. 列表 `has_more` 统一。 +2. 风控 token 单次消费。 +3. 好友申请、入群申请、@ 全体等限频。 +4. 二维码吊销与扫码审计。 +5. 关系操作审计。 + +这一阶段适合在主要业务接口补齐后做横向治理。 + +## 5. 本批次不处理但需保留的增强项 + +以下增强项仍然重要,但不纳入第一批: + +1. 真实短信供应商发送、失败重试和发送审计。 +2. FCM/APNs 离线 Push 投递。 +3. TURN/STUN 配置实际接入和 SFU 群通话。 +4. 病毒扫描和第三方内容安全检测。 +5. HotGo 管理后台完整审核流、组织维护界面、信箱后台流转界面。 +6. 大规模性能压测和专项压测报告。 + +这些项目建议作为第二批或外部依赖就绪后的专项处理。 + +## 6. 文档同步要求 + +每完成一个开发项,需要同步更新: + +1. `docs/IM-前后端对接文档.md` +2. `docs/IM-系统设计文档.md` +3. `docs/IM-未实现后续增强项.md` +4. 必要时补充数据库变更到 `docs/IM-数据库变更汇总.sql` + +已经完成的增强项应从 `IM-未实现后续增强项.md` 的待办中移除或标记为已进入基线,避免重复排期。 diff --git a/docs/IM-未实现后续增强项.md b/docs/IM-未实现后续增强项.md index 381490d..576dcc9 100644 --- a/docs/IM-未实现后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -1,116 +1,161 @@ # IM 第一至十一章后续增强项 -> 更新日期:2026-08-17 -> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈 -> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 -> 状态说明:2026-08-17 已完成的 P0/P1 已移入各章“当前已完成基线”,不再作为后续待办。完整修复记录见 `IM-需求实现情况审核报告.md`。 +> 更新日期:2026-08-21 +> 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。 +> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。 +> 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。 + +## 0. 本次核对结论 + +### 0.1 已从待办移入基线 + +1. 注册、找回密码和敏感操作验证码在短信未接入时均可用固定测试码 `123456` 完成联调;真实短信通道仍是后续增强项。 +2. `GET /v1/im/bootstrap` 已返回 `entries`、`entry_badges`、`modules`、`message_policy`、`system_topic`、`security` 和 `limits.max_file_size/max_text_length/max_forward_targets`。 +3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。 +4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。 +5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。 +6. `GET /v1/im/groups/{topic}/files` 和 `/announcements` 不再误落到群详情;未实现路径返回 404。 +7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。 +8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。 +9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。 + +### 0.2 当前仍明确缺口及分流状态 + +| 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 | +| --- | --- | --- | --- | --- | +| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 | +| P0 | 群文件完整接口 | 6.27、6.28 | `/groups/{topic}/files` 已不误兜底,但列表/登记/删除未实现 | 已抽入 `IM-增强项第一批开发.md` | +| P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` | +| P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | +| P1 | 单聊主动建会话/会话设置/云草稿 | 3.2、4.5、4.21 | `GET /conversations/{topic}/context` 已有,`POST /conversations/p2p`、`/settings`、`PUT /topics/{topic}/draft` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | +| P1 | 群邀请外部接受接口 | 6.8 | 群内 `/groups/{topic}/invitations` 已有,`POST /v1/im/group-invitations/{id}/accept` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | +| P1 | 实名更正申诉 | 1.9、10.22 | `POST /v1/im/security/realname/appeals` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | +| P1 | 组织全局成员接口 | 5.1、9.2 | 部门成员与组织搜索已具备,`GET /v1/im/organization/members` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | +| P1 | 月报 reopen | 9.10 | 月报草稿/提交/详情已具备,`POST /reports/monthly/{id}/reopen` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | + +### 0.3 已抽入第一批开发的项目 + +以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。它们当前代码上仍未完全落地,但不再按“未分流的未实现项”处理,后续应以第一批开发文档作为实现、验收和同步更新依据。 + +1. 登出与指定会话撤销。 +2. 普通消息转发。 +3. 合并转发 Bundle 详情。 +4. 群文件列表、登记和删除。 +5. 主动创建/获取 P2P 会话。 +6. 会话设置。 +7. 多端云草稿。 +8. 群邀请外部接受。 +9. 实名更正申诉。 +10. 组织全局成员接口。 +11. 月报 reopen。 +12. 列表 `has_more` 统一。 +13. 风控 token 单次消费接入业务接口。 ## 1. 第一章:登录、注册与安全 ### 1.1 当前已完成基线 1. 企业码解析、租户状态校验和公开租户配置返回。 -2. 注册、登录前验证码发送与验证。 -3. 注册验证码 Token 消费,避免绕过验证码直接注册。 -4. 忘记密码挑战验证与重置确认。 -5. Token 绑定 `auth_version`,密码重置后旧 Token 失效。 -6. 登录后敏感操作二次验证挑战。 -7. 实名认证状态查询与提交,证件信息脱敏/保护存储。 -8. 独立密码设置与验证,包含弱密码规则和失败锁定。 -9. 登录后 bootstrap 返回实名、安全与菜单基础能力。 -10. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 -11. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 -12. 风险确认已生成可重试安全事件和用户系统通知。 +2. WebSocket `hi` 租户绑定、`basic` 登录和 `{acc user:new}` 注册已具备。 +3. 注册支持用户名/手机号二选一;手机号注册写入 `im_users.phone`,用户名写入 `im_users.username`。 +4. 用户名格式校验、手机号规范化、服务端随机用户名生成规则已具备。 +5. 注册验证码发码、校验、一次性 `verification_token` 消费已具备。 +6. 找回密码挑战验证与重置确认已具备。 +7. 敏感操作二次验证挑战和 `action_token` 签发已具备。 +8. 短信未接入时注册、找回密码、敏感操作均可用测试码 `123456` 联调。 +9. 邀请码主数据在 HotGo 控制库,注册明细在 IM 租户库,支持可复用邀请码。 +10. Token 绑定 `auth_version`,密码重置后旧 Token 失效。 +11. 实名状态查询与提交已具备,证件信息脱敏/保护存储。 +12. 独立密码设置与验证已具备,包含弱密码规则和失败锁定。 +13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 +14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 +15. 风险确认可生成安全事件和系统通知。 ### 1.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 | -| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | -| P1 | 安全事件通知覆盖扩展 | 风险确认已接入统一事件和系统通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件且重试不重复入箱。 | -| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 | -| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 | -| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 | +| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 | +| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | +| P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | +| P1 | 实名审核与申诉 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过状态变更和 `realname/appeals`。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有工单状态。 | +| P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 | | P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 | -| P2 | 风控与反诈策略接入 | 第一章安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险动作触发二次验证或反诈提示,低风险动作直通。 | +| P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 | ## 2. 第二章:消息首页、全局导航与系统通知 ### 2.1 当前已完成基线 -1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。 -2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。 -3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。 -4. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。 -5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。 -6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。 -7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。 -8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。 -9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。 -10. 消息编辑/撤回已在事务内写入通用 Outbox,并向 Topic 在线成员派发可去重事件。 -11. 编辑/撤回已接入全局 HTTP 幂等和响应重放。 -12. 好友、群、会议和安全模块已可信生产系统通知。 +1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限、入口、红点和 `message_policy`。 +2. `bootstrap.limits` 已返回 `max_file_size`、`max_text_length`、`max_forward_targets`。 +3. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。 +4. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。 +5. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。 +6. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。 +7. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。 +8. 撤回、编辑接口已具备,并接入幂等与 Outbox。 +9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read` 已具备。 +10. 好友、群、会议和安全模块已可信生产系统通知。 ### 2.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | -| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | -| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 | +| P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 | +| P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 | +| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | | P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | | P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | -| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 | +| P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 | | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | -| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 | ## 3. 第三章:单聊关系、权限与会话上下文 ### 3.1 当前已完成基线 -1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。 +1. `GET /v1/im/conversations/{topic}/context` 返回双方关系、发送能力、对端公开资料和 P2P Topic。 2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。 3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。 4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 -5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。 -6. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 -7. 好友申请/审批关键路径已在业务事务内写入通知和 Outbox,其他可重复关系写由全局 HTTP 幂等保证可补写。 -8. 非好友资料查询已执行 `profile_visibility`。 +5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 +6. 非好友资料查询已执行 `profile_visibility`。 ### 3.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | -| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 | -| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | -| P1 | 资料策略继续扩展 | 好友/非好友可见性已执行,企业字段级可见性和青少年模式仍需纳入同一策略。 | 企业受限字段与青少年用户无法从其他入口绕过策略。 | +| P1 | 主动建立 P2P 会话接口 | 对接文档有 `POST /v1/im/conversations/p2p`,当前未见路由。 | 好友关系成立时可显式获取/创建 P2P topic。 | +| P1 | 会话设置接口 | 对接文档有 `GET/PATCH /v1/im/conversations/{topic}/settings`,当前未见路由。 | 置顶、免打扰、备注、保留策略跨端同步。 | +| P1 | 多端草稿 | 对接文档有 `PUT /v1/im/topics/{topic}/draft`,当前仍建议本地加密保存。 | 同账号多端草稿同步,版本冲突可处理。 | +| P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 | +| P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | | P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | -| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 | -## 4. 第四章:群消息、群成员与 @ 能力 +## 4. 第四章:消息输入、媒体内容、转发与检索 ### 4.1 当前已完成基线 -1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。 -2. 返回成员角色、公开资料、ACL 和是否可被 @。 -3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。 -4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。 -5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。 -6. 群昵称、角色、禁言、成员和公告等群业务变更已写入群 Outbox,并生成在线事件与系统通知。 +1. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。 +2. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。 +3. 群成员列表支持基础分页、搜索和 `mentionable` 过滤。 +4. `GET /v1/im/forward-targets` 已具备,返回文件助手和可转发的会话目标。 +5. 消息撤回、编辑、回执、搜索、上下文定位、媒体列表接口已挂在 `/v1/im/topics/` 下。 +6. 收藏入口与收藏转发基础接口已具备。 ### 4.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 | -| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 | -| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | -| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 | +| P0 | 消息转发接口 | 对接文档有 `POST /v1/im/messages/forward`,当前未见路由;收藏转发不等同普通消息转发。 | 单条/多条消息可分别转发到好友、群和文件助手。 | +| P0 | 合并转发 Bundle | 对接文档有 `GET /v1/im/forward-bundles/{id}`,当前未见路由。 | 合并转发卡片可分页查看,权限隔离原会话。 | +| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在线、离线、多端场景均收到一次提醒。 | +| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频。 | 超限返回稳定错误码,管理员可配置限额。 | +| P1 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | | P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 | -| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 | -| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 | +| P1 | 消息检索稳定游标 | 搜索和媒体列表需要稳定游标,避免新消息插入造成跳页。 | 搜索期间新增消息不影响当前翻页结果。 | ## 5. 第五章:通讯录、好友关系与黑名单 @@ -120,28 +165,25 @@ 2. 新朋友列表、好友申请、通过、拒绝链路已具备。 3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。 4. 添加好友二维码支持短期签名 Token 生成与解析。 -5. 好友个人主页返回关系、资料和操作能力。 +5. 个人主页支持 `user_id/username/public_account` identifier,返回关系、资料和操作能力。 6. 群内非好友个人主页返回群角色、关系、资料和操作能力。 7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。 8. 黑名单列表和批量移出黑名单已具备。 9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。 10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。 -11. 好友个人主页已执行 `profile_visibility`。 -12. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。 -13. 注册手机号以 `im_users.phone` 为真源,邮箱作为 `im_user_profiles.email` 资料字段;旧 `im_users.tags/im_usertags` 已迁移删除,Topic 标签保留。 +11. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。 ### 5.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 | -| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 | -| P1 | 好友资料字段精细裁剪 | 基础 `everyone/friends/nobody` 可见性已执行,后续需按群内关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 | -| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 | +| P1 | 二维码吊销与扫码审计 | 当前二维码是短期签名 Token,主动失效、刷新覆盖、扫码审计需要落库或接 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 | +| P1 | 好友资料字段精细裁剪 | 基础可见性已执行,后续需按群关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 | +| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致。 | | P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 | | P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 | -| P1 | 关系操作审计 | 关键关系系统通知已接入,添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 | -| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 | +| P1 | 关系操作审计 | 添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 | ## 6. 第六章:群聊创建、资料维护与成员治理 @@ -150,25 +192,25 @@ 1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。 2. 群设置、个人群设置、退出群、群昵称接口已具备。 3. 群二维码生成与扫码解析支持 `join_group` 场景。 -4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。 +4. 群内邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。 5. 群公告最新查询、发布/更新、详情和已读接口已具备。 6. 群治理配置 `GET/PATCH /governance` 已具备。 7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。 8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 -9. 12 张 `im_group_*` 业务表已纳入 MySQL 租户库自动初始化,独立租户库模式不保存 `tenant_id`。 -10. 群邀请、入群申请/审批、成员变更、角色变更、群主转让和退群已收口到 MySQL 事务。 -11. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 -12. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 -13. 群公告已使用独立表并记录成员已读时间。 +9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 +10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 +11. 群文件和群公告未实现路径已不再误返回群详情。 ### 6.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | +| P0 | 群文件索引与管理 | 对接文档要求 `GET/POST/DELETE /v1/im/groups/{topic}/files`,当前未实现,只修复了错误兜底。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 | +| P1 | 群邀请外部接受接口 | 对接文档有 `POST /v1/im/group-invitations/{id}/accept`,当前未见路由。 | 被邀请人可通过邀请卡入群或进入审批。 | | P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | | P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | -| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 | | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | +| P1 | 群成员 `user_id` 完整性回归 | 测试报告曾发现 owner 成员 `user_id` 为空,需要专门回归确认。 | 群成员列表所有成员均返回稳定 `usr...`。 | | P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 | ## 7. 第七章:文件下载、收藏管理与聊天记录检索 @@ -176,26 +218,25 @@ ### 7.1 当前已完成基线 1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。 -2. 收藏批量创建和基础分享前的数据结构已具备。 -3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。 -4. 消息上下文定位接口可围绕指定 seq 返回前后消息。 -5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。 -6. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。 +2. 收藏批量创建、批量删除和收藏转发接口已具备。 +3. 转发目标列表 `GET /v1/im/forward-targets` 已具备。 +4. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。 +5. 消息上下文定位接口可围绕指定 seq 返回前后消息。 +6. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。 +7. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。 ### 7.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P1 | 收藏业务去重策略 | 同一 HTTP 请求的超时重试已由全局幂等保证;使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 | +| P0 | 普通消息转发与合并转发 | `/favorites/forward` 已有,但 `/messages/forward`、`/forward-bundles/{id}` 未实现。 | 普通消息可单条/多条/合并转发,Bundle 可分页查看。 | | P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 | | P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 | | P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 | +| P1 | 收藏业务去重策略 | 使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 | | P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 | -| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 | | P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 | | P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 | -| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 | -| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 | ## 8. 第八章:实时语音与视频通话 @@ -213,27 +254,26 @@ | --- | --- | --- | --- | | P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 | | P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 | -| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 | +| P0 | 通话事件 Outbox 与离线 Push | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 | | P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 | | P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 | | P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 | | P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 | | P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 | -| P2 | 通话记录聚合与清理 | 通话记录可按会话、日期、未接分类聚合,并支持保留周期清理。 | 记录列表分页稳定,超过保留期按策略清理。 | ## 9. 第九章:企业工作台与办公协同 ### 9.1 当前已完成基线 1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。 -2. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。 -3. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。 -4. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。 -5. 部门列表 `GET /v1/im/organization/departments`、部门成员 `GET /v1/im/organization/departments/{id}/members`、组织搜索 `GET /v1/im/organization/search` 已具备。 -6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。 -7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 -8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 -9. 工作台 `tenant_code` 已返回租户注册表的公开编码,不再暴露内部 ID。 +2. `GET /v1/im/bootstrap` 已返回工作台总未读 `entry_badges.workbench_total_unread`。 +3. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。 +4. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。 +5. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。 +6. 部门列表、部门成员、组织搜索已具备。 +7. 会议通知创建、列表、详情、更新、取消、提醒、参会回应已具备基线。 +8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 +9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 ### 9.2 后续增强项 @@ -241,25 +281,24 @@ | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 | -| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | +| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | | P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | | P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | -| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 | -| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 | -| P1 | 会议离线 Push 联调 | 会议在线事件、系统通知和 Outbox 已完成,后续需使用真实 Firebase 凭据和移动端验证离线通知。 | 参与人离线时收到一次提醒,点击可跳转至正确会议。 | +| P1 | 组织全局成员接口 | 对接文档提到 `GET /v1/im/organization/members`,当前可由部门成员和组织搜索替代,未见独立路由。 | 前端如需要全局成员列表,可直接分页查询并按部门过滤。 | +| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 | +| P1 | 月报 reopen | 对接文档有 `POST /v1/im/reports/monthly/{id}/reopen`,当前未见路由。 | 已提交月报可按权限退回草稿并保留审计。 | | P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | | P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | | P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | -| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 | -## 10. 个人设置与客户端能力 +## 10. 第十章:个人设置与客户端能力 ### 10.1 当前已完成基线 1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。 2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。 3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。 -4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 +4. 青少年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。 6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。 7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。 @@ -268,15 +307,15 @@ | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | +| P0 | 精准登出与会话撤销 | `auth/logout` 与 `security/sessions/{id}` 未见路由,现有 token 主要靠 `auth_version` 批量失效。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | | P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 | -| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | -| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 | +| P0 | 青少年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 | | P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 | | P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 | -| P1 | 隐私策略入口扩展 | 产品主要搜索、资料、回执和最后在线入口已执行隐私设置,组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 | +| P1 | 隐私策略入口扩展 | 组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 | | P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 | -## 11. 反诈安全、举报与意见反馈 +## 11. 第十一章:反诈安全、举报与意见反馈 ### 11.1 当前已完成基线 @@ -302,7 +341,7 @@ ## 12. 统一处理建议 -1. 优先剩余 P0:验证码真实发送与多维限频、消息撤回历史占位、P2P 文件/通话策略统一、@通知与频控、文件安全与短时授权、通话强状态机、工作台/组织权限和风控 token 业务消费。 -2. 再做剩余 P1:租户策略配置、实名审核、设备/会话管理、字段级隐私矩阵、关系审计、群文件与解散状态机、通话质量指标、报表/信箱后台流转和真实 FCM 离线联调。 -3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。 -4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和 `IM-需求实现情况审核报告.md`,避免已完成项继续出现在待办清单。 +1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、群文件、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。 +2. 第二优先级补齐前端文档缺口:`conversations/p2p`、会话设置、云草稿、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。 +3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。 +4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。 diff --git a/server/auth/token/auth_token_test.go b/server/auth/token/auth_token_test.go index eccd23b..9ceb63c 100644 --- a/server/auth/token/auth_token_test.go +++ b/server/auth/token/auth_token_test.go @@ -31,6 +31,10 @@ func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealN return s.err } +func (s testSecurityStore) CreateRealNameAppeal(types.TenantID, *types.RealNameAppeal) (*types.RealNameAppeal, error) { + return nil, s.err +} + func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error { return s.err } @@ -67,6 +71,10 @@ func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, st return nil, "", s.err } +func (s testSecurityStore) CountUnreadSystemNotifications(types.TenantID, types.Uid) (int, error) { + return 0, s.err +} + func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error { return s.err } diff --git a/server/db/adapter.go b/server/db/adapter.go index 98e5e15..957ad34 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -321,6 +321,11 @@ type TenantBusinessAdapter interface { TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error) TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error) + TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error) + TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error) + TenantTopicDraftGet(t.TenantID, t.Uid, string) (*t.TopicDraft, error) + TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error) + TenantCallCreate(t.TenantID, *t.CallRecord) error TenantCallAccept(t.TenantID, string, int, time.Time) error TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error @@ -334,6 +339,7 @@ type TenantBusinessAdapter interface { TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error) TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error) TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error) + TenantGroupInvitationAccept(t.TenantID, string, t.Uid, any, time.Time) (*t.GroupInvitation, error) TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error) TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error) TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error) @@ -343,6 +349,9 @@ type TenantBusinessAdapter interface { TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error) TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error) TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error + TenantGroupFileList(t.TenantID, string, string, string, string, int) ([]t.GroupFile, string, error) + TenantGroupFileCreate(t.TenantID, *t.GroupFile) (*t.GroupFile, error) + TenantGroupFileDelete(t.TenantID, string, string, t.Uid, bool, time.Time) (*t.GroupFile, error) TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error) TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error @@ -355,6 +364,7 @@ type TenantBusinessAdapter interface { TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error) TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error) TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error) + TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error) TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error) TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error) TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error) @@ -374,6 +384,7 @@ type TenantBusinessAdapter interface { TenantMonthlyReportGet(t.TenantID, t.Uid, string) (*t.MonthlyReport, error) TenantMonthlyReportSaveDraft(t.TenantID, t.Uid, string, string, string, []string, int) (*t.MonthlyReport, error) TenantMonthlyReportSubmit(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error) + TenantMonthlyReportReopen(t.TenantID, t.Uid, string, int) (*t.MonthlyReport, error) TenantMonthlyReportStats(t.TenantID, t.Uid, string, string) (*t.MonthlyReportStats, error) TenantMailboxConfigGet(t.TenantID, t.Uid) (*t.MailboxConfig, error) TenantMailboxSubmissionCreate(t.TenantID, t.Uid, *t.MailboxSubmission, string) (*t.MailboxSubmission, error) @@ -422,6 +433,7 @@ type TenantBusinessAdapter interface { TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error) TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error) TenantRealNameCreate(t.TenantID, *t.RealNameVerification) error + TenantRealNameAppealCreate(t.TenantID, *t.RealNameAppeal) (*t.RealNameAppeal, error) TenantActionTokenCreate(t.TenantID, *t.ActionToken) error TenantActionTokenConsume(t.TenantID, t.Uid, string, string, string, time.Time) (*t.ActionToken, error) TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 17206ba..07d7e6f 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1595,6 +1595,24 @@ func createIMSecurityTables(tx *sql.Tx) error { )`); err != nil { return err } + if _, err := tx.Exec( + `CREATE TABLE im_realname_appeals( + id BIGINT NOT NULL, + appeal_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + attachments_json JSON, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + review_comment VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_realname_appeals_appeal(appeal_id), + INDEX idx_im_realname_appeals_user(user_id, created_at), + INDEX idx_im_realname_appeals_status(status, created_at) + )`); err != nil { + return err + } if _, err := tx.Exec( `CREATE TABLE im_action_tokens( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/server/db/mysql/infrastructure_schema.go b/server/db/mysql/infrastructure_schema.go index 80641f0..72fcb3c 100644 --- a/server/db/mysql/infrastructure_schema.go +++ b/server/db/mysql/infrastructure_schema.go @@ -29,6 +29,33 @@ func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error { INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_business_events_outbox_target(target_type, target, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS im_realname_appeals ( + id BIGINT NOT NULL, appeal_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', attachments_json JSON, + status VARCHAR(32) NOT NULL DEFAULT 'pending', review_comment VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE INDEX uk_im_realname_appeals_appeal(appeal_id), + INDEX idx_im_realname_appeals_user(user_id, created_at), + INDEX idx_im_realname_appeals_status(status, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS im_conversation_settings ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL, + pinned TINYINT NOT NULL DEFAULT 0, muted TINYINT NOT NULL DEFAULT 0, + remark VARCHAR(128) NOT NULL DEFAULT '', message_retention VARCHAR(32) NOT NULL DEFAULT 'default', + version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE INDEX uk_im_conversation_settings_user_topic(user_id, topic), + INDEX idx_im_conversation_settings_user_pinned(user_id, pinned, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS im_topic_drafts ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, topic VARCHAR(128) NOT NULL, + content TEXT, entities_json JSON, version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE INDEX uk_im_topic_drafts_user_topic(user_id, topic), + INDEX idx_im_topic_drafts_user_updated(user_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, } for _, statement := range statements { if _, err := exec.Exec(statement); err != nil { diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 7977299..9c2d504 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -2307,6 +2307,74 @@ func (a *adapter) TenantOrganizationMembers(id t.TenantID, uid t.Uid, department return members, nextCursor, nil } +func (a *adapter) TenantOrganizationMembersAll(id t.TenantID, uid t.Uid, departmentID, queryText, cursor string, limit int) ([]t.OrganizationMember, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantOrganizationMembersAll(id, uid, departmentID, queryText, cursor, limit) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"m.employment_state='active'"} + args := []any{} + if strings.TrimSpace(departmentID) != "" { + where = append(where, "m.department_id=?") + args = append(args, strings.TrimSpace(departmentID)) + } + if q := strings.TrimSpace(queryText); q != "" { + like := "%" + q + "%" + where = append(where, "(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)") + args = append(args, like, like, like, like) + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "m.id>?") + args = append(args, parsed) + } + args = append(args, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT m.id,m.department_id,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email, + m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at + FROM im_org_members AS m + WHERE `+strings.Join(where, " AND ")+` + ORDER BY m.sort_order ASC,m.id ASC LIMIT ?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + members := make([]t.OrganizationMember, 0, limit) + nextCursor := "" + for rows.Next() { + member, err := scanOrganizationMember(rows) + if err != nil { + return nil, "", err + } + if len(members) == limit { + nextCursor = strconv.FormatInt(member.ID, 10) + continue + } + members = append(members, *member) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return members, nextCursor, nil +} + func (a *adapter) TenantOrganizationSearch(id t.TenantID, uid t.Uid, queryText, resultTypes, cursor string, limit int) ([]t.OrganizationSearchItem, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -3547,6 +3615,37 @@ func (a *adapter) TenantMonthlyReportSubmit(id t.TenantID, uid t.Uid, reportID s return a.TenantMonthlyReportGet(id, uid, reportID) } +func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID string, version int) (*t.MonthlyReport, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMonthlyReportReopen(id, uid, reportID, version) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.db.ExecContext(ctx, `UPDATE im_monthly_reports + SET status=?,submitted_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE report_id=? AND author_id=? AND status=? AND version=?`, + t.DailyReportStatusDraft, reportID, decodeUID(uid), t.DailyReportStatusSubmitted, version) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.TenantMonthlyReportGet(id, uid, reportID) +} + func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -4627,6 +4726,213 @@ func kvMapFromJSON(value any) t.KVMap { } } +func (a *adapter) TenantConversationSettingsGet(id t.TenantID, uid t.Uid, topic string) (*t.ConversationSettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantConversationSettingsGet(id, uid, topic) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + settings, err := a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic)) + if err == sql.ErrNoRows { + settingsID, idErr := nextSnowflakeID() + if idErr != nil { + return nil, idErr + } + now := t.TimeNow() + _, err = a.db.ExecContext(ctx, `INSERT IGNORE INTO im_conversation_settings + (id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + settingsID, decodeUID(uid), strings.TrimSpace(topic), false, false, "", "default", 1, now, now) + if err != nil { + return nil, err + } + return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic)) + } + if err != nil { + return nil, err + } + return settings, nil +} + +func (a *adapter) TenantConversationSettingsUpdate(id t.TenantID, uid t.Uid, topic string, patch t.ConversationSettingsPatch) (*t.ConversationSettings, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantConversationSettingsUpdate(id, uid, topic, patch) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 { + return nil, t.ErrMalformed + } + if _, err := a.TenantConversationSettingsGet(id, uid, topic); err != nil { + return nil, err + } + sets := []string{} + args := []any{} + if patch.Pinned != nil { + sets = append(sets, "pinned=?") + args = append(args, *patch.Pinned) + } + if patch.Muted != nil { + sets = append(sets, "muted=?") + args = append(args, *patch.Muted) + } + if patch.Remark != nil { + sets = append(sets, "remark=?") + args = append(args, strings.TrimSpace(*patch.Remark)) + } + if patch.MessageRetention != nil { + sets = append(sets, "message_retention=?") + args = append(args, strings.TrimSpace(*patch.MessageRetention)) + } + if len(sets) == 0 { + return a.TenantConversationSettingsGet(id, uid, topic) + } + sets = append(sets, "version=version+1", "updated_at=CURRENT_TIMESTAMP(3)") + args = append(args, decodeUID(uid), strings.TrimSpace(topic), patch.Version) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.db.ExecContext(ctx, `UPDATE im_conversation_settings SET `+strings.Join(sets, ",")+` + WHERE user_id=? AND topic=? AND version=?`, args...) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.conversationSettingsByKey(ctx, uid, strings.TrimSpace(topic)) +} + +func (a *adapter) TenantTopicDraftGet(id t.TenantID, uid t.Uid, topic string) (*t.TopicDraft, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantTopicDraftGet(id, uid, topic) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + draft, err := a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic)) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return draft, err +} + +func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content string, entities []byte, version int) (*t.TopicDraft, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantTopicDraftPut(id, uid, topic, content, entities, version) + } + } + if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + if version == 0 { + draftID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + now := t.TimeNow() + _, err = a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts + (id,user_id,topic,content,entities_json,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json), + version=version+1,updated_at=VALUES(updated_at)`, + draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now) + if err != nil { + return nil, err + } + return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic)) + } + res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts + SET content=?,entities_json=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE user_id=? AND topic=? AND version=?`, + content, entities, decodeUID(uid), strings.TrimSpace(topic), version) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return nil, t.ErrInvalidResponse + } + return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic)) +} + +func (a *adapter) conversationSettingsByKey(ctx context.Context, uid t.Uid, topic string) (*t.ConversationSettings, error) { + row := struct { + ID int64 + UserID int64 `db:"user_id"` + Topic string `db:"topic"` + Pinned bool + Muted bool + Remark string + MessageRetention string `db:"message_retention"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,pinned,muted,remark,message_retention,version,created_at,updated_at + FROM im_conversation_settings WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil { + return nil, err + } + return &t.ConversationSettings{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic, + Pinned: row.Pinned, Muted: row.Muted, Remark: row.Remark, MessageRetention: row.MessageRetention, + Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil +} + +func (a *adapter) topicDraftByKey(ctx context.Context, uid t.Uid, topic string) (*t.TopicDraft, error) { + row := struct { + ID int64 + UserID int64 `db:"user_id"` + Topic string `db:"topic"` + Content string + EntitiesJSON []byte `db:"entities_json"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + }{} + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,user_id,topic,content,entities_json,version,created_at,updated_at + FROM im_topic_drafts WHERE user_id=? AND topic=?`, decodeUID(uid), topic); err != nil { + return nil, err + } + return &t.TopicDraft{ID: row.ID, UserID: store.EncodeUid(row.UserID), UserIDRaw: row.UserID, Topic: row.Topic, + Content: row.Content, EntitiesJSON: row.EntitiesJSON, Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil +} + func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -5821,6 +6127,49 @@ func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification return err } +func (a *adapter) TenantRealNameAppealCreate(id t.TenantID, appeal *t.RealNameAppeal) (*t.RealNameAppeal, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantRealNameAppealCreate(id, appeal) + } + } + if err := validTenant(id); err != nil || appeal == nil || appeal.TenantID != id || appeal.UserID.IsZero() || + strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.Reason) == "" { + return nil, t.ErrMalformed + } + appealID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + now := t.TimeNow() + if appeal.Status == "" { + appeal.Status = "pending" + } + appeal.ID = appealID + appeal.CreatedAt = now + appeal.UpdatedAt = now + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + _, err = a.db.ExecContext(ctx, `INSERT INTO im_realname_appeals + (id,appeal_id,user_id,reason,attachments_json,status,review_comment,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, + appeal.ID, appeal.AppealID, decodeUID(appeal.UserID), appeal.Reason, appeal.AttachmentsJSON, + appeal.Status, appeal.ReviewComment, appeal.CreatedAt, appeal.UpdatedAt) + if isDupe(err) { + return nil, t.ErrDuplicate + } + if err != nil { + return nil, err + } + return appeal, nil +} + func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 83d95c1..1e51e8d 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -398,6 +398,97 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat return inv, nil } +func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string, uid t.Uid, memberPrivate any, now time.Time) (*t.GroupInvitation, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return nil, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupInvitationAccept(id, invitationID, uid, memberPrivate, now) + } + if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + var inv t.GroupInvitation + var handledAt sql.NullTime + err = tx.QueryRowxContext(ctx, `SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result, + join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at + FROM im_group_invitations WHERE invitation_id=? FOR UPDATE`, strings.TrimSpace(invitationID)). + Scan(&inv.ID, &inv.InvitationID, &inv.Topic, &inv.InviterUserRaw, &inv.TargetUserRaw, &inv.Message, &inv.Status, + &inv.Result, &inv.JoinRequestID, &inv.IdempotencyKey, &inv.ExpiresAt, &handledAt, &inv.CreatedAt, &inv.UpdatedAt) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + inv.InviterUserID = store.EncodeUid(inv.InviterUserRaw) + inv.TargetUserID = store.EncodeUid(inv.TargetUserRaw) + if handledAt.Valid { + inv.HandledAt = &handledAt.Time + } + if inv.TargetUserID != uid { + return nil, t.ErrPermissionDenied + } + if inv.ExpiresAt.Before(now) { + return nil, t.ErrExpired + } + if inv.Status == t.GroupInvitationStatusJoined || inv.Result == "joined" { + return &inv, nil + } + var deletedAt sql.NullTime + err = tx.QueryRowxContext(ctx, "SELECT DeletedAt FROM subscriptions WHERE Topic=? AND User=?", inv.Topic, uid.String()).Scan(&deletedAt) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + if err == sql.ErrNoRows || deletedAt.Valid { + sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: inv.Topic, + ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate} + sub.InitTimes() + if err = createSubscription(tx, sub, deletedAt.Valid); err != nil { + return nil, err + } + } + member := &t.GroupMember{Topic: inv.Topic, UserID: uid, Role: t.GroupMemberRoleMember, + MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inv.InviterUserID, JoinedAt: now, + Version: 1, CreatedAt: now, UpdatedAt: now} + if err = upsertGroupMemberTx(tx, member); err != nil { + return nil, err + } + if err = recountGroupMembersTx(tx, inv.Topic); err != nil { + return nil, err + } + if err = updateGroupTopicSubCountTx(tx, inv.Topic); err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations + SET status=?,result=?,handled_at=?,updated_at=? WHERE invitation_id=?`, + t.GroupInvitationStatusJoined, "joined", now, now, inv.InvitationID); err != nil { + return nil, err + } + if err = insertGroupEventTx(tx, inv.Topic, uid, "group.member.joined", map[string]any{ + "user_id": uid.UserId(), "source": "invitation", "invitation_id": inv.InvitationID, + }); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + inv.Status = t.GroupInvitationStatusJoined + inv.Result = "joined" + inv.HandledAt = &now + inv.UpdatedAt = now + return &inv, nil +} + func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRequest) (*t.GroupJoinRequest, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return nil, err @@ -765,6 +856,130 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor return tx.Commit() } +func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cursor string, limit int) ([]t.GroupFile, string, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return nil, "", err + } else if tenantAdp != a { + return tenantAdp.TenantGroupFileList(id, topic, fileType, query, cursor, limit) + } + if strings.TrimSpace(topic) == "" { + return nil, "", t.ErrMalformed + } + where := []string{"topic=?", "status='active'"} + args := []any{strings.TrimSpace(topic)} + if fileType != "" { + where = append(where, "file_type=?") + args = append(args, strings.TrimSpace(fileType)) + } + if query != "" { + where = append(where, "file_name LIKE ?") + args = append(args, "%"+strings.TrimSpace(query)+"%") + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id 500 || len(body.Attachments) > 9 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"}) + return + } + attachments := make([]string, 0, len(body.Attachments)) + for _, raw := range body.Attachments { + fileID := strings.TrimSpace(raw) + if fileID == "" { + continue + } + attachments = append(attachments, fileID) + } + encoded, err := json.Marshal(attachments) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + appeal, err := store.Security.CreateRealNameAppeal(principal.TenantID, &types.RealNameAppeal{ + TenantID: principal.TenantID, + UserID: principal.Uid, + AppealID: "rna_" + token, + Reason: reason, + Attachments: attachments, + AttachmentsJSON: encoded, + Status: "pending", + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, realNameAppealPayload(appeal)) +} + func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1438,6 +1524,7 @@ func serveIMOrganizationDepartments(wrt http.ResponseWriter, req *http.Request) writeIMSuccess(wrt, req, map[string]any{ "items": organizationDepartmentPayloads(items), "next_cursor": nextCursor, + "has_more": nextCursor != "", }) } @@ -1465,6 +1552,35 @@ func serveIMOrganizationDepartmentResource(wrt http.ResponseWriter, req *http.Re "department_id": departmentID, "items": organizationMemberPayloads(items), "next_cursor": nextCursor, + "has_more": nextCursor != "", + }) +} + +func serveIMOrganizationMembers(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + query := strings.TrimSpace(req.URL.Query().Get("q")) + if query == "" { + query = strings.TrimSpace(req.URL.Query().Get("keyword")) + } + departmentID := strings.TrimSpace(req.URL.Query().Get("department_id")) + items, nextCursor, err := store.Organization.ListAllMembers(principal.TenantID, principal.Uid, departmentID, query, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "department_id": departmentID, + "items": organizationMemberPayloads(items), + "next_cursor": nextCursor, + "has_more": nextCursor != "", }) } @@ -1491,6 +1607,7 @@ func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{ "items": organizationSearchPayloads(items), "next_cursor": nextCursor, + "has_more": nextCursor != "", }) } @@ -1981,7 +2098,7 @@ func serveIMMonthlyReports(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor}) + writeIMSuccess(wrt, req, map[string]any{"items": monthlyReportListPayloads(reports), "next_cursor": nextCursor, "has_more": nextCursor != ""}) case http.MethodPost: var body struct { Month string `json:"month"` @@ -2031,6 +2148,9 @@ func serveIMMonthlyReportResource(wrt http.ResponseWriter, req *http.Request) { case "submit": serveIMMonthlyReportSubmit(wrt, req, reportID) return + case "reopen": + serveIMMonthlyReportReopen(wrt, req, reportID) + return } writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } @@ -2097,6 +2217,29 @@ func serveIMMonthlyReportSubmit(wrt http.ResponseWriter, req *http.Request, repo writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) } +func serveIMMonthlyReportReopen(wrt http.ResponseWriter, req *http.Request, reportID string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body struct { + Version int `json:"version"` + } + if !decodeIMJSONBody(wrt, req, &body) { + return + } + report, err := store.MonthlyReports.Reopen(principal.TenantID, principal.Uid, reportID, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, monthlyReportDetailPayload(report, principal.Uid)) +} + func serveIMMonthlyReportStatistics(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -2488,6 +2631,37 @@ func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) } +func serveIMP2PConversation(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imP2PConversationRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + peer := types.ParseUserId(strings.TrimSpace(body.PeerUserID)) + if peer.IsZero() || peer == principal.Uid { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "peer_user_id"}) + return + } + topic, err := store.Conversations.EnsureP2P(principal.TenantID, principal.Uid, peer) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, topic) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) +} + func serveIMForwardTargets(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -2828,6 +3002,8 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) { func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { + case strings.HasSuffix(req.URL.Path, "/draft"): + serveIMTopicDraft(wrt, req) case strings.HasSuffix(req.URL.Path, "/calls"): serveIMTopicCalls(wrt, req) case strings.HasSuffix(req.URL.Path, "/messages/search"): @@ -2847,6 +3023,53 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { } } +func serveIMTopicDraft(wrt http.ResponseWriter, req *http.Request) { + topic := topicFromDraftPath(req.URL.Path) + if topic == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + draft, err := store.Conversations.GetDraft(principal.TenantID, principal.Uid, topic) + if err != nil { + if errors.Is(err, types.ErrNotFound) { + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "content": "", "entities": []any{}, "version": 0}) + return + } + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, topicDraftPayload(draft)) + case http.MethodPut: + var body imTopicDraftRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + encoded, err := json.Marshal(body.Entities) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "entities"}) + return + } + if int64(len([]byte(body.Content))) > globals.maxMessageSize { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"}) + return + } + draft, err := store.Conversations.PutDraft(principal.TenantID, principal.Uid, topic, body.Content, encoded, body.Version) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, topicDraftPayload(draft)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + func serveIMCalls(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -3149,44 +3372,35 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) { } func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) - return - } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return } topic, tail := conversationResourceFromPath(req.URL.Path) if tail == "settings" { - if topic == "" { - writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) - return - } - writeIMSuccess(wrt, req, map[string]any{ - "topic": topic, - "pinned": false, - "mute_notifications": false, - "message_retention": "default", - "blocked": false, - "remark": "", - "version": 1, - "permissions": map[string]any{"can_update": false}, - }) + serveIMConversationSettings(wrt, req, principal, topic) return } if tail == "messages" { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } if topic == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } - writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": ""}) + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": []any{}, "next_cursor": "", "has_more": false}) return } if tail != "context" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "conversation route not found", nil) return } + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } peer := peerFromP2PTopic(topic, principal.Uid) if topic == "" || peer.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) @@ -3200,6 +3414,46 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } +func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, topic string) { + if topic == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + switch req.Method { + case http.MethodGet: + settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, conversationSettingsPayload(settings)) + case http.MethodPatch: + var body imConversationSettingsRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + muted := body.Muted + if muted == nil { + muted = body.MuteNotifications + } + patch := types.ConversationSettingsPatch{ + Pinned: body.Pinned, + Muted: muted, + Remark: body.Remark, + MessageRetention: body.MessageRetention, + Version: body.Version, + } + settings, err := store.Conversations.UpdateSettings(principal.TenantID, principal.Uid, topic, patch) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, conversationSettingsPayload(settings)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: @@ -3510,6 +3764,10 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { serveIMGroupAnnouncements(wrt, req, topic, tail) return } + if tail == "files" || strings.HasPrefix(tail, "files/") { + serveIMGroupFiles(wrt, req, topic, tail) + return + } if tail == "governance" { serveIMGroupGovernance(wrt, req, topic) return @@ -3967,6 +4225,104 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results}) } +func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + access := selfSub.ModeGiven & selfSub.ModeWant + switch { + case tail == "files" && req.Method == http.MethodGet: + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.GroupBusiness.ListFiles(principal.TenantID, topic, + strings.TrimSpace(req.URL.Query().Get("type")), + strings.TrimSpace(req.URL.Query().Get("q")), + strings.TrimSpace(req.URL.Query().Get("cursor")), + limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "topic": topic, + "items": groupFilePayloads(items), + "next_cursor": nextCursor, + "has_more": nextCursor != "", + }) + case tail == "files" && req.Method == http.MethodPost: + idempotencyKey, ok := requireIMIdempotencyKey(wrt, req) + if !ok { + return + } + var body imGroupFileCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + fileID := strings.TrimSpace(body.FileID) + if fileID == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + file, err := store.GroupBusiness.CreateFile(principal.TenantID, &types.GroupFile{ + Topic: topic, + FileID: fileID, + SourceTopic: strings.TrimSpace(body.SourceTopic), + SourceSeq: body.SourceSeq, + UploaderID: principal.Uid, + FileName: strings.TrimSpace(body.FileName), + MIMEType: strings.TrimSpace(body.MIMEType), + FileType: normalizeIMFileType(body.FileType), + FileSize: body.FileSize, + Status: "active", + IdempotencyKey: idempotencyKey, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, groupFilePayload(file)) + case strings.HasPrefix(tail, "files/") && req.Method == http.MethodDelete: + groupFileID := strings.TrimSpace(strings.TrimPrefix(tail, "files/")) + if groupFileID == "" || strings.Contains(groupFileID, "/") { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) + return + } + canManage := access.IsAdmin() || access.IsOwner() + file, err := store.GroupBusiness.DeleteFile(principal.TenantID, topic, groupFileID, principal.Uid, canManage, time.Now().UTC().Round(time.Millisecond)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, groupFilePayload(file)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupInvitationResource(wrt http.ResponseWriter, req *http.Request) { + invitationID, tail, ok := groupInvitationResourcePath(req.URL.Path) + if !ok || tail != "accept" { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group invitation route not found", nil) + return + } + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + now := time.Now().UTC().Round(time.Millisecond) + inv, err := store.GroupBusiness.AcceptInvitation(principal.TenantID, invitationID, principal.Uid, groupMemberPrivate("", false, false, 1, now), now) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) + writeIMSuccess(wrt, req, groupInvitationPayload(inv)) +} + func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) { if req.Method == http.MethodPost { principal, ok := authenticateIMHTTPBearer(wrt, req) @@ -5332,6 +5688,89 @@ func realNamePayload(rv *types.RealNameVerification) map[string]any { return data } +func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any { + if appeal == nil { + return map[string]any{} + } + attachments := appeal.Attachments + if len(attachments) == 0 && len(appeal.AttachmentsJSON) > 0 { + _ = json.Unmarshal(appeal.AttachmentsJSON, &attachments) + } + return map[string]any{ + "appeal_id": appeal.AppealID, + "reason": appeal.Reason, + "attachments": attachments, + "status": appeal.Status, + "created_at": appeal.CreatedAt.Format(time.RFC3339Nano), + } +} + +func groupInvitationPayload(inv *types.GroupInvitation) map[string]any { + if inv == nil { + return map[string]any{} + } + data := map[string]any{ + "invitation_id": inv.InvitationID, + "topic": inv.Topic, + "inviter_id": inv.InviterUserID.UserId(), + "target_user_id": inv.TargetUserID.UserId(), + "status": inv.Status, + "result": inv.Result, + "expires_at": inv.ExpiresAt.Format(time.RFC3339Nano), + } + if inv.HandledAt != nil { + data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano) + } + return data +} + +func groupFilePayloads(files []types.GroupFile) []map[string]any { + items := make([]map[string]any, 0, len(files)) + for i := range files { + items = append(items, groupFilePayload(&files[i])) + } + return items +} + +func groupFilePayload(file *types.GroupFile) map[string]any { + if file == nil { + return map[string]any{} + } + data := map[string]any{ + "group_file_id": file.GroupFileID, + "topic": file.Topic, + "file_id": file.FileID, + "file_name": file.FileName, + "mime_type": file.MIMEType, + "file_type": file.FileType, + "file_size": file.FileSize, + "uploader_id": file.UploaderID.UserId(), + "status": file.Status, + "created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if file.SourceTopic != "" { + data["source_topic"] = file.SourceTopic + } + if file.SourceSeq > 0 { + data["source_seq"] = file.SourceSeq + } + if file.DeletedAt != nil { + data["deleted_at"] = file.DeletedAt.UTC().Format(time.RFC3339Nano) + data["deleted_by"] = file.DeletedBy.UserId() + } + return data +} + +func normalizeIMFileType(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "image", "video", "audio", "document", "file": + return strings.ToLower(strings.TrimSpace(raw)) + default: + return "file" + } +} + func parseIMLimit(raw string, fallback, max int) int { limit := fallback if raw != "" { @@ -5442,6 +5881,40 @@ func conversationPayload(sub types.Subscription) map[string]any { } } +func conversationSettingsPayload(settings *types.ConversationSettings) map[string]any { + if settings == nil { + return map[string]any{} + } + return map[string]any{ + "topic": settings.Topic, + "pinned": settings.Pinned, + "muted": settings.Muted, + "mute_notifications": settings.Muted, + "remark": settings.Remark, + "message_retention": settings.MessageRetention, + "version": settings.Version, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + "permissions": map[string]any{"can_update": true}, + } +} + +func topicDraftPayload(draft *types.TopicDraft) map[string]any { + if draft == nil { + return map[string]any{} + } + var entities any = []any{} + if len(draft.EntitiesJSON) > 0 { + _ = json.Unmarshal(draft.EntitiesJSON, &entities) + } + return map[string]any{ + "topic": draft.Topic, + "content": draft.Content, + "entities": entities, + "version": draft.Version, + "updated_at": draft.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + func forwardTargetPayload(sub types.Subscription) map[string]any { topicType := "" switch types.GetTopicCat(sub.Topic) { @@ -7691,6 +8164,20 @@ func systemNotificationIDFromPath(path string) string { return strings.TrimSpace(eventID) } +func groupInvitationResourcePath(path string) (string, string, bool) { + const marker = "/v1/im/group-invitations/" + idx := strings.Index(path, marker) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], marker) + invitationID, tail, ok := strings.Cut(rest, "/") + if !ok || strings.TrimSpace(invitationID) == "" { + return "", "", false + } + return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true +} + func messageReceiptsPath(path string) (string, int, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) @@ -7731,6 +8218,12 @@ func topicFromCallsPath(path string) string { return topicFromPathWithSuffix(path, prefix, suffix) } +func topicFromDraftPath(path string) string { + const prefix = "/v1/im/topics/" + const suffix = "/draft" + return topicFromPathWithSuffix(path, prefix, suffix) +} + func topicFromPathWithSuffix(path, prefix, suffix string) string { idx := strings.Index(path, prefix) if idx < 0 || !strings.HasSuffix(path, suffix) { diff --git a/server/im_http_test.go b/server/im_http_test.go index 3eb1a6f..304307b 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -121,6 +121,14 @@ type testCallsStore struct { next string } +type testConversationsStore struct { + topic string + settings *types.ConversationSettings + settingsPatch types.ConversationSettingsPatch + draft *types.TopicDraft + putDraftCalled bool +} + type testWorkbenchStore struct { modules []types.WorkbenchModule version int @@ -199,6 +207,7 @@ type testGroupBusinessStore struct { joinRequests []types.GroupJoinRequest announcement *types.GroupAnnouncement mutes []types.GroupMute + files []types.GroupFile invitations []types.GroupInvitation updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch @@ -265,6 +274,19 @@ func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.G return inv, nil } +func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID string, uid types.Uid, _ any, now time.Time) (*types.GroupInvitation, error) { + for i := range s.invitations { + if s.invitations[i].InvitationID == invitationID { + s.invitations[i].TargetUserID = uid + s.invitations[i].Status = types.GroupInvitationStatusJoined + s.invitations[i].Result = "joined" + s.invitations[i].HandledAt = &now + return &s.invitations[i], nil + } + } + return &types.GroupInvitation{InvitationID: invitationID, Topic: "grp_test", TargetUserID: uid, Status: types.GroupInvitationStatusJoined, Result: "joined", ExpiresAt: now.Add(time.Hour), HandledAt: &now}, nil +} + func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) { now := time.Now().UTC() req.CreatedAt = now @@ -328,6 +350,33 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types. return nil } +func (s *testGroupBusinessStore) ListFiles(_ types.TenantID, _ string, _ string, _ string, _ string, _ int) ([]types.GroupFile, string, error) { + return s.files, "", nil +} + +func (s *testGroupBusinessStore) CreateFile(_ types.TenantID, file *types.GroupFile) (*types.GroupFile, error) { + if file.GroupFileID == "" { + file.GroupFileID = "gfile_test" + } + now := time.Now().UTC().Round(time.Millisecond) + file.CreatedAt = now + file.UpdatedAt = now + s.files = append(s.files, *file) + return file, nil +} + +func (s *testGroupBusinessStore) DeleteFile(_ types.TenantID, topic, groupFileID string, actor types.Uid, _ bool, now time.Time) (*types.GroupFile, error) { + for i := range s.files { + if s.files[i].Topic == topic && s.files[i].GroupFileID == groupFileID { + s.files[i].Status = "deleted" + s.files[i].DeletedBy = actor + s.files[i].DeletedAt = &now + return &s.files[i], nil + } + } + return &types.GroupFile{Topic: topic, GroupFileID: groupFileID, Status: "deleted", DeletedBy: actor, DeletedAt: &now, CreatedAt: now, UpdatedAt: now}, nil +} + func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid, message, idempotencyKey string, _ any, now time.Time) (bool, error) { for _, invitation := range s.invitations { @@ -410,6 +459,12 @@ func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, depar return s.members, "", nil } +func (s *testOrganizationStore) ListAllMembers(_ types.TenantID, _ types.Uid, departmentID, queryText, _ string, _ int) ([]types.OrganizationMember, string, error) { + s.departmentID = departmentID + s.query = queryText + return s.members, "", nil +} + func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) { s.query = queryText s.resultTypes = resultTypes @@ -560,6 +615,13 @@ func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string return s.report, nil } +func (s *testMonthlyReportsStore) Reopen(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) { + s.report.Status = types.DailyReportStatusDraft + s.report.SubmittedAt = nil + s.report.Version = version + 1 + return s.report, nil +} + func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) { return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil } @@ -755,6 +817,54 @@ func (s *testCallsStore) List(tenantID types.TenantID, uid types.Uid, topic, cur return s.items, s.next, nil } +func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid) (string, error) { + s.topic = uid.P2PName(peer) + return s.topic, nil +} + +func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) { + if s.settings == nil { + now := time.Now().UTC().Round(time.Millisecond) + s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now} + } + return s.settings, nil +} + +func (s *testConversationsStore) UpdateSettings(_ types.TenantID, _ types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) { + s.settingsPatch = patch + settings, _ := s.GetSettings(0, 0, topic) + if patch.Pinned != nil { + settings.Pinned = *patch.Pinned + } + if patch.Muted != nil { + settings.Muted = *patch.Muted + } + if patch.Remark != nil { + settings.Remark = *patch.Remark + } + if patch.MessageRetention != nil { + settings.MessageRetention = *patch.MessageRetention + } + settings.Version = patch.Version + 1 + settings.UpdatedAt = time.Now().UTC().Round(time.Millisecond) + return settings, nil +} + +func (s *testConversationsStore) GetDraft(_ types.TenantID, _ types.Uid, topic string) (*types.TopicDraft, error) { + if s.draft == nil { + return nil, types.ErrNotFound + } + s.draft.Topic = topic + return s.draft, nil +} + +func (s *testConversationsStore) PutDraft(_ types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) { + s.putDraftCalled = true + now := time.Now().UTC().Round(time.Millisecond) + s.draft = &types.TopicDraft{UserID: uid, Topic: topic, Content: content, EntitiesJSON: entities, Version: version + 1, CreatedAt: now, UpdatedAt: now} + return s.draft, nil +} + func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { for i := range s.items { existing := &s.items[i] @@ -1614,6 +1724,34 @@ func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) { } } +func TestServeIMRealNameAppealCreatesPending(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSecurity := store.Security + security := &testSecurityStore{} + store.Security = security + defer func() { store.Security = oldSecurity }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/appeals", + bytes.NewBufferString(`{"reason":"证件信息需要更正","attachments":["file1"]}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMRealNameAppeals(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if security.realNameAppeal == nil || security.realNameAppeal.Reason != "证件信息需要更正" { + t.Fatalf("appeal was not stored: %#v", security.realNameAppeal) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != "pending" || data["appeal_id"] == "" { + t.Fatalf("unexpected appeal response: %#v", data) + } +} + func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1889,6 +2027,32 @@ func TestServeIMOrganizationDepartmentMembers(t *testing.T) { } } +func TestServeIMOrganizationMembers(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldOrganization := store.Organization + org := &testOrganizationStore{members: []types.OrganizationMember{ + {DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1}, + }} + store.Organization = org + defer func() { store.Organization = oldOrganization }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/members?department_id=dep-sales&q=%E5%BC%A0", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMOrganizationMembers(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) + items := data["items"].([]any) + if len(items) != 1 || org.departmentID != "dep-sales" || org.query != "张" || data["has_more"] != false { + t.Fatalf("unexpected organization members response: %#v department=%q query=%q", data, org.departmentID, org.query) + } +} + func TestServeIMOrganizationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2144,6 +2308,41 @@ func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) { } } +func TestServeIMMonthlyReportReopen(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldMonthlyReports := store.MonthlyReports + now := time.Now().UTC().Round(time.Millisecond) + submittedAt := now.Add(-time.Hour) + reports := &testMonthlyReportsStore{report: &types.MonthlyReport{ + ReportID: "mr_test", + AuthorID: 42, + Month: "2026-08", + Status: types.DailyReportStatusSubmitted, + ReadState: "unread", + Version: 3, + SubmittedAt: &submittedAt, + CreatedAt: now, + UpdatedAt: now, + }} + store.MonthlyReports = reports + defer func() { store.MonthlyReports = oldMonthlyReports }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/reopen", strings.NewReader(`{"version":3}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMMonthlyReportResource(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) + if data["status"] != types.DailyReportStatusDraft || data["action"] != "edit" || reports.report.SubmittedAt != nil { + t.Fatalf("unexpected reopen response: %#v report=%#v", data, reports.report) + } +} + func TestServeIMMonthlyReportStatistics(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -3001,6 +3200,93 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { } } +func TestServeIMGroupInvitationAccept(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 43) + oldGroupBusiness := store.GroupBusiness + groupStore := &testGroupBusinessStore{invitations: []types.GroupInvitation{{ + InvitationID: "ginv_test", + Topic: "grpAlpha", + InviterUserID: 42, + TargetUserID: 43, + Status: "pending", + ExpiresAt: time.Now().UTC().Add(time.Hour), + }}} + store.GroupBusiness = groupStore + defer func() { store.GroupBusiness = oldGroupBusiness }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/group-invitations/ginv_test/accept", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupInvitationResource(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) + if data["invitation_id"] != "ginv_test" || data["status"] != types.GroupInvitationStatusJoined || data["result"] != "joined" { + t.Fatalf("unexpected invitation accept response: %#v", data) + } +} + +func TestServeIMGroupFiles(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldGroupBusiness := store.GroupBusiness + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + groupStore := &testGroupBusinessStore{} + store.Subs = subs + store.GroupBusiness = groupStore + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }() + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, + }, nil).Times(3) + + createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", + strings.NewReader(`{"file_id":"file1","file_name":"方案.pdf","file_type":"document","file_size":123}`)) + createReq.Header.Set("Authorization", "Bearer token-1") + createReq.Header.Set("Idempotency-Key", "gfile-1") + createRec := httptest.NewRecorder() + serveIMGroupResource(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String()) + } + created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any) + if created["file_id"] != "file1" || created["file_type"] != "document" { + t.Fatalf("unexpected create response: %#v", created) + } + + listReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/files", nil) + listReq.Header.Set("Authorization", "Bearer token-1") + listRec := httptest.NewRecorder() + serveIMGroupResource(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String()) + } + listed := decodeIMHTTPResponse(t, listRec).Data.(map[string]any) + if len(listed["items"].([]any)) != 1 || listed["has_more"] != false { + t.Fatalf("unexpected list response: %#v", listed) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha/files/gfile_test", nil) + deleteReq.Header.Set("Authorization", "Bearer token-1") + deleteRec := httptest.NewRecorder() + serveIMGroupResource(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status: got %d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + deleted := decodeIMHTTPResponse(t, deleteRec).Data.(map[string]any) + if deleted["status"] != "deleted" || deleted["deleted_by"] != types.Uid(42).UserId() { + t.Fatalf("unexpected delete response: %#v", deleted) + } +} + func TestServeIMGroupQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -3950,6 +4236,104 @@ func TestServeIMConversationContext(t *testing.T) { } } +func TestServeIMP2PConversation(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldConversations := store.Conversations + oldRelations := store.Relations + peer := types.Uid(2) + conversations := &testConversationsStore{} + store.Conversations = conversations + store.Relations = &testRelationsStore{} + defer func() { + store.Conversations = oldConversations + store.Relations = oldRelations + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/conversations/p2p", strings.NewReader(`{"peer_user_id":"`+peer.UserId()+`"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMP2PConversation(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) + if data["topic"] != types.Uid(1).P2PName(peer) || data["can_send"] != true { + t.Fatalf("unexpected p2p response: %#v topic=%q", data, conversations.topic) + } +} + +func TestServeIMConversationSettings(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldConversations := store.Conversations + topic := types.Uid(1).P2PName(types.Uid(2)) + conversations := &testConversationsStore{} + store.Conversations = conversations + defer func() { store.Conversations = oldConversations }() + + req := httptest.NewRequest(http.MethodPatch, "/v1/im/conversations/"+topic+"/settings", strings.NewReader(`{"pinned":true,"mute_notifications":true,"remark":"同事","version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMConversationContext(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) + if data["pinned"] != true || data["mute_notifications"] != true || data["remark"] != "同事" || data["version"].(float64) != 2 { + t.Fatalf("unexpected settings response: %#v", data) + } + if conversations.settingsPatch.Muted == nil || *conversations.settingsPatch.Muted != true { + t.Fatalf("mute_notifications was not mapped to Muted patch: %#v", conversations.settingsPatch) + } +} + +func TestServeIMTopicDraftPutAndGet(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldMaxMessageSize := globals.maxMessageSize + globals.maxMessageSize = 1024 + defer func() { globals.maxMessageSize = oldMaxMessageSize }() + oldConversations := store.Conversations + topic := "grpDraft" + conversations := &testConversationsStore{} + store.Conversations = conversations + defer func() { store.Conversations = oldConversations }() + + req := httptest.NewRequest(http.MethodPut, "/v1/im/topics/"+topic+"/draft", strings.NewReader(`{"content":"未发送草稿","entities":[{"type":"mention"}],"version":0}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMTopicMessageAction(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("put status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !conversations.putDraftCalled { + t.Fatal("expected draft store to be called") + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["content"] != "未发送草稿" || data["version"].(float64) != 1 { + t.Fatalf("unexpected draft put response: %#v", data) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+topic+"/draft", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMTopicMessageAction(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("get status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data = decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["content"] != "未发送草稿" { + t.Fatalf("unexpected draft get response: %#v", data) + } +} + func TestServeIMFriendRequestCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index 348060d..85bff4b 100644 --- a/server/main.go +++ b/server/main.go @@ -790,11 +790,13 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges) mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify) mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName) + mux.HandleFunc(config.ApiPath+"v1/im/security/realname/appeals", serveIMRealNameAppeals) mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions) mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap) mux.HandleFunc(config.ApiPath+"v1/im/workbench", serveIMWorkbench) mux.HandleFunc(config.ApiPath+"v1/im/organization/departments", serveIMOrganizationDepartments) mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource) + mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers) mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) @@ -822,6 +824,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify) mux.HandleFunc(config.ApiPath+"v1/im/devices/", serveIMDevicePushToken) mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch) + mux.HandleFunc(config.ApiPath+"v1/im/conversations/p2p", serveIMP2PConversation) mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) @@ -830,6 +833,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/calls", serveIMCalls) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) + mux.HandleFunc(config.ApiPath+"v1/im/group-invitations/", serveIMGroupInvitationResource) mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups) mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource) mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes) diff --git a/server/store/store.go b/server/store/store.go index 0260b88..f33a313 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1045,6 +1045,114 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) } +type ConversationsPersistenceInterface interface { + EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error) + GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error) + UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) + GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error) + PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) +} + +type conversationsMapper struct{} + +var Conversations ConversationsPersistenceInterface + +func (conversationsMapper) EnsureP2P(tenantID types.TenantID, uid, peer types.Uid) (string, error) { + if uid.IsZero() || peer.IsZero() || uid == peer { + return "", types.ErrMalformed + } + canSend, _, err := Relations.CanSendP2P(tenantID, uid, peer) + if err != nil { + return "", err + } + if !canSend { + return "", types.ErrPermissionDenied + } + topic := uid.P2PName(peer) + if topic == "" { + return "", types.ErrMalformed + } + now := types.TimeNow() + initiator := &types.Subscription{ + ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, + TenantID: tenantID, + User: uid.String(), + Topic: topic, + ModeWant: types.ModeCP2P, + ModeGiven: types.ModeCP2P, + Private: map[string]any{"peer": peer.UserId(), "created_by": "http"}, + } + invited := &types.Subscription{ + ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now}, + TenantID: tenantID, + User: peer.String(), + Topic: topic, + ModeWant: types.ModeCP2P, + ModeGiven: types.ModeCP2P, + Private: map[string]any{"peer": uid.UserId(), "created_by": "http"}, + } + if err = Topics.CreateP2P(tenantID, initiator, invited); err != nil { + return "", err + } + return topic, nil +} + +func (conversationsMapper) GetSettings(tenantID types.TenantID, uid types.Uid, topic string) (*types.ConversationSettings, error) { + if uid.IsZero() || strings.TrimSpace(topic) == "" { + return nil, types.ErrMalformed + } + if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil { + return nil, err + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantConversationSettingsGet(tenantID, uid, strings.TrimSpace(topic)) +} + +func (conversationsMapper) UpdateSettings(tenantID types.TenantID, uid types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) { + if uid.IsZero() || strings.TrimSpace(topic) == "" || patch.Version <= 0 { + return nil, types.ErrMalformed + } + if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil { + return nil, err + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantConversationSettingsUpdate(tenantID, uid, strings.TrimSpace(topic), patch) +} + +func (conversationsMapper) GetDraft(tenantID types.TenantID, uid types.Uid, topic string) (*types.TopicDraft, error) { + if uid.IsZero() || strings.TrimSpace(topic) == "" { + return nil, types.ErrMalformed + } + if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil { + return nil, err + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantTopicDraftGet(tenantID, uid, strings.TrimSpace(topic)) +} + +func (conversationsMapper) PutDraft(tenantID types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) { + if uid.IsZero() || strings.TrimSpace(topic) == "" || version < 0 { + return nil, types.ErrMalformed + } + if _, err := Subs.Get(tenantID, strings.TrimSpace(topic), uid, false); err != nil { + return nil, err + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantTopicDraftPut(tenantID, uid, strings.TrimSpace(topic), content, entities, version) +} + type CallsPersistenceInterface interface { Create(tenantID types.TenantID, record *types.CallRecord) error Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error @@ -1120,6 +1228,7 @@ type GroupBusinessPersistenceInterface interface { ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error) UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error) CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error) + AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error) CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error) ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error) ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error) @@ -1129,6 +1238,9 @@ type GroupBusinessPersistenceInterface interface { ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error) UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error) DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error + ListFiles(types.TenantID, string, string, string, string, int) ([]types.GroupFile, string, error) + CreateFile(types.TenantID, *types.GroupFile) (*types.GroupFile, error) + DeleteFile(types.TenantID, string, string, types.Uid, bool, time.Time) (*types.GroupFile, error) InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error) Leave(types.TenantID, string, types.Uid, time.Time) error UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error @@ -1248,6 +1360,17 @@ func (groupBusinessMapper) CreateInvitation(tenantID types.TenantID, invitation return tenantAdp.TenantGroupInvitationCreate(tenantID, invitation) } +func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationID string, uid types.Uid, memberPrivate any, now time.Time) (*types.GroupInvitation, error) { + if strings.TrimSpace(invitationID) == "" || uid.IsZero() || now.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now) +} + func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) { if req == nil || req.Topic == "" || req.ApplicantUserID.IsZero() || req.RequestID == "" { return nil, types.ErrMalformed @@ -1353,6 +1476,42 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now) } +func (groupBusinessMapper) ListFiles(tenantID types.TenantID, topic, fileType, query, cursor string, limit int) ([]types.GroupFile, string, error) { + if strings.TrimSpace(topic) == "" { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantGroupFileList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(fileType), strings.TrimSpace(query), strings.TrimSpace(cursor), limit) +} + +func (groupBusinessMapper) CreateFile(tenantID types.TenantID, file *types.GroupFile) (*types.GroupFile, error) { + if file == nil || strings.TrimSpace(file.Topic) == "" || strings.TrimSpace(file.FileID) == "" || file.UploaderID.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupFileCreate(tenantID, file) +} + +func (groupBusinessMapper) DeleteFile(tenantID types.TenantID, topic, groupFileID string, actor types.Uid, canManage bool, now time.Time) (*types.GroupFile, error) { + if strings.TrimSpace(topic) == "" || strings.TrimSpace(groupFileID) == "" || actor.IsZero() || now.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupFileDelete(tenantID, strings.TrimSpace(topic), strings.TrimSpace(groupFileID), actor, canManage, now) +} + func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid, message, idempotencyKey string, private any, now time.Time) (bool, error) { if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() { @@ -1456,6 +1615,7 @@ func (workbenchMapper) Get(tenantID types.TenantID, uid types.Uid) ([]types.Work type OrganizationPersistenceInterface interface { ListDepartments(tenantID types.TenantID, uid types.Uid, parentID, cursor string, limit int) ([]types.OrganizationDepartment, string, error) ListMembers(tenantID types.TenantID, uid types.Uid, departmentID, cursor string, limit int) ([]types.OrganizationMember, string, error) + ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) } @@ -1491,6 +1651,20 @@ func (organizationMapper) ListMembers(tenantID types.TenantID, uid types.Uid, de return tenantAdp.TenantOrganizationMembers(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(cursor), limit) } +func (organizationMapper) ListAllMembers(tenantID types.TenantID, uid types.Uid, departmentID, queryText, cursor string, limit int) ([]types.OrganizationMember, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantOrganizationMembersAll(tenantID, uid, strings.TrimSpace(departmentID), strings.TrimSpace(queryText), strings.TrimSpace(cursor), limit) +} + func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryText, resultTypes, cursor string, limit int) ([]types.OrganizationSearchItem, string, error) { if uid.IsZero() || strings.TrimSpace(queryText) == "" { return nil, "", types.ErrMalformed @@ -1714,6 +1888,7 @@ type MonthlyReportsPersistenceInterface interface { Get(tenantID types.TenantID, uid types.Uid, reportID string) (*types.MonthlyReport, error) SaveDraft(tenantID types.TenantID, uid types.Uid, reportID, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) Submit(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) + Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) } @@ -1779,6 +1954,17 @@ func (monthlyReportsMapper) Submit(tenantID types.TenantID, uid types.Uid, repor return tenantAdp.TenantMonthlyReportSubmit(tenantID, uid, strings.TrimSpace(reportID), version) } +func (monthlyReportsMapper) Reopen(tenantID types.TenantID, uid types.Uid, reportID string, version int) (*types.MonthlyReport, error) { + if uid.IsZero() || strings.TrimSpace(reportID) == "" || version <= 0 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMonthlyReportReopen(tenantID, uid, strings.TrimSpace(reportID), version) +} + func (monthlyReportsMapper) Stats(tenantID types.TenantID, uid types.Uid, month, departmentID string) (*types.MonthlyReportStats, error) { if uid.IsZero() || strings.TrimSpace(month) == "" { return nil, types.ErrMalformed @@ -3118,6 +3304,7 @@ type UserSecurityPersistenceInterface interface { BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error + CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) @@ -3171,6 +3358,17 @@ func (userSecurityMapper) CreateRealNameSubmission(tenantID types.TenantID, rv * return tenantAdp.TenantRealNameCreate(tenantID, rv) } +func (userSecurityMapper) CreateRealNameAppeal(tenantID types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) { + if appeal == nil || appeal.TenantID != tenantID || appeal.UserID.IsZero() || strings.TrimSpace(appeal.AppealID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantRealNameAppealCreate(tenantID, appeal) +} + func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error { if token == nil || token.TenantID != tenantID { return types.ErrMalformed @@ -3481,6 +3679,7 @@ func init() { Topics = topicsMapper{} Subs = subsMapper{} Messages = messagesMapper{} + Conversations = conversationsMapper{} Calls = callsMapper{} GroupBusiness = groupBusinessMapper{} Workbench = workbenchMapper{} diff --git a/server/store/types/conversations.go b/server/store/types/conversations.go new file mode 100644 index 0000000..e57d026 --- /dev/null +++ b/server/store/types/conversations.go @@ -0,0 +1,37 @@ +package types + +import "time" + +type ConversationSettings struct { + ID int64 `db:"id"` + UserID Uid `db:"-"` + Topic string `db:"topic"` + Pinned bool `db:"pinned"` + Muted bool `db:"muted"` + Remark string `db:"remark"` + MessageRetention string `db:"message_retention"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + UserIDRaw int64 `db:"user_id"` +} + +type ConversationSettingsPatch struct { + Pinned *bool + Muted *bool + Remark *string + MessageRetention *string + Version int +} + +type TopicDraft struct { + ID int64 `db:"id"` + UserID Uid `db:"-"` + Topic string `db:"topic"` + Content string `db:"content"` + EntitiesJSON []byte `db:"entities_json"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + UserIDRaw int64 `db:"user_id"` +} diff --git a/server/store/types/groups.go b/server/store/types/groups.go index 0351d10..ee6c7d4 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -181,6 +181,28 @@ type GroupMute struct { RevokedRaw *int64 `db:"revoked_by"` } +type GroupFile struct { + ID int64 `db:"id"` + GroupFileID string `db:"group_file_id"` + Topic string `db:"topic"` + FileID string `db:"file_id"` + SourceTopic string `db:"source_topic"` + SourceSeq int `db:"source_seq"` + UploaderID Uid `db:"-"` + FileName string `db:"file_name"` + MIMEType string `db:"mime_type"` + FileType string `db:"file_type"` + FileSize int64 `db:"file_size"` + Status string `db:"status"` + DeletedBy Uid `db:"-"` + DeletedAt *time.Time `db:"deleted_at"` + IdempotencyKey string `db:"idempotency_key"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + UploaderRaw int64 `db:"uploader_id"` + DeletedByRaw *int64 `db:"deleted_by"` +} + type GroupEvent struct { ID int64 `db:"id"` EventID string `db:"event_id"` diff --git a/server/store/types/security.go b/server/store/types/security.go index 031acf7..643d82a 100644 --- a/server/store/types/security.go +++ b/server/store/types/security.go @@ -21,6 +21,21 @@ type RealNameVerification struct { UpdatedAt time.Time `db:"updated_at" json:"-"` } +// RealNameAppeal is a user-submitted request to correct a real-name review. +type RealNameAppeal struct { + ID int64 `db:"id" json:"-"` + TenantID TenantID `db:"tenant_id" json:"-"` + UserID Uid `db:"user_id" json:"-"` + AppealID string `db:"appeal_id" json:"appeal_id"` + Reason string `db:"reason" json:"reason"` + AttachmentsJSON []byte `db:"attachments_json" json:"-"` + Attachments []string `db:"-" json:"attachments,omitempty"` + Status string `db:"status" json:"status"` + ReviewComment string `db:"review_comment" json:"review_comment,omitempty"` + CreatedAt time.Time `db:"created_at" json:"-"` + UpdatedAt time.Time `db:"updated_at" json:"-"` +} + // ActionToken is a short-lived one-time token for sensitive actions. type ActionToken struct { ID int64 `db:"id" json:"-"` diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 5c27b7d..2418af0 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -221,6 +221,7 @@ type testSecurityStore struct { bumpedUser types.Uid realName *types.RealNameVerification + realNameAppeal *types.RealNameAppeal actionToken *types.ActionToken independentPassword *types.IndependentPassword youthMode *types.YouthModeSettings @@ -276,6 +277,17 @@ func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types return s.err } +func (s *testSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) { + s.realNameAppeal = appeal + if s.err != nil { + return nil, s.err + } + now := time.Now().UTC().Round(time.Millisecond) + appeal.CreatedAt = now + appeal.UpdatedAt = now + return appeal, nil +} + func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error { s.actionToken = token return s.err