# chat / notifications ## 结论 - 检查调用点 62 个 / 操作 58 个(chat 56 + notifications 2) - 确认问题 3 条(Q 0 / B 1 / P 0 / R 2 / G 0),可疑 4 条,未实证 58 个操作 - 未实证原因:本机服务在 7051 已起(`GET /healthz` → 200),但 chat 与 `/me/notifications` 全部要求会话; 匿名调用直接 401,取不到 400 的字段级报错。原文(`curl -s 'http://localhost:7051/api/v1/chat/spaces/demo/rooms'`): `{"code":401,"data":null,"error":null,"error_code":"unauthenticated","message":"authentication required","request_id":"01a19e44-534f-760a-a5db-0e708cfff6d5"}` 需要:任意已登录用户会话 Cookie(`session`),且该用户是目标空间成员(`require_space_member` / `require_room_readable`)。 下文所有「后端依据」均为源码取证,未走 HTTP 实测。 ## 确认问题 | # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 | |---|---|---|---|---|---|---| | 1 | R | `view/app/src/domains/chat/model/api.ts:200`(消费方 `view/app/src/domains/chat/model/use-room-data.ts:70`) | `chat.list_pins` | 前端按分页对象取 `page?.items ?? []`,但后端返回**裸数组**,`.items` 恒为 `undefined`,置顶列表恒为空数组——即使房间里有置顶消息也一条都不显示 | `lib/service/chat/src/handlers/messages.rs:195-202` → `Result>, ApiError>`;`lib/service/chat/src/services/messages.rs:1329-1343` → `list_pins(...) -> Result, ChatError>`,`build_message_dto(rows, caller)` 直接返回 `Vec`;路由 `lib/service/chat/src/routes.rs:65` `GET /chat/rooms/{room_id}/pins`。生成类型 `packages/apiclient/index.schemas.ts` 的 `EnvelopePageMessageDto`(前端 `PinnedPage = EnvelopePageMessageDto['data']`,`chat.ts:54`)把响应描述成 `{items,total,limit,offset}`,与 Rust 不符 | 前端改 `listOf(...)` 或 `unwrapApi` 并直接返回数组(`PinnedPage` 类型同时删除/改为 `ChatMessage[]`) | | 2 | R | `view/app/src/domains/chat/model/api.ts:348-352`(崩溃点 `view/app/src/domains/chat/ui/emoji-picker.tsx:91`) | `chat.list_emojis` | 前端用 `listOf` 当作**裸数组**(注释写「The endpoint answers with a bare array」),但后端返回 `Page`。`listOf` 的 `rows ?? []` 把整个分页对象当数组放行,最终经 `chat-room.tsx:632/651/741` → `emoji-picker.tsx:91` 执行 `custom.filter(...)`,对普通对象调用 `.filter` 抛 `TypeError`,表情面板整体不可用 | `lib/service/chat/src/handlers/emojis.rs:24-32` → `Result>, ApiError>`;`lib/service/chat/src/services/emojis.rs:65-98` → `list_emojis(...) -> Result, ChatError>`,末尾 `Ok(Page::new(items, total, limit, offset))`;路由 `routes.rs:130-133` `GET /chat/spaces/{space_name}/emojis`;生成类型 `EnvelopePageEmojiDto`(前端 `CustomEmoji = EnvelopePageEmojiDto['data']['items'][number]`,`chat.ts:52`,本身也承认是分页) | 改读 `page?.items ?? []`(与 `CustomEmoji` 的生成类型一致),删掉那条与源码相反的注释 | | 3 | B | `view/app/src/domains/chat/model/api.ts:53-63`;触发点 `view/app/src/domains/chat/ui/chat-room.tsx:981-986` | `chat.update_room` | `rate_limit_per_user` 的三态在前后端不一致:前端在「限流值 ≤ 0」时发 `null`(`Number.isFinite(rate) && rate > 0 ? rate : null`),意在**清零**;后端字段是 `Option` 且 `#[serde(default)]`,`null` 与「缺省」同为 `None`,服务里 `if let Some(...)` 直接跳过 —— 房间限流一旦设上就再也无法通过编辑弹窗关掉(`topic: null` 清空同理) | `lib/service/chat/src/services/rooms.rs:49-66` `UpdateRoomCmd { name: Option, topic: Option, is_private: Option, nsfw: Option, rate_limit_per_user: Option, position: Option, parent_id: Option> }` —— 只有 `parent_id` 用 `deserialize_opt_opt`,其余是单层 `Option`;`api::serde_patch::deserialize_opt_opt`(`lib/infra/api/src/serde_patch.rs`)才是三态实现。对端 `moveRoom`(`view/app/src/domains/space/management/model/api.ts:73-86`)正是靠它才能把房间拖回空间根 | 后端把 `rate_limit_per_user` / `topic` 改成 `Option>` + `deserialize_opt_opt` 并在服务里处理 `Some(None)`;或前端改为发送哨兵值(如 `rate_limit_per_user: 0`,需后端把 0 视为清空),二选一即可 | ## 可疑(需人工确认) | # | 位置 | 操作 | 疑点 | 已排除的可能 | |---|---|---|---|---| | 1 | `view/app/src/domains/chat/model/api.ts:293-294` | `chat.unfollow_thread` | 前端传 `data: { notify_level: null }`,但 `OPS` 表该条是 `path: 1, query: false, body: false`(`view/app/src/services/api/ops.ts:186`),`invoke()` 根本不会把 `data` 放进实参——这份 body 是死载荷 | 不会 400:后端 `unfollow_thread`(`lib/service/chat/src/handlers/threads.rs:166-173`)本身只有 `Path(thread_id)`,无 `Json` 提取器,忽略任何 body;且 `leave_thread`(`services/threads.rs:416-445`)语义就是退出话题,与 `notify_level` 无关。属冗余代码而非契约错误 | | 2 | `view/app/src/domains/chat/model/api.ts:73-82` | `chat.update_room_setting` | 声明了 `mute_minutes?: number \| null`、`notification_level?: ... \| null`,注释表示 `null` 可清空;后端 `UpdateRoomSettingCmd` 两者都是单层 `Option`(`lib/service/chat/src/services/settings.rs:23-34`),`null` 等同缺省 → 静默不生效 | 当前无调用方传这两个字段:仅 `chat-room.tsx:506/511` 传 `{muted}` 与 `{favorite}`,未触发。解除静音走后端 `mute_minutes: 0` 分支(`minutes > 0` 判否即清),前端并未使用 | | 3 | `view/app/src/domains/chat/model/api.ts:279-282` | `chat.update_thread` | cmd 类型写 `name?: string \| null`、`archived?: boolean \| null`、`locked?: boolean \| null`;后端 `UpdateThreadCmd`(`lib/service/chat/src/services/threads.rs:48-55`)是 `Option` / `Option`,`null` 与缺省同义,无法清空话题名(后端要靠空串才置 `None`,`threads.rs:331-337`) | 唯一调用点 `side-panel.tsx:288` 只传 `{ locked: !thread.locked }`,未使用 `null` 分支;归档/改名 UI 不存在,暂不构成线上故障 | | 4 | `view/app/src/domains/chat/model/api.ts:67` | (无)`listRoomMembers` | chat 域的成员列表是本地空实现 `Promise.resolve([])`,而空间域已有 `space.list_space_members`;房间成员面板因此恒空 | 后端 chat 路由表里**确实没有** `/chat/rooms/{room_id}/members`(`lib/service/chat/src/routes.rs` 全文),不是被删端点,不存在 404/501;`list_space_members` 已在 `api.ts:326` 另作他用。属未接线而非契约错配 | ## 已核对无问题 | 操作 | 依据 | |---|---| | `chat.list_messages` | query `before_seq/after_seq/limit/offset/thread_id/author_id` 与 `ListMessageQuery` 逐字一致(`lib/service/chat/src/services/messages.rs:207-216`,且带 `deny_unknown_fields`,前端未多送键);响应 `MessagePage { items,total,limit,offset,has_more,oldest_seq,newest_seq }`(`messages.rs:141-149`)与前端兜底对象 `api.ts:115` 完全对齐 | | `chat.send_message` | body `content/nonce/reply_to_id/thread_id/thread_broadcast/attachments/urgent` 与 `SendMessageCmd`(`messages.rs:169-188`)一致;`attachments` 前后端都是 `string[]` ↔ `Vec`(前端 `SendMessageInput.attachments?: string[]`,`chat.ts:65`);`nonce` 走 `nz()`,空值省略而非发 `null`,符合 `Option` | | `chat.get_message` | `pathParams{room_id,message_id}` ↔ `Path((room_id, message_id))`,路由 `/chat/rooms/{room_id}/messages/{message_id}`(`routes.rs:56-60`) | | `chat.update_message` / `chat.delete_message` | body `{content}` ↔ `UpdateMessageCmd { content: String }`(`messages.rs:190-193`);path 2 段一致 | | `chat.bulk_delete_messages` | body `{ message_ids }` ↔ `BulkDeleteCmd { message_ids: Vec }`(`messages.rs:195-198`);路由 `/messages/bulk-delete`(`routes.rs:51-54`) | | `chat.forward_message` | body `{ target_room_id, comment }` ↔ `ForwardMessageCmd`(`messages.rs:200-206`) | | `chat.add_reaction` / `chat.remove_reaction` | body `{ emoji }` ↔ `ReactionCmd { emoji: String }`;响应为**裸** `Vec`(`handlers/reactions.rs:32,59`),前端 `listOf` 取法正确 | | `chat.list_reactions` | 裸 `Vec`(`handlers/reactions.rs:81-90`),前端 `listOf` 正确 | | `chat.set_message_pin` | body `{ pinned }` ↔ `PinCmd { pinned: bool }`(`messages.rs:200`),路由 `PUT .../pin`(`routes.rs:61-64`) | | `chat.get_read_state` / `chat.mark_read` | `MarkReadCmd { message_id?, seq? }`(`services/read_state.rs:34-41`);响应 `ReadStateDto`(同文件 18-26) | | `chat.list_space_read_states` | 响应 `SpaceReadStates { items: Vec }`(`read_state.rs:28-31`),前端 `state?.items ?? []` 与之一致 | | `chat.get_draft` / `chat.delete_draft` | query `thread_id` ↔ `DraftQuery { thread_id: Option }`(`handlers/drafts.rs:10-13`),路由同路径 GET/DELETE(`routes.rs:150-155`) | | `chat.upsert_draft` | body `{ content, thread_id }` ↔ `UpsertDraftCmd { thread_id: Option, content: String }`(`services/drafts.rs:20-25`) | | `chat.create_thread` | path `(room_id, message_id)` + body `{ name }` ↔ `CreateThreadCmd { name: Option }`(`services/threads.rs:42`),路由 `/messages/{message_id}/threads`(`routes.rs:74-77`) | | `chat.list_threads` / `chat.list_my_threads` | `list_threads` 响应 `ThreadPage = Page`(`threads.rs:39`),前端读 `page.items` 正确;`list_my_threads` 用 `PageQuery`(`handlers/threads.rs:74-83`),前端只送 `limit` 合法 | | `chat.get_thread` / `chat.update_thread` | path 1 段 ↔ `/chat/threads/{thread_id}`(`routes.rs:80-83`);`UpdateThreadCmd` 字段名一致(`null` 三态问题见可疑 #3) | | `chat.follow_thread` | body `{ notify_level }` ↔ `ThreadMemberCmd { notify_level: Option }`(`threads.rs:57-61`);枚举值 `"None" \| "All"`(`packages/apiclient/chat/follow-thread.schemas.ts:105`)与前端 `'All' \| 'None' \| null` 一致 | | `chat.mark_thread_read` | body `{ message_id }` ↔ `MarkThreadReadCmd { message_id: Option }`(`handlers/read_state.rs:76-80`) | | `chat.list_space_presences` / `chat.get_presence` | 分别为裸 `Vec` 与 `PresenceDto`(`handlers/presence.rs:62-72,41-48`);前端 `listOf` / `unwrapApi` 取法对应 | | `chat.create_attachment` | body `{ filename, content_type, size }` ↔ `CreateAttachmentCmd { filename, content_type, size: i64, description?: Option }`(`services/attachments.rs:59-65`),响应 `UploadIntentDto { id, upload_url, expires_secs }` | | `chat.finalize_attachment` | body `{ width, height, duration_secs }` ↔ `FinalizeAttachmentCmd`(`attachments.rs:75-82`),path 1 段 ↔ `/chat/attachments/{attachment_id}/finalize`(`routes.rs:122-125`) | | `chat.voice_config` / `chat.voice_token` | 路由 `/chat/voice/config`(path 0)与 `POST /chat/rooms/{room_id}/voice/token`(path 1)(`routes.rs:186-187`),与 `OPS` 表 `path` 值一致 | | `chat.voice_participants` / `chat.voice_space_states` | 裸 `Vec` 与 `VoiceSpaceStatesDto`(`handlers/voice.rs:70-99`),前端取法对应 | | `chat.list_hands` / `raise_hand` / `cancel_hand` | `VoiceHandsDto { hands, can_moderate, my_state }`(`services/voice.rs:149-153`);三个动作 path 1 段,`/@me` 删除走 `cancel_hand`(`routes.rs:212`) | | `chat.approve_hand` / `deny_hand` / `revoke_hand` / `kick_participant` | 均为 path 2 段 `(room_id, user_id)` ↔ `Path<(Uuid, i64)>`(`handlers/voice.rs:223-231,271-279`) | | `chat.moderate_mute` | `params: { muted }` 走 query(`OPS` 标 `query: true`)↔ 后端 `Query(ModerateQuery { muted: bool })`(`handlers/voice.rs:281-313`),位置正确 | | `chat.get_voice_layout` / `chat.save_voice_layout` | 响应 `VoiceLayoutDto { tiles }`(`services/voice.rs:64-68`),前端 `data?.tiles ?? {}` 正确;请求 `VoiceLayoutPatchDto { tiles, removed }`(`voice.rs:73-80`)与前端 `{ tiles, removed }` 一致 | | `chat.update_room`(空间侧 `moveRoom`) | `parent_id` 三态由 `deserialize_opt_opt`(`services/rooms.rs:62-65`)真正支持,`null` 回空间根有效 | | `chat.create_room` | `CreateRoomCmd { name: String, kind?, topic?, is_private, nsfw, user_limit?, rate_limit_per_user?, parent_id? }`(`services/rooms.rs:501-517`),path 1 段 `space_name` ↔ `/chat/spaces/{space_name}/rooms`(`routes.rs:39-42`) | | `chat.list_space_rooms` | 后端裸 `Vec`(`services/rooms.rs:69-73`,`handlers/rooms.rs:24`),前端 `unwrapApi`(`shell-api.ts:40`)取法正确 | | `chat.list_dms` / `chat.open_dm` / `chat.close_dm` | `Page` + `PageQuery`(`handlers/dms.rs:46-53`),前端读 `items` 并送 `limit` 合法;`OpenDmCmd { peer_user_id?: Option }`(`services/dms.rs:34-39`)与前端 `{ peer_user_id }` 一致;`close_dm` DELETE `/chat/dms/{room_id}`(`routes.rs:119`) | | `notifications.list_notifications` | query `limit/offset/unread/type` ↔ `ListNotificationsQuery { limit, offset, #[serde(rename="type")] notification_type, unread }`(`lib/service/auth/src/handlers/list_notifications.rs:13-20`,带 `deny_unknown_fields`);响应 `Page`,前端读 `page.items` 正确;`realtime/client.ts:294` 的 `{limit:1,offset:0}` 同样合法 | | `notifications.delete_notification` | `DELETE /me/notifications/{notification_id}`(`handlers/delete_notification.rs:9-26`),path 1 段一致 | | 实时回填一致性 | `services/realtime/client.ts:316` 的 `message-create/message-update` 回填用 `get_message{room_id,message_id}`,与 REST 同一 DTO(`MessageDto`);`client.ts:294` 的 `notification-create` 回填用 `list_notifications?limit=1` 取 `items[0]`——单条字段与 `NotificationDto` 同源,形状一致 |