修复泥点消耗刷新不及时 (#138)
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-08-08 10:25:02 +08:00
committed by 段舒康
parent 44de26d204
commit fc0dcb0782
36 changed files with 4160 additions and 491 deletions
+178 -9
View File
@@ -662,6 +662,168 @@ describe('apiClient', () => {
);
});
it('does not retry an unsafe write with a new token after its account signal aborts', async () => {
setStoredAccessToken('account-a-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 503 }))
.mockResolvedValueOnce(createResponseMock({ status: 200 }));
const abortController = new AbortController();
const request = requestJson(
'/api/profile/redeem-codes/redeem',
{
method: 'POST',
body: JSON.stringify({ code: 'REWARD01' }),
signal: abortController.signal,
},
'兑换失败',
{
retry: {
maxRetries: 1,
baseDelayMs: 1000,
maxDelayMs: 1000,
retryUnsafeMethods: true,
},
},
);
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
setStoredAccessToken('account-b-token', { emit: false });
abortController.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(
(fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>)
.Authorization,
).toBe('Bearer account-a-token');
});
it('does not replay a 401 unsafe write after its account signal aborts during refresh', async () => {
setStoredAccessToken('account-a-token', { emit: false });
const refreshResponse =
createDeferred<ReturnType<typeof createResponseMock>>();
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockImplementationOnce(() => refreshResponse.promise)
.mockResolvedValueOnce(createResponseMock({ status: 200 }));
const abortController = new AbortController();
const request = requestJson(
'/api/profile/redeem-codes/redeem',
{
method: 'POST',
body: JSON.stringify({ code: 'REWARD01' }),
signal: abortController.signal,
},
'兑换失败',
);
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
const accountARefresh = refreshStoredAccessToken({
clearOnFailure: false,
});
const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject(
{
name: 'AuthStateChangedDuringRefreshError',
},
);
setStoredAccessToken('account-b-token', { emit: false });
abortController.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
refreshResponse.resolve(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { token: 'late-refresh-token' },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
}),
);
await accountARefreshResult;
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getStoredAccessToken()).toBe('account-b-token');
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/api/profile/redeem-codes/redeem',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh');
});
it('starts a separate refresh for a new auth generation', async () => {
setStoredAccessToken('account-a-token', { emit: false });
const accountAResponse =
createDeferred<ReturnType<typeof createResponseMock>>();
const accountBResponse =
createDeferred<ReturnType<typeof createResponseMock>>();
fetchMock
.mockImplementationOnce(() => accountAResponse.promise)
.mockImplementationOnce(() => accountBResponse.promise);
const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
setStoredAccessToken('account-b-token', { emit: false });
const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
accountAResponse.resolve(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { token: 'late-account-a-token' },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
}),
);
await expect(accountARefresh).rejects.toMatchObject({
name: 'AuthStateChangedDuringRefreshError',
});
expect(getStoredAccessToken()).toBe('account-b-token');
accountBResponse.resolve(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { token: 'fresh-account-b-token' },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
}),
);
await expect(accountBRefresh).resolves.toBe('fresh-account-b-token');
expect(getStoredAccessToken()).toBe('fresh-account-b-token');
});
it('does not clear a new account token when an old refresh fails late', async () => {
setStoredAccessToken('account-a-token', { emit: false });
const accountAResponse =
createDeferred<ReturnType<typeof createResponseMock>>();
fetchMock.mockImplementationOnce(() => accountAResponse.promise);
const accountARefresh = refreshStoredAccessToken();
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
setStoredAccessToken('account-b-token', { emit: false });
accountAResponse.resolve(createResponseMock({ status: 401 }));
await expect(accountARefresh).rejects.toMatchObject({ status: 401 });
expect(getStoredAccessToken()).toBe('account-b-token');
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
@@ -700,9 +862,8 @@ describe('apiClient', () => {
it.each(['missing-token', 'unauthorized'] as const)(
'bounds the %s refresh wait with an absolute request deadline',
async (refreshMode) => {
const refreshResponse = createDeferred<
ReturnType<typeof createResponseMock>
>();
const refreshResponse =
createDeferred<ReturnType<typeof createResponseMock>>();
if (refreshMode === 'unauthorized') {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
@@ -853,9 +1014,13 @@ describe('apiClient', () => {
);
await expect(
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
method: 'POST',
}, '执行拼图操作失败。'),
requestJson(
'/api/runtime/puzzle/agent/sessions/test/actions',
{
method: 'POST',
},
'执行拼图操作失败。',
),
).rejects.toMatchObject({
message: '拼图图片生成失败:请求参数不合法',
status: 400,
@@ -895,9 +1060,13 @@ describe('apiClient', () => {
);
await expect(
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
method: 'POST',
}, '执行拼图操作失败。'),
requestJson(
'/api/runtime/puzzle/agent/sessions/test/actions',
{
method: 'POST',
},
'执行拼图操作失败。',
),
).rejects.toMatchObject({
message:
'无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置',