feat: 支持工作流对话匿名分享

- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
2026-08-31 16:45:21 +08:00
parent fa174f6c16
commit 1bd9810518
46 changed files with 3742 additions and 180 deletions

View File

@@ -21,12 +21,11 @@ import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store';
import {
isWorkflowShareRequest,
readWorkflowShareKey,
withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER,
withWorkflowShareHeaders,
} from '#/utils/workflow-share-context';
import { refreshTokenApi } from './core';
import { isInactiveSseRequest } from './sseRequestLifecycle';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
@@ -101,12 +100,15 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey();
if (
workflowShareKey &&
isWorkflowShareRequest(config.url, config.method)
) {
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
const workflowShareHeaders = withWorkflowShareHeaders(
{},
{
requestMethod: config.method,
requestUrl: config.url,
},
);
for (const [name, value] of Object.entries(workflowShareHeaders)) {
config.headers[name] = value;
}
return config;
},
@@ -132,6 +134,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken ?? false,
formatToken,
shouldHandleUnauthorized: (config) =>
!isWorkflowShareRequest(config?.url, config?.method),
}),
);
@@ -186,7 +190,7 @@ export function createEventStreamHeaders(
headers[key] = value;
});
}
return withWorkflowShareHeader(headers, {
return withWorkflowShareHeaders(headers, {
requestMethod: 'POST',
requestUrl,
});
@@ -274,15 +278,25 @@ export class SseClient {
options?.onMessage?.(event);
}
} catch (innerError) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
options?.onError?.(innerError);
return;
}
// 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) {
if (
!isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
options?.onFinished?.();
}
} catch (error) {
if (this.currentRequestId !== currentRequestId) {
if (
isInactiveSseRequest(signal, this.currentRequestId, currentRequestId)
) {
return;
}
console.error('SSE错误:', error);

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isInactiveSseRequest } from './sseRequestLifecycle';
describe('sseRequestLifecycle', () => {
it('treats an explicit abort as an inactive request', () => {
const controller = new AbortController();
controller.abort();
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(true);
});
it('treats a superseded request as inactive', () => {
const controller = new AbortController();
expect(isInactiveSseRequest(controller.signal, 2, 1)).toBe(true);
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/**
* 判断 SSE 请求是否已被主动中止或被后续请求替换。
*/
export function isInactiveSseRequest(
signal: AbortSignal,
currentRequestId: number,
requestId: number,
) {
return signal.aborted || currentRequestId !== requestId;
}