修复Zentao批量报告的6个前端bug:来电UI不弹出/黑名单发送无提示/群名校验/群人数与通话记录显示uid

用户提供了一份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)互相操作实机验证。
This commit is contained in:
Your Name
2026-09-17 21:51:00 +08:00
parent 50767f701f
commit 0c5b329179
8 changed files with 124 additions and 22 deletions
+14 -4
View File
@@ -28,9 +28,12 @@ function statusLabel(item: CallHistoryItem): string {
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in /** 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` * 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 * 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 * from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to * to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
* a display name via the chat list, same lookup ChatListColumn itself uses. * 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() { export function CallHistoryView() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -80,7 +83,14 @@ export function CallHistoryView() {
// something the viewer missed and should feel warned about. This used to redden EVERY // something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike. // non-'finished' result alike.
const isMissed = result === 'missed' && incoming; 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; const ts = item.started_at || item.created_at;
return ( return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3"> <div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
+5 -1
View File
@@ -96,7 +96,11 @@ export function GroupListView() {
) : null} ) : null}
</span> </span>
<span className="truncate text-xs text-text-tertiary-2"> <span className="truncate text-xs text-text-tertiary-2">
{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}
</span> </span>
</span> </span>
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" /> <Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
+19 -8
View File
@@ -62,14 +62,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
let result: Awaited<ReturnType<typeof createGroup>>; let result: Awaited<ReturnType<typeof createGroup>>;
// 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 { try {
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a // Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
// combination of member nicknames when left blank), but `name` has no such fallback // it had to cut names short, so this can't itself produce an over-length name the server would
// server-side (unlike `description`, which the protocol doc explicitly allows empty) -- // also reject.
// 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.
const trimmedName = name.trim(); const trimmedName = name.trim();
let finalName = trimmedName; let finalName = trimmedName;
if (!finalName) { if (!finalName) {
@@ -81,7 +83,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
member_user_ids: [...selected.keys()], member_user_ids: [...selected.keys()],
}); });
} catch (err) { } 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); setSubmitting(false);
return; return;
} }
+7 -1
View File
@@ -149,6 +149,12 @@ export function ChatListColumn() {
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId); const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic; const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType(); 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 memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications; const muted = !!contact.private?.mute_notifications;
const avatarFileId = const avatarFileId =
@@ -172,7 +178,7 @@ export function ChatListColumn() {
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong> <strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right {/* 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. */} after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
{typeof memberCount === 'number' ? ( {typeof memberCount === 'number' && memberCount > 0 ? (
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span> <span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
) : null} ) : null}
</span> </span>
+29 -1
View File
@@ -19,7 +19,7 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks'; import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; 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 { getMe } from '@/lib/api/profile';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks'; import { useClickOutside } from '@/lib/hooks';
@@ -482,6 +482,28 @@ export function ConversationView() {
queryFn: () => getUserProfile(peerUserId || ''), queryFn: () => getUserProfile(peerUserId || ''),
enabled: !!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({ const membersQuery = useQuery({
queryKey: ['group-members', topicId], queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''), queryFn: () => getAllGroupMembers(topicId || ''),
@@ -973,6 +995,10 @@ export function ConversationView() {
</div> </div>
) : ( ) : (
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2"> <div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
{blockedNotice ? (
<div className="flex h-18 items-center justify-center text-sm text-text-tertiary-3">{blockedNotice}</div>
) : (
<>
{replyTo ? ( {replyTo ? (
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1"> <div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
<span className="truncate"> <span className="truncate">
@@ -1101,6 +1127,8 @@ export function ConversationView() {
</div> </div>
</> </>
)} )}
</>
)}
</div> </div>
)} )}
+27
View File
@@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) {
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`); return imGet<ConversationSettings>(`/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<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
}
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) { export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body); return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
} }
+8 -1
View File
@@ -61,7 +61,14 @@ export const IMAGE_THUMBNAIL_DIM = 36;
export const VIDEO_THUMBNAIL_WIDTH = 48; export const VIDEO_THUMBNAIL_WIDTH = 48;
export const MAX_ONLINE_IN_TOPIC = 4; 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 字符". */ /** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200; export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
export const MAX_PEER_TITLE_LENGTH = 24; export const MAX_PEER_TITLE_LENGTH = 24;
+15 -6
View File
@@ -10,7 +10,7 @@ import {
} from '@/lib/calls/agora-client'; } from '@/lib/calls/agora-client';
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls'; import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
import { getTinode } from '@/lib/tinode/connection'; 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'; import { useSessionStore } from '@/stores/session';
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended'; export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
@@ -243,12 +243,21 @@ export function initCallSignaling() {
subscribeAnyTopicChange(() => { subscribeAnyTopicChange(() => {
const myUserId = useSessionStore.getState().myUserId; const myUserId = useSessionStore.getState().myUserId;
if (!myUserId) return; 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<string, string>(); const peerNames = new Map<string, string>();
getTinode() for (const contact of getChatListSnapshot()) {
.getMeTopic() if (contact.topic) peerNames.set(contact.topic, contact.public?.fn || contact.topic);
.contacts((c) => { }
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
});
for (const topicName of peerNames.keys()) { for (const topicName of peerNames.keys()) {
const messages = getMessagesSnapshot(topicName); const messages = getMessagesSnapshot(topicName);