281 lines
14 KiB
TypeScript
281 lines
14 KiB
TypeScript
// 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 { getKickedInfo } from '@/lib/tinode/connection';
|
|
import { useSessionStore } from '@/stores/session';
|
|
|
|
const DEFAULT_API_BASE = '/v1/im';
|
|
const DEFAULT_FILE_BASE = '/v0/file';
|
|
const CONFIGURED_API_BASE = import.meta.env.VITE_TINODE_API_BASE?.trim();
|
|
const CONFIGURED_FILE_BASE = import.meta.env.VITE_TINODE_FILE_BASE?.trim();
|
|
|
|
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: '没有权限执行此操作',
|
|
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
|
|
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
|
|
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
|
|
// internal message or the bare reason string straight to the user for this exact reason code,
|
|
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
|
|
FORBIDDEN: '没有权限执行此操作',
|
|
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
|
|
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
|
|
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: '邮箱格式不正确',
|
|
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
|
|
TOO_MANY_FILES: '文件数量超出限制',
|
|
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
|
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
|
GROUP_MUTED: '你已被禁言,无法发言',
|
|
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
|
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
|
|
INVITATION_EXPIRED: '邀请已过期',
|
|
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
|
|
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
|
|
FILE_TOO_LARGE: '文件大小超出限制',
|
|
FILE_FORBIDDEN: '没有权限访问此文件',
|
|
FILE_UNAVAILABLE: '文件暂不可用',
|
|
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
|
|
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
|
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
|
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
|
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
|
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
|
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
|
|
// friends, or a request between the two of you (either direction) is already pending.
|
|
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
|
|
USER_NOT_DISCOVERABLE: '未找到该用户',
|
|
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
|
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
|
// 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function requestId(): string {
|
|
return typeof crypto !== 'undefined' && crypto.randomUUID
|
|
? crypto.randomUUID()
|
|
: `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
export function apiBase(): string {
|
|
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
|
// Production serves the static web app and IM API from different origins. The configured origin
|
|
// must also work before a tenant has been resolved, while local development keeps using Vite's
|
|
// same-origin proxy by leaving this value unset.
|
|
return (CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
}
|
|
|
|
export function fileBase(): string {
|
|
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
|
// Production may expose the web UI and the IM API through different deployments. Prefer the
|
|
// build-time canonical file origin so a tenant context previously resolved through the web host
|
|
// cannot keep routing attachments to that host's stale /v0/file proxy. Local development leaves
|
|
// this unset and continues to use the resolved relative endpoint through Vite's proxy.
|
|
return (CONFIGURED_FILE_BASE || 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". */
|
|
export 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) {
|
|
const known = translateReason(payload.reason, payload.details);
|
|
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
|
|
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
|
|
// else in this client uniformly handled this before, so a session invalidated mid-use just made
|
|
// every subsequent call fail the same way forever with no path back to a real login.
|
|
if (auth && response.status === 401 && authToken()) forceReauth();
|
|
throw new ImApiError(
|
|
known || payload.message || payload.reason || response.statusText || '请求失败',
|
|
payload.reason,
|
|
response.status,
|
|
payload.details,
|
|
);
|
|
}
|
|
return normalizeResponse(payload) as T;
|
|
}
|
|
|
|
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
|
|
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
|
|
* cache, any open modal's local state) resets from scratch rather than needing each of them to
|
|
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
|
|
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
|
|
* token expires) only navigates once. */
|
|
let reauthTriggered = false;
|
|
function forceReauth() {
|
|
if (reauthTriggered) return;
|
|
sessionStorage.removeItem('im-session-token');
|
|
useSessionStore.getState().setMyUserId(null);
|
|
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
|
|
reauthTriggered = true;
|
|
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
|
|
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
|
|
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
|
|
// network timing, not something this code controls). Without this, a REST 401 that happens to
|
|
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
|
|
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
|
|
// that WS message (usually already in flight over an open connection, typically faster than a
|
|
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
|
|
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
|
|
// by far the common case) just navigate ~200ms later than before -- not perceptible.
|
|
window.setTimeout(() => {
|
|
if (getKickedInfo()) return;
|
|
window.location.href = '/login';
|
|
}, 200);
|
|
}
|
|
|
|
const idem = () => ({ 'Idempotency-Key': requestId() });
|
|
|
|
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
|
|
// caller that needs its own extra header can't accidentally drop the Idempotency-Key.
|
|
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, ...opts, headers: { ...idem(), ...opts.headers } });
|
|
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
|
imRequest<T>(path, { method: 'PATCH', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
|
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
|
imRequest<T>(path, { method: 'PUT', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
|
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
|
|
imRequest<T>(path, { method: 'DELETE', ...opts, headers: { ...idem(), ...opts.headers } });
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
// Guards against a server-side cursor bug (e.g. two values that cycle back and forth) turning this
|
|
// into an infinite fetch loop -- 200 pages at a 20-50 item page size is already far more than any
|
|
// realistic friends/blocks/search list, so hitting this is itself a signal something's wrong server-
|
|
// side, not a real user's data legitimately needing more.
|
|
const MAX_PAGES = 200;
|
|
|
|
export async function fetchAllPages<T>(
|
|
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
|
|
startCursor = '',
|
|
): Promise<T[]> {
|
|
let cursor = startCursor;
|
|
let items: T[] = [];
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const result = await fetchPage(cursor);
|
|
items = items.concat(result?.items || []);
|
|
const nextCursor = result?.next_cursor || '';
|
|
if (!nextCursor || nextCursor === cursor) break;
|
|
cursor = nextCursor;
|
|
}
|
|
return items;
|
|
}
|