fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s

This commit is contained in:
Your Name
2026-10-19 17:23:34 +08:00
parent c39ec78e89
commit db18514e6d
11 changed files with 898 additions and 60 deletions
+164 -20
View File
@@ -25,18 +25,57 @@ export interface ChatMessage {
file_name?: string;
}
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
interface CustomerServiceChatOptions {
origin?: string;
onConnectionState?: (state: CustomerServiceConnectionState) => void;
}
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
const pageOrigin = new URL(origin);
const sameOriginURL = (value: string, fallback: string) => {
const configured = new URL(value || fallback, pageOrigin);
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
return resolved.toString().replace(/\/$/, '');
};
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
return {
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
fileURL: sameOriginURL(session.file_url, '/v0/file'),
websocketURL: websocket.toString().replace(/\/$/, ''),
};
}
export class CustomerServiceChat {
private static readonly reconnectPageSize = 50;
private static readonly maxReconnectPages = 100;
private tinode: any;
private topic: any;
private currentUserID: string;
private onMessages: (messages: ChatMessage[]) => void;
private session: CustomerServiceSession;
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
private token = '';
private sessionActive = false;
private intentionalDisconnect = false;
private recovery: Promise<void> | null = null;
private onConnectionState: (state: CustomerServiceConnectionState) => void;
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
const endpoint = new URL(session.websocket_url);
constructor(
session: CustomerServiceSession,
onMessages: (messages: ChatMessage[]) => void,
options: CustomerServiceChatOptions = {},
) {
const origin = options.origin || window.location.origin;
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
const endpoint = new URL(this.endpoints.websocketURL);
this.session = session;
this.token = session.token;
this.currentUserID = session.current_user_id;
this.onMessages = onMessages;
this.onConnectionState = options.onConnectionState || (() => undefined);
this.tinode = new Tinode({
appName: '悦选',
host: endpoint.host,
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
this.installTenantPatch(session.tenant_code);
this.topic = this.tinode.getTopic(session.topic);
this.topic.onData = () => this.refreshMessages();
this.tinode.onDisconnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState('reconnecting');
}
};
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
}
};
this.tinode.onConnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
return this.restoreAfterReconnect();
}
};
}
async connect(token: string) {
this.intentionalDisconnect = false;
this.token = token;
this.onConnectionState('connecting');
await this.tinode.connect();
const login = await this.tinode.loginToken(token);
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话登录失败');
this.acceptRotatedToken(login);
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
const subscribed = await this.topic.subscribe(query);
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
this.sessionActive = true;
this.onConnectionState('online');
this.refreshMessages();
}
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
}
disconnect() {
this.intentionalDisconnect = true;
this.sessionActive = false;
this.onConnectionState('offline');
try {
this.topic?.leave?.(false);
} finally {
@@ -108,6 +170,85 @@ export class CustomerServiceChat {
}
}
private restoreAfterReconnect() {
if (this.recovery) return this.recovery;
this.recovery = (async () => {
this.onConnectionState('reconnecting');
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
this.acceptRotatedToken(login);
await this.restoreSubscriptionAndMessages();
this.refreshMessages();
this.onConnectionState('online');
})()
.catch(() => {
this.onConnectionState('offline');
})
.finally(() => {
this.recovery = null;
});
return this.recovery;
}
private acceptRotatedToken(login: any) {
const rotated = String(login?.params?.token || '');
if (!rotated) return;
this.token = rotated;
this.session.token = rotated;
}
private async restoreSubscriptionAndMessages() {
const pageSize = CustomerServiceChat.reconnectPageSize;
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
for (let page = 1; count >= pageSize; page += 1) {
if (page >= CustomerServiceChat.maxReconnectPages) {
throw new Error('客服消息积压过多,请重新进入客服会话');
}
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
if (count > 0 && currentMax <= previousMax) {
throw new Error('客服历史消息补拉未推进');
}
}
}
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
let observedCount: number | undefined;
let resolveObserved: ((count: number) => void) | undefined;
const observed = new Promise<number>((resolve) => {
resolveObserved = resolve;
});
const previousHandler = this.topic.onAllMessagesReceived;
this.topic.onAllMessagesReceived = (count: number) => {
previousHandler?.(count);
observedCount = Number(count || 0);
resolveObserved?.(observedCount);
};
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const response = await request();
if (response?.code >= 300) throw new Error(errorMessage);
if (observedCount !== undefined) return observedCount;
return await Promise.race([
observed,
new Promise<number>((_, reject) => {
// A Tinode request promise resolves before the SDK finishes routing
// its data packets. Only this completion callback guarantees that
// maxMsgSeq and the local message cache belong to the finished page.
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
this.topic.onAllMessagesReceived = previousHandler;
}
}
private installTenantPatch(tenantCode: string) {
const connection = this.tinode._connection;
if (!connection || connection.__olonsTenantPatched) return;
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
}
private async upload(file: File, onProgress?: (progress: number) => void) {
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url);
const uploadURL = new URL(this.endpoints.fileURL);
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
uploadURL.searchParams.set('apikey', this.session.api_key);
uploadURL.searchParams.set('auth', 'token');
uploadURL.searchParams.set('secret', this.session.token);
const form = new FormData();
form.set('id', clientMID());
form.set('file', file, file.name);
onProgress?.(0.1);
let response: Response;
try {
// Do not attach XMLHttpRequest upload progress listeners here. For a
// cross-origin IM endpoint they trigger a CORS preflight which the file
// service does not expose, while multipart/form-data itself is accepted.
response = await fetch(uploadURL.toString(), { method: 'POST', body: form });
// Keep uploads on fetch so API and token credentials stay in headers and
// never leak into the URL, browser history, or proxy access logs.
response = await fetch(uploadURL.toString(), {
method: 'POST',
headers: {
'X-MeetingIM-APIKey': this.session.api_key,
Authorization: `Token ${this.token}`,
},
body: form,
});
} catch {
throw new Error('媒体上传失败,请检查网络后重试');
}
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
private mediaURL(ref?: string) {
if (!ref) return '';
const apiBase = new URL(this.session.api_base_url);
const apiBase = new URL(this.endpoints.apiBaseURL);
const resolved = new URL(ref, apiBase);
// The SDK signs relative file URLs. Also tolerate historical absolute IM
// references by converting same-origin URLs back to a signable path.
const signable = resolved.origin === apiBase.origin
? `${resolved.pathname}${resolved.search}`
: ref;
// Historical messages may contain the old absolute IM host. Route only
// known IM file paths through the current frontend origin; leave unrelated
// external media untouched.
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
const signable = `${resolved.pathname}${resolved.search}`;
const authorized = this.tinode.authorizeURL(signable);
return new URL(authorized, this.session.api_base_url).toString();
return new URL(authorized, this.endpoints.apiBaseURL).toString();
}
}