fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
Deploy olons-server production / deploy (push) Successful in 3m38s
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
|
||||
|
||||
vi.mock('tinode-sdk', () => {
|
||||
class QueryBuilder {
|
||||
private kind = '';
|
||||
private since?: number;
|
||||
private limit?: number;
|
||||
|
||||
constructor(private topic?: { maxMsgSeq?: () => number }) {}
|
||||
|
||||
withData(_since?: number, _before?: number, limit?: number) {
|
||||
this.kind = 'initial';
|
||||
this.limit = limit;
|
||||
return this;
|
||||
}
|
||||
|
||||
withLaterData(limit?: number) {
|
||||
this.kind = 'later';
|
||||
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
|
||||
this.limit = limit;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { kind: this.kind, since: this.since, limit: this.limit };
|
||||
}
|
||||
}
|
||||
|
||||
class MockTinode {
|
||||
onConnect?: () => Promise<void> | void;
|
||||
onDisconnect?: () => void;
|
||||
onAutoreconnectIteration?: (timeoutMs: number) => void;
|
||||
_connection = { sendText: vi.fn() };
|
||||
connect = vi.fn(async () => undefined);
|
||||
loginToken = vi.fn(async () => ({ code: 200 }));
|
||||
disconnect = vi.fn();
|
||||
authorizeURL = vi.fn((value: string) => value);
|
||||
topic = {
|
||||
onData: undefined as (() => void) | undefined,
|
||||
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
|
||||
maxSeq: 0,
|
||||
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
|
||||
startMetaQuery: () => new QueryBuilder(this.topic),
|
||||
subscribe: vi.fn(async () => {
|
||||
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
|
||||
return { code: 200, params: { what: 'data', count: 0 } };
|
||||
}),
|
||||
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
|
||||
leave: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
createMessage: vi.fn(),
|
||||
publishDraft: vi.fn(),
|
||||
};
|
||||
|
||||
constructor(public options: Record<string, unknown>) {
|
||||
mocks.instances.push(this);
|
||||
}
|
||||
|
||||
getTopic() {
|
||||
return this.topic;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Tinode: MockTinode,
|
||||
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
CustomerServiceChat,
|
||||
resolveCustomerServiceEndpoints,
|
||||
type CustomerServiceSession,
|
||||
} from './im';
|
||||
|
||||
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
|
||||
tenant_code: 'default',
|
||||
api_base_url: '/v1/im',
|
||||
file_url: '/v0/file',
|
||||
websocket_url: '/v0/channels',
|
||||
api_key: 'public-key',
|
||||
token: 'token-1',
|
||||
current_user_id: 'usr_member',
|
||||
topic: 'p2p-topic',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('customer service IM endpoints', () => {
|
||||
it('resolves API, file and websocket paths against the current page origin', () => {
|
||||
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
|
||||
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
|
||||
fileURL: 'https://www.yuexuan2.shop/v0/file',
|
||||
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not follow legacy absolute IM hosts from a cached session response', () => {
|
||||
expect(
|
||||
resolveCustomerServiceEndpoints(
|
||||
session({
|
||||
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
|
||||
file_url: 'https://im-api.yuexuan.shop/v0/file',
|
||||
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
|
||||
}),
|
||||
'https://www.yuexuan3.shop',
|
||||
),
|
||||
).toEqual({
|
||||
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
|
||||
fileURL: 'https://www.yuexuan3.shop/v0/file',
|
||||
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer service reconnect', () => {
|
||||
beforeEach(() => {
|
||||
mocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it('reauthenticates and resubscribes with later data after reconnect', async () => {
|
||||
const states: string[] = [];
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
||||
origin: 'https://www.yuexuan1.shop',
|
||||
onConnectionState: (state) => states.push(state),
|
||||
});
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
tinode.onDisconnect?.();
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
|
||||
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
|
||||
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
|
||||
expect(states.at(-1)).toBe('online');
|
||||
});
|
||||
|
||||
it('does not restore a deliberately disconnected chat', async () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
chat.disconnect();
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
|
||||
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports offline without leaking a rejected reconnect callback', async () => {
|
||||
const states: string[] = [];
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
||||
origin: 'https://www.yuexuan3.shop',
|
||||
onConnectionState: (state) => states.push(state),
|
||||
});
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
|
||||
tinode.onDisconnect?.();
|
||||
|
||||
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
|
||||
expect(states.at(-1)).toBe('offline');
|
||||
});
|
||||
|
||||
it('uses rotated login tokens for reconnect and media upload', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
tinode.loginToken
|
||||
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
|
||||
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
|
||||
|
||||
await chat.connect('token-1');
|
||||
await tinode.onConnect?.();
|
||||
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
||||
|
||||
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('paginates all messages accumulated while disconnected', async () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
await chat.connect('token-1');
|
||||
|
||||
tinode.topic.subscribe.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 50;
|
||||
tinode.topic.onAllMessagesReceived?.(50);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 50 } };
|
||||
});
|
||||
tinode.topic.getMeta
|
||||
.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 100;
|
||||
tinode.topic.onAllMessagesReceived?.(50);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 50 } };
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 120;
|
||||
tinode.topic.onAllMessagesReceived?.(20);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 20 } };
|
||||
});
|
||||
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
|
||||
{ kind: 'later', limit: 50, since: 51 },
|
||||
{ kind: 'later', limit: 50, since: 101 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer service media upload', () => {
|
||||
beforeEach(() => {
|
||||
mocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it('keeps credentials in headers and out of the upload URL', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
|
||||
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
|
||||
expect(String(url)).not.toContain('secret=');
|
||||
expect(init?.headers).toEqual({
|
||||
'X-MeetingIM-APIKey': 'public-key',
|
||||
Authorization: 'Token token-1',
|
||||
});
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('rewrites historical absolute IM file references to the current frontend origin', () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
|
||||
|
||||
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
|
||||
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
|
||||
);
|
||||
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
|
||||
'https://cdn.example.com/public/image.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
+164
-20
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1493,6 +1493,48 @@ body {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-chat-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
color: #8a5a00;
|
||||
background: #fff3cd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-chat-connection.is-offline {
|
||||
color: #9f2d2d;
|
||||
background: #fde7e7;
|
||||
}
|
||||
|
||||
.m-chat-connection button,
|
||||
.m-chat-media-error button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-chat-media-error {
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #8b3a3a;
|
||||
background: #f8eaea;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-empty {
|
||||
min-height: 530px;
|
||||
padding: 100px 30px 20px;
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="!chatLoading && connectionState !== 'online'" :class="['m-chat-connection', `is-${connectionState}`]">
|
||||
<span>{{ connectionStatusText }}</span>
|
||||
<button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
@@ -59,8 +63,11 @@
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||||
</div>
|
||||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
@@ -90,8 +97,11 @@
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||||
</div>
|
||||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
@@ -103,12 +113,12 @@
|
||||
</div>
|
||||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||||
<form class="m-chat-input" @submit.prevent="sendMessage">
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<button
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
:disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<img :src="mobileAssets.chatMore" alt="" />
|
||||
@@ -157,7 +167,12 @@
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||
import {
|
||||
CustomerServiceChat,
|
||||
type ChatMessage,
|
||||
type CustomerServiceConnectionState,
|
||||
type CustomerServiceSession,
|
||||
} from '../im';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
@@ -171,6 +186,12 @@
|
||||
);
|
||||
const chatLoading = ref(false);
|
||||
const chatError = ref('');
|
||||
const connectionState = ref<CustomerServiceConnectionState>('offline');
|
||||
const connectionStatusText = computed(() => {
|
||||
if (connectionState.value === 'connecting') return '正在连接客服…';
|
||||
if (connectionState.value === 'reconnecting') return '连接已断开,正在重新连接并同步消息…';
|
||||
return '连接已断开,请重新连接';
|
||||
});
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const historyBoundarySeq = ref(0);
|
||||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||
@@ -179,6 +200,8 @@
|
||||
const sending = ref(false);
|
||||
const mediaSending = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const failedMediaKeys = ref(new Set<string>());
|
||||
const mediaRetries = ref<Record<string, number>>({});
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
const toolsOpen = ref(false);
|
||||
@@ -247,6 +270,7 @@
|
||||
historyBoundarySeq.value = 0;
|
||||
chatLoading.value = false;
|
||||
chatError.value = '';
|
||||
connectionState.value = 'offline';
|
||||
draft.value = '';
|
||||
sending.value = false;
|
||||
mediaSending.value = false;
|
||||
@@ -267,11 +291,19 @@
|
||||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||
});
|
||||
if (version !== connectionVersion) return;
|
||||
const nextChat = new CustomerServiceChat(session, (rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
});
|
||||
const nextChat = new CustomerServiceChat(
|
||||
session,
|
||||
(rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
},
|
||||
{
|
||||
onConnectionState: (state) => {
|
||||
if (version === connectionVersion) connectionState.value = state;
|
||||
},
|
||||
},
|
||||
);
|
||||
chat = nextChat;
|
||||
await nextChat.connect(session.token);
|
||||
if (version !== connectionVersion) {
|
||||
@@ -294,7 +326,7 @@
|
||||
}
|
||||
}
|
||||
async function sendMessage() {
|
||||
if (!chat || sending.value || !draft.value.trim()) return;
|
||||
if (!chat || connectionState.value !== 'online' || sending.value || !draft.value.trim()) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const value = draft.value;
|
||||
@@ -318,7 +350,7 @@
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
if (!file || !chat || connectionState.value !== 'online' || mediaSending.value) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
@@ -360,6 +392,26 @@
|
||||
draft.value += emoji;
|
||||
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||||
}
|
||||
function mediaIdentity(message: ChatMessage) {
|
||||
return `${message.from}:${message.seq}:${message.media_url || ''}`;
|
||||
}
|
||||
function mediaFailed(message: ChatMessage) {
|
||||
return failedMediaKeys.value.has(mediaIdentity(message));
|
||||
}
|
||||
function mediaRenderKey(message: ChatMessage) {
|
||||
const key = mediaIdentity(message);
|
||||
return `${key}:${mediaRetries.value[key] || 0}`;
|
||||
}
|
||||
function markMediaFailed(message: ChatMessage) {
|
||||
failedMediaKeys.value = new Set([...failedMediaKeys.value, mediaIdentity(message)]);
|
||||
}
|
||||
function retryMedia(message: ChatMessage) {
|
||||
const key = mediaIdentity(message);
|
||||
const nextFailures = new Set(failedMediaKeys.value);
|
||||
nextFailures.delete(key);
|
||||
failedMediaKeys.value = nextFailures;
|
||||
mediaRetries.value = { ...mediaRetries.value, [key]: (mediaRetries.value[key] || 0) + 1 };
|
||||
}
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||
|
||||
Reference in New Issue
Block a user