企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store

lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。

lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。

实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 00:38:50 +08:00
co-authored by Claude Sonnet 5
parent 8bcd9f3a5b
commit 8cdd84152d
11 changed files with 629 additions and 13 deletions
+211
View File
@@ -0,0 +1,211 @@
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS
// protocol. Ported from the previous im_webapp's src/lib/im-api.js -- the header names, the
// reason-code translation table, and the batch-endpoint "always 200, real result in items[]"
// quirk are all hard-won facts about this exact backend (im_team/im_server, server/im_http.go),
// verified against a live deployment over months of integration work. They are not guesses and
// should not be "cleaned up" back to what the upstream Tinode docs say (this deployment's header
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
// a real, repeatedly-rediscovered bug in the old codebase).
import { API_KEY } from '@/lib/config';
import { useSessionStore } from '@/stores/session';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
export class ImApiError extends Error {
reason?: string;
status?: number;
details?: unknown;
constructor(message: string, reason?: string, status?: number, details?: unknown) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
/** docs/IM-前后端对接文档.md §12.4's full reason list -- see client.ts's header comment. */
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
PERMISSION_DENIED: '没有权限执行此操作',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
AUTH_REQUIRED: '请先登录',
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
VERIFICATION_RATE_LIMITED: (details) =>
isRetryAfter(details) ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试',
ALREADY_GROUP_MEMBER: '对方已经是群成员',
DESCRIPTION_REQUIRED: '请填写简介内容',
DESCRIPTION_TOO_LONG: '简介内容过长',
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
// verification-code verify) -- keep generic enough to be accurate for both.
CODE_INVALID: '验证码或独立密码不正确',
TOO_MANY_ATTEMPTS: (details) =>
isRetryAfter(details) ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试',
};
function isRetryAfter(details: unknown): details is { retry_after_seconds: number } {
return !!details && typeof details === 'object' && 'retry_after_seconds' in details;
}
export function translateReason(reason: string | undefined, details?: unknown): string | null {
if (!reason) return null;
const entry = REASON_MESSAGES[reason];
return (typeof entry === 'function' ? entry(details) : entry) || null;
}
function requestId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
function apiBase(): string {
const endpoints = useSessionStore.getState().tenant?.endpoints;
return (endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
}
export function fileBase(): string {
const endpoints = useSessionStore.getState().tenant?.endpoints;
return (endpoints?.file || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
export function currentTenantCode(): string {
return useSessionStore.getState().tenant?.code || 'default';
}
/** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */
function authToken(): string {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload: { code?: number; data?: unknown }): unknown {
return payload && payload.code === 0 && payload.data !== undefined ? payload.data : payload;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
headers?: Record<string, string>;
auth?: boolean;
tenantCodeOverride?: string;
}
/** Core request helper. `auth: false` skips the Authorization header (pre-login calls). */
export async function imRequest<T = unknown>(
path: string,
{ method = 'GET', body, headers, auth = true, tenantCodeOverride }: RequestOptions = {},
): Promise<T> {
const finalHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'X-MeetingIM-APIKey': API_KEY,
'X-IM-Schema': '1',
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
'X-Request-ID': requestId(),
...headers,
};
if (auth) {
const token = authToken();
if (token) finalHeaders.Authorization = `Bearer ${token}`;
}
const response = await fetch(apiBase() + path, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let payload: { code?: number; data?: unknown; reason?: string; message?: string; details?: unknown } = {};
if (text) {
try {
payload = JSON.parse(text);
} catch {
throw new ImApiError(
response.ok ? '接口返回非 JSON 数据' : response.statusText || '接口请求失败',
'NON_JSON_RESPONSE',
response.status,
text.slice(0, 160),
);
}
}
if (!response.ok || (payload?.code && payload.code !== 0)) {
const known = translateReason(payload.reason, payload.details);
throw new ImApiError(
known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason,
response.status,
payload.details,
);
}
return normalizeResponse(payload) as T;
}
const idem = () => ({ 'Idempotency-Key': requestId() });
export const imGet = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'POST', body, headers: idem(), ...opts });
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'PATCH', body, headers: idem(), ...opts });
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'PUT', body, headers: idem(), ...opts });
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
imRequest<T>(path, { method: 'DELETE', headers: idem(), ...opts });
/**
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
* only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real
* answer was on page 2). Empty/repeated next_cursor means done.
*/
export async function fetchAllPages<T>(
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
startCursor = '',
): Promise<T[]> {
let cursor = startCursor;
let items: T[] = [];
for (;;) {
const page = await fetchPage(cursor);
items = items.concat(page?.items || []);
const nextCursor = page?.next_cursor || '';
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return items;
}