用户提供了一份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)互相操作实机验证。
205 lines
9.7 KiB
TypeScript
205 lines
9.7 KiB
TypeScript
// Friend/conversation-settings/blocklist REST endpoints. Paths and payload shapes ported from the
|
|
// old app's src/lib/im-api.js (contact-management-view.jsx's real "聊天设置" panel, not the mock
|
|
// direct-message-view.jsx) -- verified there, not guessed.
|
|
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
|
|
|
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
|
|
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
|
|
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
|
|
export interface Friend {
|
|
user_id: string;
|
|
display_name?: string;
|
|
remark?: string;
|
|
avatar_file_id?: string;
|
|
initial?: string;
|
|
topic?: string;
|
|
relationship?: string;
|
|
online_visibility?: string;
|
|
capabilities?: { can_send?: boolean; can_call?: boolean };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export function getFriends(query = '', cursor = '', initial = '') {
|
|
const qs = new URLSearchParams();
|
|
if (query) qs.set('query', query);
|
|
if (initial) qs.set('initial', initial);
|
|
if (cursor) qs.set('cursor', cursor);
|
|
const suffix = qs.toString();
|
|
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
|
|
}
|
|
|
|
export function deleteFriend(userId: string) {
|
|
return imDelete<void>(`/friends/${encodeURIComponent(userId)}`);
|
|
}
|
|
|
|
export function updateFriendRemark(userId: string, remark: string) {
|
|
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
|
|
}
|
|
|
|
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
|
|
* nothing in the response describing what UI affordance to show -- that's derived client-side
|
|
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
|
|
export interface UserSearchResult {
|
|
user_id: string;
|
|
display_name: string;
|
|
avatar_file_id?: string;
|
|
public_account?: string;
|
|
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
|
|
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
|
|
// comparison could ever match either real pending state).
|
|
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
|
can_add: boolean;
|
|
}
|
|
|
|
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
|
|
const qs = new URLSearchParams({ q: query, type });
|
|
if (cursor) qs.set('cursor', cursor);
|
|
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
|
|
}
|
|
|
|
export function sendFriendRequest(targetUserId: string, message?: string, source = 'search', sourceTopic?: string) {
|
|
return imPost<{ request_id: string; topic: string; status: string }>('/friend-requests', {
|
|
target_user_id: targetUserId,
|
|
message,
|
|
source,
|
|
source_topic: sourceTopic,
|
|
});
|
|
}
|
|
|
|
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
|
|
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
|
|
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
|
|
export interface FriendRequest {
|
|
request_id: string;
|
|
applicant_id: string;
|
|
target_user_id: string;
|
|
direction: 'inbound' | 'outbound';
|
|
status: string;
|
|
message?: string;
|
|
source?: string;
|
|
created_at: string;
|
|
handled_at?: string;
|
|
peer?: { user_id: string; display_name?: string };
|
|
topic?: string;
|
|
}
|
|
|
|
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
|
|
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
|
|
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
|
|
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
|
|
* whatever an unfiltered call happens to return. */
|
|
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
|
|
const qs = new URLSearchParams({ direction, status });
|
|
if (cursor) qs.set('cursor', cursor);
|
|
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
|
|
}
|
|
|
|
export function acceptFriendRequest(requestId: string, version?: number) {
|
|
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version !== undefined ? { version } : {});
|
|
}
|
|
|
|
export function rejectFriendRequest(requestId: string, version?: number) {
|
|
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
|
|
}
|
|
|
|
/**
|
|
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
|
|
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
|
|
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
|
|
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
|
|
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
|
|
* between the doc and what this deployment actually returns right now.
|
|
*/
|
|
export interface UserProfile {
|
|
user_id: string;
|
|
display_name: string;
|
|
remark?: string;
|
|
avatar_file_id?: string;
|
|
gender?: string;
|
|
region?: string;
|
|
public_account?: string;
|
|
signature?: string;
|
|
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
|
|
// pending states, never a bare "pending".
|
|
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
|
topic?: string;
|
|
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
|
|
}
|
|
|
|
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
|
|
* own user_id (Tinode convention), so DetailColumn can pass `topicId` straight through. */
|
|
export function getUserProfile(identifier: string) {
|
|
return imGet<UserProfile>(`/users/${encodeURIComponent(identifier)}/profile`);
|
|
}
|
|
|
|
export interface ConversationSettings {
|
|
pinned: boolean;
|
|
mute_notifications: boolean;
|
|
version: number;
|
|
}
|
|
|
|
/** P2P-only -- group pin/mute goes through a different, group-specific mechanism (native Tinode private/tags). */
|
|
export function getConversationSettings(topic: string) {
|
|
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 }) {
|
|
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
|
|
}
|
|
|
|
export function blockUser(userId: string) {
|
|
return imPut<void>(`/blocks/${encodeURIComponent(userId)}`);
|
|
}
|
|
|
|
export function unblockUser(userId: string) {
|
|
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
|
|
}
|
|
|
|
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
|
|
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
|
|
* that comes from assuming a 200 means every item succeeded). */
|
|
export function batchUnblockUsers(userIds: string[]) {
|
|
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
|
|
user_ids: userIds,
|
|
});
|
|
}
|
|
|
|
export interface BlockedUser {
|
|
user: { user_id: string; display_name?: string; avatar_file_id?: string };
|
|
blocked_at: string;
|
|
}
|
|
|
|
/** Unlike `/friends`, this endpoint nests the user under `.user` (docs/IM-前后端对接文档.md:1379,1428,
|
|
* both the prose and the JSON example agree, and the old app's own consumer reads `item.user.
|
|
* display_name` -- not a flat item like Friend). */
|
|
export function getBlocks(cursor = '') {
|
|
return imGet<{ items: BlockedUser[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
|
|
}
|