Skip to content
Clone
b868469
63 lines 13.9 KB Markdown

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 实测。

确认问题

#类位置操作现象后端依据建议修法
1Rview/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<AppResponseData<Vec<MessageDto>>, ApiError>;lib/service/chat/src/services/messages.rs:1329-1343 → list_pins(...) -> Result<Vec<MessageDto>, 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<ChatMessage>(...) 或 unwrapApi<ChatMessage[]> 并直接返回数组(PinnedPage 类型同时删除/改为 ChatMessage[])
2Rview/app/src/domains/chat/model/api.ts:348-352(崩溃点 view/app/src/domains/chat/ui/emoji-picker.tsx:91)chat.list_emojis前端用 listOf<CustomEmoji> 当作裸数组(注释写「The endpoint answers with a bare array」),但后端返回 Page<EmojiDto>。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<AppResponseData<Page<EmojiDto>>, ApiError>;lib/service/chat/src/services/emojis.rs:65-98 → list_emojis(...) -> Result<Page<EmojiDto>, 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 的生成类型一致),删掉那条与源码相反的注释
3Bview/app/src/domains/chat/model/api.ts:53-63;触发点 view/app/src/domains/chat/ui/chat-room.tsx:981-986chat.update_roomrate_limit_per_user 的三态在前后端不一致:前端在「限流值 ≤ 0」时发 null(Number.isFinite(rate) && rate > 0 ? rate : null),意在清零;后端字段是 Option<i32> 且 #[serde(default)],null 与「缺省」同为 None,服务里 if let Some(...) 直接跳过 —— 房间限流一旦设上就再也无法通过编辑弹窗关掉(topic: null 清空同理)lib/service/chat/src/services/rooms.rs:49-66 UpdateRoomCmd { name: Option<String>, topic: Option<String>, is_private: Option<bool>, nsfw: Option<bool>, rate_limit_per_user: Option<i32>, position: Option<i64>, parent_id: Option<Option<Uuid>> } —— 只有 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<Option<T>> + deserialize_opt_opt 并在服务里处理 Some(None);或前端改为发送哨兵值(如 rate_limit_per_user: 0,需后端把 0 视为清空),二选一即可

可疑(需人工确认)

