Files
im_webapp/web/src/lib/tinode/errors.ts
T
Your NameandClaude Sonnet 5 df22f26628 修复刷新/深链进入已解散的群聊时显示错乱,改成直接跳回消息列表
用户实机截图报告:一个群被解散后,如果直接刷新页面(或深链)停留在这个群的
URL上,标题栏/侧边栏显示原始topic id(不是群名)、"1970/1/1"的时间戳、
"0人"成员数,而不是跳转离开。

这跟已有的"正在查看时被实时解散"处理(subscribeTopicGone/onDeleteTopic)
不是同一条路径——那个只覆盖"已经订阅成功、活跃看着的时候被解散",覆盖不了
"这次页面加载压根没订阅成功过"的情况。实机复现确认:openConversation()对
一个已解散群的{sub}请求会正确收到404 CommError拒绝,但ConversationView
原来的catch只弹一个通用的"加载会话失败,请重试"提示,不会跳转,会话视图就
停留在一个半初始化的空壳状态。

修复:新增isTopicGone()识别这个404,命中后跟"实时解散"走一样的用户体验
(toast提示+跳转回/messages)。同时发现并修复了一个连带的清理问题——单纯
调用getTinode().getTopic(name)(openConversation内部一开始就会调,不管
后面订阅成不成功)就已经在SDK本地缓存里留下一个空壳对象,光是这个空壳存在
就足以让侧边栏显示出这条错乱的记录;而清理这个空壳时又跟ConversationView
自己卸载时的closeConversation()清理逻辑(它自己也会重新调用getTopic())
产生竞态——直接删除后又被重新创建。改成维护一个"确认已消失"的名单,让聊天
列表的过滤逻辑直接按名字永久排除,不再依赖谁先执行、谁后执行。

实机验证:真实解散一个群后,直接deep-link/刷新回这个群的URL——确认页面
立即跳转回/messages,且侧边栏不再出现这条已解散群的残留记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:49:17 +08:00

140 lines
7.6 KiB
TypeScript

// Tinode WS {ctrl} error translation. Registration/login go over the native WS protocol, not
// REST, so client.ts's REASON_MESSAGES table (REST-only) never sees these -- the SDK's own
// CommError text is raw English with a status-code suffix baked in ("authentication failed (401)"),
// confirmed live against the real backend. Ported from the old app's tinode-web.jsx (CTRL_ERROR_TEXT_CN
// / translateCtrlError / loginErrorMessage), not guessed -- this exact table is what made login/register
// error copy match the product spec instead of leaking SDK internals.
const CTRL_ERROR_TEXT_CN: Record<string, string> = {
'authentication failed': '账号或密码错误',
'authentication required': '请先登录',
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录',
'internal error': '服务器繁忙,请稍后重试',
// Confirmed live 2026-09-09: registering with a username or phone number that's already taken
// returns this exact raw SDK text ("duplicate credential (409)") -- without this entry,
// registerErrorMessage()'s fallback showed the raw English straight to a Chinese-language user.
'duplicate credential': '该用户名或手机号已被注册,请更换后重试',
// docs/IM-前后端对接文档.md's own worked example (`{"login":...,"secret":"其他租户 token"}` ->
// `403 tenant mismatch`) -- without this entry, loginErrorMessage's generic `code===403` fallback
// below shows "账号或密码错误" for what's actually a stale-token-from-a-different-tenant case, which
// is a materially different, more confusing message (the password wasn't wrong at all).
'tenant mismatch': '登录状态异常,请重新进入企业码',
};
function translateCtrlError(err: unknown): string | null {
const raw = (err as Error)?.message;
if (!raw) return null;
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
return CTRL_ERROR_TEXT_CN[normalized] || null;
}
/** docs/IM-前后端对接文档.md: this specific reason means the client is holding a session token
* issued by a DIFFERENT tenant than the one it's currently connected to -- the doc's own prescribed
* recovery is "清 Token 并回企业码页" (clear the token and return to the tenant-code entry screen),
* not just show an error and let the user retry the same broken state. */
export function isTenantMismatch(err: unknown): boolean {
const raw = (err as Error)?.message;
if (!raw) return false;
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
}
/**
* `openConversation()`'s `{sub}` genuinely rejects with a clean `CommError` (code 404, message
* "topic not found (404)") for a topic that no longer exists -- confirmed live: a group dissolved
* by its owner, then deep-linked/refreshed into directly by a former member (not the "I'm actively
* watching it get dissolved right now" case subscribeTopicGone already covers), reproducibly 404s
* exactly like this. Before this check existed, ConversationView's generic catch just showed
* "加载会话失败,请重试" and left the screen sitting on a half-initialized topic stub (raw topic id
* as the name, "1970/1/1" for `touched`, "0" members -- `getTinode().getTopic()` lazily creates that
* stub the moment anything asks for the name, whether or not the subscribe behind it ever succeeds),
* a real reported bug ("当打开的群聊被删除后, 刷新还在群聊中 但是显示的不对"). 404 alone is treated
* as sufficiently unambiguous for "this topic doesn't exist" -- there's no other real reason a
* `{sub}` would come back with this exact code.
*/
export function isTopicGone(err: unknown): boolean {
return (err as { code?: number })?.code === 404;
}
export function loginErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
if (known) return known;
const code = (err as { code?: number })?.code;
if (code === 401 || code === 403) return '账号或密码错误';
return (err as Error)?.message || '登录失败,请重试';
}
export function registerErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
return known || (err as Error)?.message || '注册失败,请稍后重试';
}
/**
* Only INVITE_CODE_REQUIRED/INVALID were confirmed against a live server; REVOKED/EXPIRED are
* inferred siblings under the same naming pattern (not individually exercised).
*/
const INVITE_CODE_REASON_TEXT_CN: Record<string, string> = {
INVITE_CODE_REQUIRED: '请填写邀请码',
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码',
};
export interface ValidateCredentialsParams {
reason?: string;
cred?: string[];
token?: string;
code?: string;
}
/**
* ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created, now
* confirm a real credential (email/SMS code)" -- ctrl.params.cred is a non-empty array in that case.
* This backend reuses the exact same code/text pair for its own invite-code rejections, which carry
* `reason` instead of `cred`. This app's registration flow always pre-verifies the phone credential
* over REST before calling createAccountBasic, so a genuine pending-credential case here would mean
* an account created some other way (e.g. by an admin) -- there's no credential-confirmation screen
* built yet for that (out of scope for now), so it gets an honest, terminal message rather than a
* silent dead end.
*/
export function validateCredentialsMessage(params: ValidateCredentialsParams | undefined): string {
if (Array.isArray(params?.cred) && params.cred.length) {
return '账号存在未确认的验证信息,请联系企业管理员处理';
}
const reason = params?.reason;
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
}
/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording,
* so this stays a straight lookup rather than something to rephrase later without checking the doc
* again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table
* merges them into a single row). */
const KICKED_REASON_TEXT_CN: Record<string, { title: string; message: string }> = {
replaced_by_new_login: {
title: '账号已在其他设备登录',
message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。',
},
admin_user_evict: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
admin_device_kick: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
user_deleted: {
title: '账号已注销',
message: '您的账号已注销,当前会话已退出。',
},
user_state_changed: {
title: '账号已被限制登录',
message: '您的账号已被管理员限制登录,如有疑问请联系管理员。',
},
};
const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' };
export function kickedNoticeText(reason: string | undefined): { title: string; message: string } {
return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK;
}