Files
im_webapp/web/src/lib/api/friends.ts
T
Your Name 0c5b329179 修复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)互相操作实机验证。
2026-09-17 21:51:00 +08:00

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)}` : ''}`);
}