#位置操作疑点已排除的可能
1view/app/src/domains/chat/model/api.ts:293-294chat.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 无关。属冗余代码而非契约错误
2view/app/src/domains/chat/model/api.ts:73-82chat.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 判否即清),前端并未使用
3view/app/src/domains/chat/model/api.ts:279-282chat.update_threadcmd 类型写 name?: string | null、archived?: boolean | null、locked?: boolean | null;后端 UpdateThreadCmd(lib/service/chat/src/services/threads.rs:48-55)是 Option<String> / Option<bool>,null 与缺省同义,无法清空话题名(后端要靠空串才置 None,threads.rs:331-337)唯一调用点 side-panel.tsx:288 只传 { locked: !thread.locked },未使用 null 分支;归档/改名 UI 不存在,暂不构成线上故障
4view/app/src/domains/chat/model/api.ts:67(无)listRoomMemberschat 域的成员列表是本地空实现 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_messagesquery 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_messagebody content/nonce/reply_to_id/thread_id/thread_broadcast/attachments/urgent 与 SendMessageCmd(messages.rs:169-188)一致;attachments 前后端都是 string[] ↔ Vec<Uuid>(前端 SendMessageInput.attachments?: string[],chat.ts:65);nonce 走 nz(),空值省略而非发 null,符合 Option<String>
chat.get_messagepathParams{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_messagebody {content} ↔ UpdateMessageCmd { content: String }(messages.rs:190-193);path 2 段一致
chat.bulk_delete_messagesbody { message_ids } ↔ BulkDeleteCmd { message_ids: Vec<Uuid> }(messages.rs:195-198);路由 /messages/bulk-delete(routes.rs:51-54)
chat.forward_messagebody { target_room_id, comment } ↔ ForwardMessageCmd(messages.rs:200-206)
chat.add_reaction / chat.remove_reactionbody { emoji } ↔ ReactionCmd { emoji: String };响应为裸 Vec<ReactionAggDto>(handlers/reactions.rs:32,59),前端 listOf<ReactionAgg> 取法正确
chat.list_reactions裸 Vec<ReactionUserDto>(handlers/reactions.rs:81-90),前端 listOf 正确
chat.set_message_pinbody { pinned } ↔ PinCmd { pinned: bool }(messages.rs:200),路由 PUT .../pin(routes.rs:61-64)
chat.get_read_state / chat.mark_readMarkReadCmd { message_id?, seq? }(services/read_state.rs:34-41);响应 ReadStateDto(同文件 18-26)
chat.list_space_read_states响应 SpaceReadStates { items: Vec<ReadStateDto> }(read_state.rs:28-31),前端 state?.items ?? [] 与之一致
chat.get_draft / chat.delete_draftquery thread_id ↔ DraftQuery { thread_id: Option<Uuid> }(handlers/drafts.rs:10-13),路由同路径 GET/DELETE(routes.rs:150-155)
chat.upsert_draftbody { content, thread_id } ↔ UpsertDraftCmd { thread_id: Option<Uuid>, content: String }(services/drafts.rs:20-25)
chat.create_threadpath (room_id, message_id) + body { name } ↔ CreateThreadCmd { name: Option<String> }(services/threads.rs:42),路由 /messages/{message_id}/threads(routes.rs:74-77)
chat.list_threads / chat.list_my_threadslist_threads 响应 ThreadPage = Page<ThreadDto>(threads.rs:39),前端读 page.items 正确;list_my_threads 用 PageQuery(handlers/threads.rs:74-83),前端只送 limit 合法
chat.get_thread / chat.update_threadpath 1 段 ↔ /chat/threads/{thread_id}(routes.rs:80-83);UpdateThreadCmd 字段名一致(null 三态问题见可疑 #3)
chat.follow_threadbody { notify_level } ↔ ThreadMemberCmd { notify_level: Option<ThreadNotifyLevel> }(threads.rs:57-61);枚举值 "None" | "All"(packages/apiclient/chat/follow-thread.schemas.ts:105)与前端 'All' | 'None' | null 一致
chat.mark_thread_readbody { message_id } ↔ MarkThreadReadCmd { message_id: Option<Uuid> }(handlers/read_state.rs:76-80)
chat.list_space_presences / chat.get_presence分别为裸 Vec<PresenceDto> 与 PresenceDto(handlers/presence.rs:62-72,41-48);前端 listOf / unwrapApi 取法对应
chat.create_attachmentbody { filename, content_type, size } ↔ CreateAttachmentCmd { filename, content_type, size: i64, description?: Option<String> }(services/attachments.rs:59-65),响应 UploadIntentDto { id, upload_url, expires_secs }
chat.finalize_attachmentbody { 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<VoiceParticipantDto> 与 VoiceSpaceStatesDto(handlers/voice.rs:70-99),前端取法对应
chat.list_hands / raise_hand / cancel_handVoiceHandsDto { 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_muteparams: { 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_roomCreateRoomCmd { 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<RoomBriefDto>(services/rooms.rs:69-73,handlers/rooms.rs:24),前端 unwrapApi<RoomBrief[]>(shell-api.ts:40)取法正确
chat.list_dms / chat.open_dm / chat.close_dmPage<DmDto> + PageQuery(handlers/dms.rs:46-53),前端读 items 并送 limit 合法;OpenDmCmd { peer_user_id?: Option<i64> }(services/dms.rs:34-39)与前端 { peer_user_id } 一致;close_dm DELETE /chat/dms/{room_id}(routes.rs:119)
notifications.list_notificationsquery 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<NotificationDto>,前端读 page.items 正确;realtime/client.ts:294 的 {limit:1,offset:0} 同样合法
notifications.delete_notificationDELETE /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 同源,形状一致