修复资产计费边界风险

资产生成预扣费改为 fail-closed,避免钱包异常时继续调用外部生成

新增钱包退款 outbox,退款失败时本地落盘并后台重放

拼图首图后台任务改用 SpacetimeDB claim 表实现跨实例互斥

计费 ledger id 统一绑定 request_id,并让前端重试复用 x-request-id

同步 SpacetimeDB bindings、后端架构文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 15:55:23 +08:00
parent 86ea69f79d
commit f8a80cd795
34 changed files with 1678 additions and 264 deletions

View File

@@ -66,6 +66,9 @@ describe('apiClient', () => {
dispatchEvent: dispatchEventMock,
localStorage: createLocalStorageMock(),
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
fetchMock.mockReset();
dispatchEventMock.mockReset();
clearStoredAccessToken({ emit: false });
@@ -121,6 +124,7 @@ describe('apiClient', () => {
credentials: 'same-origin',
headers: expect.objectContaining({
Authorization: 'Bearer expired-token',
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
'x-genarrative-response-envelope': 'v1',
}),
}),
@@ -140,6 +144,7 @@ describe('apiClient', () => {
credentials: 'same-origin',
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
}),
}),
);

View File

@@ -40,6 +40,8 @@ export type ApiRequestOptions = {
notifyAuthStateChange?: boolean;
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
clearAuthOnUnauthorized?: boolean;
// 同一次业务请求在客户端重试时复用 request id后端据此做计费幂等。
requestId?: string;
};
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
@@ -99,6 +101,22 @@ function normalizeHeaders(headers?: HeadersInit) {
return nextHeaders;
}
function buildClientRequestId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `web-${randomId}`;
}
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
const explicitRequestId = options.requestId?.trim();
const existingRequestId = Object.entries(headers).find(
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
)?.[1];
return explicitRequestId || existingRequestId || buildClientRequestId();
}
function coerceMeta(value: unknown): Partial<ApiMeta> {
if (!isRecord(value)) {
return {};
@@ -582,12 +600,14 @@ export async function fetchWithApiAuth(
const retry = resolveRetryOptions(method, options.retry);
const authFailurePolicy = resolveAuthFailurePolicy(options);
const requestSignal = init.signal ?? undefined;
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
let requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
@@ -603,6 +623,7 @@ export async function fetchWithApiAuth(
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await ensureStoredAccessToken();
requestHeaders = withAuthorizationHeaders(init.headers, options);
requestHeaders[REQUEST_ID_HEADER] = requestId;
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),