baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
116
frontend/src/lib/api.ts
Normal file
116
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* API 客户端 — 统一请求封装
|
||||
* 含 JWT 注入 + 401 自动 token 刷新(扒 banana 范式,全新重建)
|
||||
* 按契约§0 统一响应包络处理
|
||||
*/
|
||||
import { ApiError, ApiResponse } from '@/types/index';
|
||||
import { ERROR_CODES } from '@/types/errors';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
|
||||
// token 刷新锁,防并发重复刷新
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('clover_token');
|
||||
}
|
||||
|
||||
function setToken(token: string): void {
|
||||
localStorage.setItem('clover_token', token);
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
localStorage.removeItem('clover_token');
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
// 走 /auth/refresh(二期实现),一期降级跳登录
|
||||
clearToken();
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
isFormData = false,
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = isFormData
|
||||
? {} // FormData: 不设 Content-Type,让浏览器填 multipart boundary
|
||||
: { 'Content-Type': 'application/json' };
|
||||
Object.assign(headers, options.headers as Record<string, string>);
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
const body: ApiResponse<T> | ApiError = await res.json();
|
||||
|
||||
if ('code' in body && body.code === ERROR_CODES.SUCCESS) {
|
||||
return (body as ApiResponse<T>).data;
|
||||
}
|
||||
|
||||
const err = body as ApiError;
|
||||
if (err.code === ERROR_CODES.UNAUTHENTICATED) {
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
setToken(newToken);
|
||||
return request<T>(path, options, isFormData);
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ─── 公开方法 ────────────────────────────────────────────────
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
postForm: <T>(path: string, form: FormData) =>
|
||||
request<T>(path, { method: 'POST', body: form }, true),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* getBlob — 带 Authorization header 下载二进制文件
|
||||
* 用于 C1 修复:download-file 接口需要鉴权,window.open 无法附带 token
|
||||
*/
|
||||
getBlob: async (path: string): Promise<Blob> => {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`${BASE_URL}${path}`, { method: 'GET', headers });
|
||||
if (res.status === 401) {
|
||||
await refreshToken();
|
||||
throw { code: 40101, message: '认证失败,请重新登录' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw { code: 50001, message: `下载失败(${res.status})` };
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
setToken,
|
||||
getToken,
|
||||
clearToken,
|
||||
};
|
||||
109
frontend/src/lib/sse.ts
Normal file
109
frontend/src/lib/sse.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// SSE 客户端 — 按契约§2 + 安全红线(不传 JWT 进 URL,先换 ticket 再建 EventSource)
|
||||
// 特性:ticket 认证 + event_seq 去重 + 最多5次指数退避重连
|
||||
// 注意:BE 用 named events(event: text_candidate 等),必须用 addEventListener 而非 onmessage
|
||||
import { SseEvent } from '@/types/sse';
|
||||
import { api } from './api';
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
// 与 backend/app/utils/sse_utils.py SSE_EVENT_TYPES 保持同步
|
||||
const SSE_EVENT_TYPES = [
|
||||
'task_started', 'analyze_done', 'text_progress', 'text_candidate',
|
||||
'image_progress', 'image_candidate', 'flywheel_injected',
|
||||
'batch_failed', 'task_done', 'error', 'heartbeat',
|
||||
] as const;
|
||||
|
||||
export type SseConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'closed';
|
||||
|
||||
export interface SseClientOptions {
|
||||
onEvent: (event: SseEvent) => void;
|
||||
onStatusChange: (status: SseConnectionStatus, attempt?: number) => void;
|
||||
}
|
||||
|
||||
export class SseClient {
|
||||
private taskId: number;
|
||||
private options: SseClientOptions;
|
||||
private es: EventSource | null = null;
|
||||
private seenSeqs = new Set<number>();
|
||||
private retryCount = 0;
|
||||
private closed = false;
|
||||
|
||||
constructor(taskId: number, options: SseClientOptions) {
|
||||
this.taskId = taskId;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(lastSeq = 0): void {
|
||||
if (this.closed) return;
|
||||
this.options.onStatusChange(this.retryCount === 0 ? 'connecting' : 'reconnecting', this.retryCount);
|
||||
this._fetchTicketAndOpen(lastSeq);
|
||||
}
|
||||
|
||||
private async _fetchTicketAndOpen(lastSeq: number): Promise<void> {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
const { ticket } = await api.post<{ ticket: string; expires_in: number }>(
|
||||
`/api/v1/tasks/${this.taskId}/sse-ticket`
|
||||
);
|
||||
if (!this.closed) this._openEventSource(ticket, lastSeq);
|
||||
} catch {
|
||||
if (!this.closed) this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatch(e: MessageEvent): void {
|
||||
// BE格式:event:<type>\ndata:<json>\nid:<seq>;e.type=named event type
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
const seq = e.lastEventId ? Number(e.lastEventId) : undefined;
|
||||
if (seq !== undefined && !isNaN(seq)) {
|
||||
if (this.seenSeqs.has(seq)) return;
|
||||
this.seenSeqs.add(seq);
|
||||
}
|
||||
this.retryCount = 0;
|
||||
// 将 named event type 合入 data,构造 SseEvent
|
||||
const sseEvent = { type: e.type, event_seq: seq ?? 0, data } as SseEvent;
|
||||
this.options.onEvent(sseEvent);
|
||||
} catch { /* 忽略解析错误 */ }
|
||||
}
|
||||
|
||||
private _openEventSource(ticket: string, lastSeq: number): void {
|
||||
// SSE 必须直连后端,绕过 Next dev rewrite 代理——后者会缓冲/吞掉 EventSource 的
|
||||
// 流式事件(实测:经代理 EventSource 只触发 open,零事件;直连后端事件正常)。
|
||||
// NEXT_PUBLIC_SSE_BASE_URL 缺省时退回 NEXT_PUBLIC_API_BASE_URL,再退回相对路径。
|
||||
const sseBase = (
|
||||
process.env.NEXT_PUBLIC_SSE_BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
''
|
||||
).replace(/\/$/, '');
|
||||
const url = `${sseBase}/api/v1/tasks/${this.taskId}/stream?ticket=${ticket}&last_seq=${lastSeq}`;
|
||||
this.es = new EventSource(url);
|
||||
this.es.onopen = () => this.options.onStatusChange('connected');
|
||||
// 监听所有 named event types(onmessage 收不到 named events)
|
||||
for (const evType of SSE_EVENT_TYPES) {
|
||||
this.es.addEventListener(evType, (e) => this._dispatch(e as MessageEvent));
|
||||
}
|
||||
this.es.onerror = () => {
|
||||
this.es?.close(); this.es = null;
|
||||
if (!this.closed) this.scheduleRetry();
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryCount >= MAX_RETRIES) { this.options.onStatusChange('failed'); return; }
|
||||
const delay = BASE_DELAY_MS * Math.pow(2, this.retryCount);
|
||||
const lastSeq = this.seenSeqs.size > 0 ? Math.max(...this.seenSeqs) : 0;
|
||||
this.retryCount++;
|
||||
setTimeout(() => this.connect(lastSeq), delay);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true; this.es?.close(); this.es = null;
|
||||
this.options.onStatusChange('closed');
|
||||
}
|
||||
}
|
||||
|
||||
export function createSseClient(taskId: number, options: SseClientOptions): SseClient {
|
||||
const client = new SseClient(taskId, options);
|
||||
client.connect();
|
||||
return client;
|
||||
}
|
||||
Reference in New Issue
Block a user