From 0c5b329179274afb7646429a3cf93b5164ed28e7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 21:51:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DZentao=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E7=9A=846=E4=B8=AA=E5=89=8D=E7=AB=AFbug?= =?UTF-8?q?=EF=BC=9A=E6=9D=A5=E7=94=B5UI=E4=B8=8D=E5=BC=B9=E5=87=BA/?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95=E5=8F=91=E9=80=81=E6=97=A0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA/=E7=BE=A4=E5=90=8D=E6=A0=A1=E9=AA=8C/=E7=BE=A4?= =?UTF-8?q?=E4=BA=BA=E6=95=B0=E4=B8=8E=E9=80=9A=E8=AF=9D=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=98=BE=E7=A4=BAuid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户提供了一份Zentao导出的bug清单(#21/23/24/25/26/27/28共7条),逐条实机复现 排查,修复了其中6个真正的前端bug,1个是纯后端问题(禁言不生效)已记录到 docs/IM-后端问题清单.md,1个(好友申请404/500)未能独立复现只记录了测试团队 的原始报告,另1个(离线状态推送)实测后确认并非报告描述的"永久不更新",只是 有正常的心跳超时延迟。 - **#23 来电UI完全不弹出**(根因最深的一个):initCallSignaling用的是 `me.getMeTopic().contacts()`枚举联系人再查最新消息——但这个方法只反映`me` 话题自己的订阅同步进度,一个P2P话题可能早就真正订阅上、能收到消息,`me`的 同步却还没跟上,导致这个联系人在枚举里直接消失。用两个真实账号实机验证: 被叫方`me.contacts()`只枚举出1个群,主叫方完全没出现,来电消息静默丢失。 topics.ts里`getChatListSnapshot()`自己的注释早就写明了这个坑、并且已经改 用`mapTopics()`规避,只是`stores/calls.ts`这个监听器当时没有跟着改。现在 统一复用`getChatListSnapshot()`,实机验证来电卡片正确弹出。 - **#28 拉黑后发送消息无原因提示**:读tinode-sdk源码确认发送失败的具体原因 从架构上就拿不到——`Topic#publishMessage`自己的`.catch()`把服务端拒绝原因 吞掉、只留一个`_failed`标记,不会重新抛出。改成事前判断:新增 `getConversationContext()`(P2P专用,`relationship`字段区分 `blocked_by_me`/`blocked_by_peer`),发送区直接换成对应文案的横幅,两个 方向都拿真实账号互相拉黑验证过。 - **#26 群名超长/留空自动名两个校验问题**:`MAX_TITLE_LENGTH`一直是60,但 后端`im_http.go`真实校验的是32字(读源码确认),导致33-59字之间的群名能 输入、能提交,然后被后端拒绝只显示通用错误。改成32,从输入框这一层就不 再放行超长名。另外群名留空时自动拼接的成员昵称组合,理论上可能被内容审核 拒绝——这种情况下用户根本不知道"名字"是问题所在,补了针对性提示。 - **#27 通话记录/群列表显示原始uid、群人数显示0人**(2处独立bug): 1. `CallHistoryView.tsx`按`peer_user_id`(真实usr id)去匹配聊天列表的 `contact.topic`(建立过真实会话后已经是服务端重命名过的p2p哈希),两个 不是同一个id空间,永远匹配不上,一直显示原始id。改成用`item.topic` (已确认和chatlist的topic是同一个值)去匹配。 2. 实测发现`GET /groups`列表接口的`member_count`会返回0,但同一个群 `GET /groups/{topic}`单独查询正确返回4——两个接口的计数器不一致(后端 已知问题,见问题8),前端这次统一改成"读到0就不显示人数"而不是显示一 个已确认错误的数字。 以上改动均跑过`tsc -b --noEmit`/`oxlint`/`vite build`,除#26外全部用两个真实 账号(systest02/systest04)互相操作实机验证。 --- web/src/features/calls/CallHistoryView.tsx | 18 ++++++++--- web/src/features/contacts/GroupListView.tsx | 6 +++- web/src/features/groups/CreateGroupModal.tsx | 27 ++++++++++++----- web/src/features/messages/ChatListColumn.tsx | 8 ++++- .../features/messages/ConversationView.tsx | 30 ++++++++++++++++++- web/src/lib/api/friends.ts | 27 +++++++++++++++++ web/src/lib/config.ts | 9 +++++- web/src/stores/calls.ts | 21 +++++++++---- 8 files changed, 124 insertions(+), 22 deletions(-) diff --git a/web/src/features/calls/CallHistoryView.tsx b/web/src/features/calls/CallHistoryView.tsx index 2597c908..e3bfe840 100644 --- a/web/src/features/calls/CallHistoryView.tsx +++ b/web/src/features/calls/CallHistoryView.tsx @@ -28,9 +28,12 @@ function statusLabel(item: CallHistoryItem): string { /** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in * NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls` * endpoint, whose response shape (see calls.ts's own comment) turned out completely different - * from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike - * `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to - * a display name via the chat list, same lookup ChatListColumn itself uses. + * from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used + * to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening + * a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once + * a conversation has real history) is what display-name resolution matches against instead -- the + * two are different identifier spaces and matching name lookup against `peer_user_id` was a real, + * live-confirmed bug (Zentao #27) that always fell through to the raw uid. */ export function CallHistoryView() { const navigate = useNavigate(); @@ -80,7 +83,14 @@ export function CallHistoryView() { // something the viewer missed and should feel warned about. This used to redden EVERY // non-'finished' result alike. const isMissed = result === 'missed' && incoming; - const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id; + // NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id` + // is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's + // renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the + // full mechanism) -- the two are different identifier spaces and this comparison never + // matched for any call with real history, always falling through to the raw uid. `item.topic` + // is that same `p2p...` form already (confirmed live: identical string to what the chat list + // caches for this conversation), so matching on it directly is the correct, direct fix. + const name = contacts.find((c) => c.topic === item.topic)?.public?.fn || item.peer_user_id; const ts = item.started_at || item.created_at; return (
diff --git a/web/src/features/contacts/GroupListView.tsx b/web/src/features/contacts/GroupListView.tsx index d66a1771..9ee6aaa7 100644 --- a/web/src/features/contacts/GroupListView.tsx +++ b/web/src/features/contacts/GroupListView.tsx @@ -96,7 +96,11 @@ export function GroupListView() { ) : null} - {g.member_count} 人 · 群号 {g.topic} + {/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty + group (confirmed live -- same backend dual-counter split as ChatListColumn's own + "N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so + an exact zero here means "don't trust this field", not "empty group". */} + {g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic} diff --git a/web/src/features/groups/CreateGroupModal.tsx b/web/src/features/groups/CreateGroupModal.tsx index e02d73ff..cc695f18 100644 --- a/web/src/features/groups/CreateGroupModal.tsx +++ b/web/src/features/groups/CreateGroupModal.tsx @@ -62,14 +62,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) setSubmitting(true); setError(''); let result: Awaited>; + // The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination + // of member nicknames when left blank), but `name` has no such fallback server-side (unlike + // `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a + // blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared + // outside the try block since the catch below needs it too. + const usedAutoName = !name.trim(); try { - // The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a - // combination of member nicknames when left blank), but `name` has no such fallback - // server-side (unlike `description`, which the protocol doc explicitly allows empty) -- - // confirmed live: submitting a blank name gets rejected outright with MALFORMED_REQUEST, not - // silently defaulted. Truncated the same way the input field itself is (MAX_TITLE_LENGTH), - // with an ellipsis when it had to cut names short, so this can't itself produce an - // over-length name the server would also reject. + // Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when + // it had to cut names short, so this can't itself produce an over-length name the server would + // also reject. const trimmedName = name.trim(); let finalName = trimmedName; if (!finalName) { @@ -81,7 +83,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) member_user_ids: [...selected.keys()], }); } catch (err) { - setError(err instanceof Error ? err.message : '创建群聊失败'); + // Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the + // "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content + // moderation flagging a purely-numeric nickname combination), and the generic error message + // gives someone who left the field blank no clue that "the name" is even what's wrong, let + // alone that they can fix it by typing one in themselves. + if (usedAutoName) { + setError('自动生成的群名未通过审核,请手动输入一个群名后重试'); + } else { + setError(err instanceof Error ? err.message : '创建群聊失败'); + } setSubmitting(false); return; } diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx index 89e15c63..e8465168 100644 --- a/web/src/features/messages/ChatListColumn.tsx +++ b/web/src/features/messages/ChatListColumn.tsx @@ -149,6 +149,12 @@ export function ChatListColumn() { const preview = formatMessagePreview(contact.latestMessage?.(), myUserId); const active = topicId === contact.topic; const isGroup = tinode.getTopic(contact.topic).isGroupType(); + // `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group + // the single-group `GET /groups/{topic}` correctly reports as several members -- the same + // dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed + // live to surface as a full zero rather than a smaller mismatch. No real group has zero + // members (the owner alone is one), so the `> 0` check below treats an exact zero as + // "don't trust this" and hides the pill instead of showing a number already proven wrong. const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined; const muted = !!contact.private?.mute_notifications; const avatarFileId = @@ -172,7 +178,7 @@ export function ChatListColumn() { {name} {/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right after the name (e.g. "项目协作群 28人"); P2P rows never have one. */} - {typeof memberCount === 'number' ? ( + {typeof memberCount === 'number' && memberCount > 0 ? ( {memberCount}人 ) : null} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 3eec38a4..580e9f1f 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -19,7 +19,7 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { useBootstrap } from '@/lib/api/hooks'; import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; -import { getUserProfile } from '@/lib/api/friends'; +import { getConversationContext, getUserProfile } from '@/lib/api/friends'; import { getMe } from '@/lib/api/profile'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { useClickOutside } from '@/lib/hooks'; @@ -482,6 +482,28 @@ export function ConversationView() { queryFn: () => getUserProfile(peerUserId || ''), enabled: !!peerUserId, }); + // Live-confirmed real bug this closes (Zentao #28): sending into a P2P conversation blocked in + // EITHER direction showed only the generic per-bubble "发送失败,点击重试", no reason, on both sides + // -- reading tinode-sdk source confirmed the specific rejection reason can never be recovered + // after a failed send (Topic#publishMessage's own .catch swallows it into a bare `_failed` flag and + // never rethrows), so the only real fix is checking BEFORE attempting to send, not reacting after. + // This endpoint is P2P-only and reports the relationship from either side (`blocked_by_me` on the + // blocker's own view, `blocked_by_peer` on the blocked side) -- see getConversationContext's own + // doc comment for why `getUserProfile`'s similar-looking `capabilities.can_send` can't be reused for + // this (403s entirely from the blocked party's own perspective instead of reporting false). + const conversationContextQuery = useQuery({ + queryKey: ['conversation-context', topicId], + queryFn: () => getConversationContext(topicId || ''), + enabled: !!topicId && !isGroup && !isSelf, + }); + const blockedNotice = + conversationContextQuery.data?.relationship === 'blocked_by_me' + ? '你已将对方加入黑名单,无法发送消息' + : conversationContextQuery.data?.relationship === 'blocked_by_peer' + ? '对方已将你加入黑名单,暂时无法发送消息' + : conversationContextQuery.data?.can_send === false + ? '暂时无法向对方发送消息' + : null; const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId || ''), @@ -973,6 +995,10 @@ export function ConversationView() {
) : (
+ {blockedNotice ? ( +
{blockedNotice}
+ ) : ( + <> {replyTo ? (
@@ -1101,6 +1127,8 @@ export function ConversationView() {
)} + + )}
)} diff --git a/web/src/lib/api/friends.ts b/web/src/lib/api/friends.ts index 965a7984..7e65960b 100644 --- a/web/src/lib/api/friends.ts +++ b/web/src/lib/api/friends.ts @@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) { return imGet(`/conversations/${encodeURIComponent(topic)}/settings`); } +/** + * P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on + * the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities. + * can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28): + * sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试" + * with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can + * never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line + * 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows, + * so there's no error object left for application code to read once a send has already failed. This + * endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after. + * `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already + * fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of + * returning a normal response, so it can't be used for this specific check either. + */ +export interface ConversationContext { + topic: string; + peer_user_id: string; + display_name: string; + can_send: boolean; + relationship: 'blocked_by_me' | 'blocked_by_peer' | string; + capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean }; +} + +export function getConversationContext(topic: string) { + return imGet(`/conversations/${encodeURIComponent(topic)}/context`); +} + export function updateConversationSettings(topic: string, body: Partial> & { version: number }) { return imPatch(`/conversations/${encodeURIComponent(topic)}/settings`, body); } diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 7e054a7e..5ca1c16a 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -61,7 +61,14 @@ export const IMAGE_THUMBNAIL_DIM = 36; export const VIDEO_THUMBNAIL_WIDTH = 48; export const MAX_ONLINE_IN_TOPIC = 4; -export const MAX_TITLE_LENGTH = 60; +/** `im_be/server/im_http.go`'s `serveIMGroupCreate`: `len([]rune(name)) < 1 || > 32` rejects outright + * with a bare MALFORMED_REQUEST (no `details.field`, unlike the description-length check right next + * to it) -- was `60` here, a value that let CreateGroupModal's input accept and submit names the + * backend always rejects, surfacing only the generic "内容不符合平台规范" (indistinguishable from an + * actual content-moderation hit, since the backend response itself doesn't say which). Capping input + * at the real limit prevents the over-length case from ever reaching the backend at all (live- + * confirmed real bug, Zentao #26). */ +export const MAX_TITLE_LENGTH = 32; /** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */ export const MAX_TOPIC_DESCRIPTION_LENGTH = 200; export const MAX_PEER_TITLE_LENGTH = 24; diff --git a/web/src/stores/calls.ts b/web/src/stores/calls.ts index e102db5b..b4ef59e9 100644 --- a/web/src/stores/calls.ts +++ b/web/src/stores/calls.ts @@ -10,7 +10,7 @@ import { } from '@/lib/calls/agora-client'; import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls'; import { getTinode } from '@/lib/tinode/connection'; -import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics'; +import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended'; @@ -243,12 +243,21 @@ export function initCallSignaling() { subscribeAnyTopicChange(() => { const myUserId = useSessionStore.getState().myUserId; if (!myUserId) return; + // NOT `getTinode().getMeTopic().contacts()` -- live-confirmed real bug this fixes (user-reported: + // a whole incoming-call test milestone blocked, WS packet capture confirmed the invite genuinely + // arrives, but no incoming-call UI ever appeared): `contacts()` reads the `me` topic's OWN + // internally-tracked subscription list, populated only once `me`'s `{meta sub}` sync reports this + // specific contact -- but a P2P topic can be fully subscribed and sitting in the SDK's topic cache + // (able to receive and store a real incoming call invite) well before that sync catches up. This + // exact gap is already documented on `getChatListSnapshot`'s own comment (topics.ts) as the reason + // that function uses `mapTopics` instead -- this call-signaling listener just never got the same + // fix when it was written. Confirmed live: a real two-account call, `me.contacts()` on the callee's + // own live session enumerated ONE contact (a group) and silently omitted the caller entirely, while + // `getChatListSnapshot()`'s own list correctly showed both. const peerNames = new Map(); - getTinode() - .getMeTopic() - .contacts((c) => { - if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic); - }); + for (const contact of getChatListSnapshot()) { + if (contact.topic) peerNames.set(contact.topic, contact.public?.fn || contact.topic); + } for (const topicName of peerNames.keys()) { const messages = getMessagesSnapshot(topicName);