Files
ftb-project-management/apps/web/lib/server-data.ts
2026-07-03 19:36:18 +08:00

162 lines
4.5 KiB
TypeScript

import { api, ApiRequestError } from './api';
export type ServerDataKey =
| 'products-overview'
| 'requirements'
| 'version-plans'
| 'dev-tasks'
| 'test-cases'
| 'bugs'
| 'members'
| 'task-categories'
| 'task-worklogs'
| 'work-activities'
| 'xiaobao-risk-insights'
| 'xiaobao-risk-snapshots'
| 'xiaobao-warning-views'
| 'overtime';
interface ServerDataResponse<T> {
key: ServerDataKey;
value: T | null;
version: string | null;
}
type ServerDataConflictBody<T> = {
code: 'APP_DATA_CONFLICT';
key: ServerDataKey;
currentValue: T | null;
currentVersion: string | null;
};
const serverDataVersions = new Map<ServerDataKey, string | null>();
const serverDataCache = new Map<ServerDataKey, {
value: unknown | null;
version: string | null;
loadedAt: number;
}>();
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
export const SERVER_DATA_CACHE_MS = 30_000;
export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';
export type ServerDataSaveErrorDetail = {
key: ServerDataKey;
message: string;
};
type LoadServerDataOptions = {
force?: boolean;
maxAgeMs?: number;
};
export class ServerDataConflictError<T = unknown> extends Error {
constructor(
public readonly key: ServerDataKey,
public readonly currentValue: T | null,
public readonly currentVersion: string | null,
) {
super('Server data was changed by another user. Reload before saving again.');
this.name = 'ServerDataConflictError';
}
}
function getServerDataSaveErrorMessage(error: unknown) {
if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试';
if (error instanceof Error && error.message) return error.message;
return '保存失败,请检查网络或 API 服务';
}
function notifyServerDataSaveFailure(key: ServerDataKey, error: unknown) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, {
detail: {
key,
message: getServerDataSaveErrorMessage(error),
},
}),
);
}
export async function loadServerData<T>(
key: ServerDataKey,
options: LoadServerDataOptions = {},
): Promise<T | null> {
const maxAgeMs = options.maxAgeMs ?? SERVER_DATA_CACHE_MS;
const cached = serverDataCache.get(key);
if (!options.force && cached && maxAgeMs > 0 && Date.now() - cached.loadedAt <= maxAgeMs) {
return cached.value as T | null;
}
const inFlight = serverDataLoadPromises.get(key);
if (inFlight) return inFlight as Promise<T | null>;
const promise = api.get<ServerDataResponse<T>>(`/data/${key}`)
.then((res) => {
const version = res.version ?? null;
serverDataVersions.set(key, version);
serverDataCache.set(key, {
value: res.value,
version,
loadedAt: Date.now(),
});
return res.value;
})
.finally(() => {
serverDataLoadPromises.delete(key);
});
serverDataLoadPromises.set(key, promise as Promise<unknown | null>);
return promise;
}
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
const payload: { value: T; version?: string | null } = { value };
if (serverDataVersions.has(key)) {
payload.version = serverDataVersions.get(key) ?? null;
}
try {
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
const version = res.version ?? null;
serverDataVersions.set(key, version);
serverDataCache.set(key, {
value,
version,
loadedAt: Date.now(),
});
} catch (error) {
if (
error instanceof ApiRequestError &&
error.status === 409 &&
isServerDataConflictBody<T>(error.body, key)
) {
const conflict = new ServerDataConflictError(
error.body.key,
error.body.currentValue,
error.body.currentVersion,
);
notifyServerDataSaveFailure(key, conflict);
throw conflict;
}
notifyServerDataSaveFailure(key, error);
throw error;
}
}
function isServerDataConflictBody<T>(
body: unknown,
key: ServerDataKey,
): body is ServerDataConflictBody<T> {
return (
typeof body === 'object' &&
body !== null &&
(body as { code?: unknown }).code === 'APP_DATA_CONFLICT' &&
(body as { key?: unknown }).key === key &&
('currentVersion' in body
? typeof (body as { currentVersion?: unknown }).currentVersion === 'string' ||
(body as { currentVersion?: unknown }).currentVersion === null
: true)
);
}