修复资产计费边界风险
资产生成预扣费改为 fail-closed,避免钱包异常时继续调用外部生成 新增钱包退款 outbox,退款失败时本地落盘并后台重放 拼图首图后台任务改用 SpacetimeDB claim 表实现跨实例互斥 计费 ledger id 统一绑定 request_id,并让前端重试复用 x-request-id 同步 SpacetimeDB bindings、后端架构文档和 Hermes 决策记录
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user