fix recommend runtime auth isolation
This commit is contained in:
@@ -134,6 +134,14 @@
|
|||||||
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
|
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
|
||||||
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。
|
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。
|
||||||
|
|
||||||
|
## 登录后推荐页加载出作品又回到未登录
|
||||||
|
|
||||||
|
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
|
||||||
|
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。
|
||||||
|
- 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。
|
||||||
|
- 验证:`npm run test -- src/services/apiClient.test.ts` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`。
|
||||||
|
- 关联:`src/services/apiClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
||||||
|
|
||||||
## Rust 冷编译导致 api-server 健康检查误超时
|
## Rust 冷编译导致 api-server 健康检查误超时
|
||||||
|
|
||||||
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。
|
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
||||||
|
- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品后局部运行态请求 `401` 不应扩散成全局登出的修复,覆盖请求层局部鉴权失败隔离、推荐页 embedded 运行态启动和回归测试。
|
||||||
- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
||||||
- [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。
|
- [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。
|
||||||
- [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。
|
- [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 推荐页运行态鉴权失败隔离修复
|
||||||
|
|
||||||
|
日期:`2026-05-09`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
登录成功进入平台推荐页后,推荐页会自动加载一个公开作品并启动嵌入式运行态。实际联调中出现过:作品刚加载出来,前端又瞬间回到未登录状态;停留在其他页面,或推荐页没有成功加载出作品时不会复现。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
推荐页首屏的作品运行态启动是后台自动副作用,不是用户主动点击的账号操作。它会触发多条受保护请求,例如:
|
||||||
|
|
||||||
|
1. 拼图、抓大鹅、方洞挑战、视觉小说的 `start run`。
|
||||||
|
2. 大鱼吃小鱼的 `start run` 与游玩记录上报。
|
||||||
|
3. 视觉小说运行前的作品详情读取。
|
||||||
|
|
||||||
|
这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
本次把推荐页自动运行态请求定义为“卡片级后台请求”:
|
||||||
|
|
||||||
|
1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。
|
||||||
|
2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。
|
||||||
|
3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。
|
||||||
|
4. 普通用户主动点击“启动”、Remix、发布、点赞等路径继续保留默认全局鉴权处理。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `npm run test -- src/services/apiClient.test.ts`
|
||||||
|
2. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`
|
||||||
|
3. `npm run typecheck`
|
||||||
|
4. `npm run check:encoding`
|
||||||
|
|
||||||
|
## 关联文件
|
||||||
|
|
||||||
|
1. `src/services/apiClient.ts`
|
||||||
|
2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||||
|
3. `src/services/*-runtime/*RuntimeClient.ts`
|
||||||
|
4. `src/services/visual-novel-works/visualNovelWorksClient.ts`
|
||||||
@@ -382,6 +382,11 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
|||||||
'publish_missing_main_chapter',
|
'publish_missing_main_chapter',
|
||||||
'publish_missing_first_act',
|
'publish_missing_first_act',
|
||||||
]);
|
]);
|
||||||
|
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = {
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
};
|
||||||
|
|
||||||
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||||
@@ -3495,13 +3500,23 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
? visualNovelWork
|
? visualNovelWork
|
||||||
: null;
|
: null;
|
||||||
if (!workDetail) {
|
if (!workDetail) {
|
||||||
const response = await getVisualNovelWorkDetail(targetProfileId);
|
const response = await getVisualNovelWorkDetail(
|
||||||
|
targetProfileId,
|
||||||
|
options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {},
|
||||||
|
);
|
||||||
workDetail = response.work;
|
workDetail = response.work;
|
||||||
}
|
}
|
||||||
const { run } = await startVisualNovelRun(targetProfileId, {
|
const startRunPayload = {
|
||||||
profileId: targetProfileId,
|
profileId: targetProfileId,
|
||||||
mode: 'play',
|
mode: 'play' as const,
|
||||||
});
|
};
|
||||||
|
const { run } = options.embedded
|
||||||
|
? await startVisualNovelRun(
|
||||||
|
targetProfileId,
|
||||||
|
startRunPayload,
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startVisualNovelRun(targetProfileId, startRunPayload);
|
||||||
setVisualNovelWork(workDetail);
|
setVisualNovelWork(workDetail);
|
||||||
setVisualNovelRun(run);
|
setVisualNovelRun(run);
|
||||||
setVisualNovelRuntimeReturnStage(returnStage);
|
setVisualNovelRuntimeReturnStage(returnStage);
|
||||||
@@ -4004,10 +4019,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
try {
|
try {
|
||||||
const item =
|
const item =
|
||||||
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
|
||||||
const { run } = await startPuzzleRun({
|
const startRunPayload = {
|
||||||
profileId: item.profileId,
|
profileId: item.profileId,
|
||||||
levelId: levelId ?? null,
|
levelId: levelId ?? null,
|
||||||
});
|
};
|
||||||
|
const { run } = options.embedded
|
||||||
|
? await startPuzzleRun(
|
||||||
|
startRunPayload,
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startPuzzleRun(startRunPayload);
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleRun(run);
|
setPuzzleRun(run);
|
||||||
setPuzzleRuntimeReturnStage(returnStage);
|
setPuzzleRuntimeReturnStage(returnStage);
|
||||||
@@ -4057,7 +4078,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setMatch3DError(null);
|
setMatch3DError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { run } = await startMatch3DRun(profile.profileId);
|
const { run } = options.embedded
|
||||||
|
? await startMatch3DRun(
|
||||||
|
profile.profileId,
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startMatch3DRun(profile.profileId);
|
||||||
setMatch3DRun(run);
|
setMatch3DRun(run);
|
||||||
setMatch3DRuntimeReturnStage(returnStage);
|
setMatch3DRuntimeReturnStage(returnStage);
|
||||||
if (!options.embedded) {
|
if (!options.embedded) {
|
||||||
@@ -4110,7 +4136,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSquareHoleError(null);
|
setSquareHoleError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { run } = await startSquareHoleRun(profile.profileId);
|
const { run } = options.embedded
|
||||||
|
? await startSquareHoleRun(
|
||||||
|
profile.profileId,
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startSquareHoleRun(profile.profileId);
|
||||||
setSquareHoleRun(run);
|
setSquareHoleRun(run);
|
||||||
setSquareHoleRuntimeReturnStage(returnStage);
|
setSquareHoleRuntimeReturnStage(returnStage);
|
||||||
if (!options.embedded) {
|
if (!options.embedded) {
|
||||||
@@ -4279,12 +4310,21 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
|
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
|
||||||
setBigFishRuntimeStartedAt(null);
|
setBigFishRuntimeStartedAt(null);
|
||||||
void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => {
|
const reportPromise =
|
||||||
|
activeRecommendRuntimeKind === 'big-fish'
|
||||||
|
? recordBigFishPlay(
|
||||||
|
sessionId,
|
||||||
|
{ elapsedMs },
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: recordBigFishPlay(sessionId, { elapsedMs });
|
||||||
|
void reportPromise.catch((error) => {
|
||||||
setBigFishError(
|
setBigFishError(
|
||||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
|
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
activeRecommendRuntimeKind,
|
||||||
bigFishRun?.sessionId,
|
bigFishRun?.sessionId,
|
||||||
bigFishRuntimeStartedAt,
|
bigFishRuntimeStartedAt,
|
||||||
resolveBigFishErrorMessage,
|
resolveBigFishErrorMessage,
|
||||||
@@ -5844,7 +5884,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setBigFishRuntimeReturnStage(returnStage);
|
setBigFishRuntimeReturnStage(returnStage);
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
try {
|
try {
|
||||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
const { run } = options.embedded
|
||||||
|
? await startBigFishRuntimeRun(
|
||||||
|
sessionId,
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startBigFishRuntimeRun(sessionId);
|
||||||
setBigFishRuntimeStartedAt(Date.now());
|
setBigFishRuntimeStartedAt(Date.now());
|
||||||
setBigFishRun(run);
|
setBigFishRun(run);
|
||||||
if (!options.embedded) {
|
if (!options.embedded) {
|
||||||
@@ -5853,7 +5898,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
const recordPlayPromise = options.embedded
|
||||||
|
? recordBigFishPlay(
|
||||||
|
sessionId,
|
||||||
|
{ elapsedMs: 0 },
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: recordBigFishPlay(sessionId, { elapsedMs: 0 });
|
||||||
|
void recordPlayPromise.catch((error) => {
|
||||||
setBigFishError(
|
setBigFishError(
|
||||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||||
);
|
);
|
||||||
@@ -6410,6 +6462,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (
|
if (
|
||||||
selectionStage !== 'platform' ||
|
selectionStage !== 'platform' ||
|
||||||
platformBootstrap.platformTab !== 'home' ||
|
platformBootstrap.platformTab !== 'home' ||
|
||||||
|
!platformBootstrap.canReadProtectedData ||
|
||||||
platformBootstrap.isLoadingPlatform
|
platformBootstrap.isLoadingPlatform
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -6439,6 +6492,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [
|
}, [
|
||||||
activeRecommendEntryKey,
|
activeRecommendEntryKey,
|
||||||
isStartingRecommendEntry,
|
isStartingRecommendEntry,
|
||||||
|
platformBootstrap.canReadProtectedData,
|
||||||
platformBootstrap.isLoadingPlatform,
|
platformBootstrap.isLoadingPlatform,
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
recommendRuntimeEntries,
|
recommendRuntimeEntries,
|
||||||
|
|||||||
@@ -3018,6 +3018,51 @@ test('published puzzle works appear on home and mobile game category channel', a
|
|||||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const publishedPuzzleWork = {
|
||||||
|
workId: 'puzzle-work-public-1',
|
||||||
|
profileId: 'puzzle-profile-public-1',
|
||||||
|
ownerUserId: 'user-2',
|
||||||
|
sourceSessionId: 'puzzle-session-public-1',
|
||||||
|
authorDisplayName: '拼图作者',
|
||||||
|
levelName: '星桥机关',
|
||||||
|
summary: '旋转碎片并接通星桥机关。',
|
||||||
|
themeTags: ['机关', '星桥'],
|
||||||
|
coverImageSrc: null,
|
||||||
|
coverAssetId: null,
|
||||||
|
publicationStatus: 'published',
|
||||||
|
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||||
|
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||||
|
playCount: 3,
|
||||||
|
likeCount: 0,
|
||||||
|
publishReady: true,
|
||||||
|
} satisfies PuzzleWorkSummary;
|
||||||
|
|
||||||
|
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||||
|
items: [publishedPuzzleWork],
|
||||||
|
});
|
||||||
|
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||||
|
item: publishedPuzzleWork,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
profileId: 'puzzle-profile-public-1',
|
||||||
|
levelId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('published big fish works stay hidden from platform home and game category channel', async () => {
|
test('published big fish works stay hidden from platform home and game category channel', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const publishedBigFishWork: BigFishWorkSummary = {
|
const publishedBigFishWork: BigFishWorkSummary = {
|
||||||
|
|||||||
@@ -243,6 +243,28 @@ describe('apiClient', () => {
|
|||||||
expect(getStoredAccessToken()).toBe('');
|
expect(getStoredAccessToken()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps auth state untouched when local background requests opt out of unauthorized clearing', async () => {
|
||||||
|
setStoredAccessToken('still-valid-token', { emit: false });
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||||
|
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||||
|
|
||||||
|
const response = await fetchWithApiAuth(
|
||||||
|
'/api/runtime/puzzle/runs',
|
||||||
|
{ method: 'POST' },
|
||||||
|
{
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||||
|
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||||
setStoredAccessToken('expired-token', { emit: false });
|
setStoredAccessToken('expired-token', { emit: false });
|
||||||
fetchMock
|
fetchMock
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export type ApiRequestOptions = {
|
|||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||||
notifyAuthStateChange?: boolean;
|
notifyAuthStateChange?: boolean;
|
||||||
|
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||||
|
clearAuthOnUnauthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedRetryOptions = {
|
type ResolvedRetryOptions = {
|
||||||
@@ -525,6 +527,8 @@ export async function fetchWithApiAuth(
|
|||||||
const method = (init.method ?? 'GET').toUpperCase();
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
const retry = resolveRetryOptions(method, options.retry);
|
const retry = resolveRetryOptions(method, options.retry);
|
||||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||||
|
const shouldClearAuthOnUnauthorized =
|
||||||
|
options.clearAuthOnUnauthorized !== false;
|
||||||
const requestSignal = init.signal ?? undefined;
|
const requestSignal = init.signal ?? undefined;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
let refreshAttempted = false;
|
let refreshAttempted = false;
|
||||||
@@ -580,7 +584,7 @@ export async function fetchWithApiAuth(
|
|||||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||||
continue;
|
continue;
|
||||||
} catch {
|
} catch {
|
||||||
if (hasAuthHeader) {
|
if (hasAuthHeader && shouldClearAuthOnUnauthorized) {
|
||||||
clearStoredAccessToken({ emit: false });
|
clearStoredAccessToken({ emit: false });
|
||||||
}
|
}
|
||||||
if (shouldNotifyAuthStateChange) {
|
if (shouldNotifyAuthStateChange) {
|
||||||
@@ -593,7 +597,9 @@ export async function fetchWithApiAuth(
|
|||||||
!options.skipAuth &&
|
!options.skipAuth &&
|
||||||
!refreshAttempted
|
!refreshAttempted
|
||||||
) {
|
) {
|
||||||
clearStoredAccessToken({ emit: false });
|
if (shouldClearAuthOnUnauthorized) {
|
||||||
|
clearStoredAccessToken({ emit: false });
|
||||||
|
}
|
||||||
if (shouldNotifyAuthStateChange) {
|
if (shouldNotifyAuthStateChange) {
|
||||||
emitAuthStateChange();
|
emitAuthStateChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type {
|
|||||||
SubmitBigFishInputRequest,
|
SubmitBigFishInputRequest,
|
||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
|
|
||||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
@@ -12,6 +16,10 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
};
|
};
|
||||||
|
type BigFishRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
|
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
|
||||||
@@ -19,6 +27,7 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
export function recordBigFishPlay(
|
export function recordBigFishPlay(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: RecordBigFishPlayRequest,
|
payload: RecordBigFishPlayRequest,
|
||||||
|
options: BigFishRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestJson<BigFishWorksResponse>(
|
return requestJson<BigFishWorksResponse>(
|
||||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||||
@@ -30,11 +39,17 @@ export function recordBigFishPlay(
|
|||||||
'记录大鱼吃小鱼游玩失败',
|
'记录大鱼吃小鱼游玩失败',
|
||||||
{
|
{
|
||||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startBigFishRun(sessionId: string) {
|
export function startBigFishRun(
|
||||||
|
sessionId: string,
|
||||||
|
options: BigFishRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<BigFishRunResponse>(
|
return requestJson<BigFishRunResponse>(
|
||||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||||
{
|
{
|
||||||
@@ -43,6 +58,9 @@ export function startBigFishRun(sessionId: string) {
|
|||||||
'启动大鱼吃小鱼玩法失败',
|
'启动大鱼吃小鱼玩法失败',
|
||||||
{
|
{
|
||||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type {
|
|||||||
Match3DRunResponse,
|
Match3DRunResponse,
|
||||||
StopMatch3DRunRequest,
|
StopMatch3DRunRequest,
|
||||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
|
|
||||||
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
@@ -20,6 +24,10 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
};
|
};
|
||||||
|
type Match3DRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
@@ -58,7 +66,10 @@ function mapClickConfirmation(
|
|||||||
/**
|
/**
|
||||||
* 基于作品启动一局抓大鹅正式 run。
|
* 基于作品启动一局抓大鹅正式 run。
|
||||||
*/
|
*/
|
||||||
export function startMatch3DRun(profileId: string) {
|
export function startMatch3DRun(
|
||||||
|
profileId: string,
|
||||||
|
options: Match3DRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<Match3DRunResponse>(
|
return requestJson<Match3DRunResponse>(
|
||||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||||
{
|
{
|
||||||
@@ -67,7 +78,12 @@ export function startMatch3DRun(profileId: string) {
|
|||||||
body: JSON.stringify({ profileId }),
|
body: JSON.stringify({ profileId }),
|
||||||
},
|
},
|
||||||
'启动抓大鹅玩法失败',
|
'启动抓大鹅玩法失败',
|
||||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
{
|
||||||
|
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import type {
|
|||||||
UpdatePuzzleRuntimePauseRequest,
|
UpdatePuzzleRuntimePauseRequest,
|
||||||
UsePuzzleRuntimePropRequest,
|
UsePuzzleRuntimePropRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
|
|
||||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
@@ -22,11 +26,18 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
};
|
};
|
||||||
|
type PuzzleRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从某个已发布拼图作品开始一次 run。
|
* 从某个已发布拼图作品开始一次 run。
|
||||||
*/
|
*/
|
||||||
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
export async function startPuzzleRun(
|
||||||
|
payload: StartPuzzleRunRequest,
|
||||||
|
options: PuzzleRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<PuzzleRunResponse>(
|
return requestJson<PuzzleRunResponse>(
|
||||||
PUZZLE_RUNTIME_API_BASE,
|
PUZZLE_RUNTIME_API_BASE,
|
||||||
{
|
{
|
||||||
@@ -37,6 +48,9 @@ export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
|||||||
'启动拼图玩法失败',
|
'启动拼图玩法失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type RuntimeRequestOptions = {
|
|||||||
retry?: ApiRetryOptions;
|
retry?: ApiRetryOptions;
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
|
notifyAuthStateChange?: boolean;
|
||||||
|
clearAuthOnUnauthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +52,8 @@ export function requestRpgRuntimeJson<T>(
|
|||||||
retry,
|
retry,
|
||||||
skipAuth: options.skipAuth,
|
skipAuth: options.skipAuth,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type {
|
|||||||
SquareHoleRunResponse,
|
SquareHoleRunResponse,
|
||||||
StopSquareHoleRunRequest,
|
StopSquareHoleRunRequest,
|
||||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
|
|
||||||
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
@@ -17,11 +21,18 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
};
|
};
|
||||||
|
type SquareHoleRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于作品启动一局方洞挑战正式 run。
|
* 基于作品启动一局方洞挑战正式 run。
|
||||||
*/
|
*/
|
||||||
export function startSquareHoleRun(profileId: string) {
|
export function startSquareHoleRun(
|
||||||
|
profileId: string,
|
||||||
|
options: SquareHoleRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<SquareHoleRunResponse>(
|
return requestJson<SquareHoleRunResponse>(
|
||||||
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
|
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
|
||||||
{
|
{
|
||||||
@@ -30,7 +41,12 @@ export function startSquareHoleRun(profileId: string) {
|
|||||||
body: JSON.stringify({ profileId }),
|
body: JSON.stringify({ profileId }),
|
||||||
},
|
},
|
||||||
'启动方洞挑战失败',
|
'启动方洞挑战失败',
|
||||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
{
|
||||||
|
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
import {
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
type ApiRetryOptions,
|
type ApiRetryOptions,
|
||||||
fetchWithApiAuth,
|
fetchWithApiAuth,
|
||||||
requestJson,
|
requestJson,
|
||||||
@@ -41,6 +42,10 @@ const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
|
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
|
||||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||||
};
|
};
|
||||||
|
type VisualNovelRuntimeRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
export type VisualNovelSaveArchiveResumeResponse =
|
export type VisualNovelSaveArchiveResumeResponse =
|
||||||
ProfileSaveArchiveResumeResponse<
|
ProfileSaveArchiveResumeResponse<
|
||||||
@@ -97,6 +102,7 @@ async function openVisualNovelRuntimeSsePost(
|
|||||||
export async function startVisualNovelRun(
|
export async function startVisualNovelRun(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
payload: VisualNovelStartRunRequest,
|
payload: VisualNovelStartRunRequest,
|
||||||
|
options: VisualNovelRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestJson<VisualNovelRunResponse>(
|
return requestJson<VisualNovelRunResponse>(
|
||||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
||||||
@@ -105,6 +111,9 @@ export async function startVisualNovelRun(
|
|||||||
{
|
{
|
||||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||||
timeoutMs: 15000,
|
timeoutMs: 15000,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import type {
|
|||||||
VisualNovelWorkResponse,
|
VisualNovelWorkResponse,
|
||||||
VisualNovelWorksResponse,
|
VisualNovelWorksResponse,
|
||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
import {
|
||||||
|
type ApiRequestOptions,
|
||||||
|
type ApiRetryOptions,
|
||||||
|
requestJson,
|
||||||
|
} from '../apiClient';
|
||||||
|
|
||||||
const VISUAL_NOVEL_WORKS_API_BASE = '/api/creation/visual-novel/works';
|
const VISUAL_NOVEL_WORKS_API_BASE = '/api/creation/visual-novel/works';
|
||||||
const VISUAL_NOVEL_WORKS_READ_RETRY: ApiRetryOptions = {
|
const VISUAL_NOVEL_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||||
@@ -17,6 +21,10 @@ const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
maxDelayMs: 620,
|
maxDelayMs: 620,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
};
|
};
|
||||||
|
type VisualNovelWorksRequestOptions = Pick<
|
||||||
|
ApiRequestOptions,
|
||||||
|
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
export function listVisualNovelWorks() {
|
export function listVisualNovelWorks() {
|
||||||
return requestJson<VisualNovelWorksResponse>(
|
return requestJson<VisualNovelWorksResponse>(
|
||||||
@@ -29,13 +37,19 @@ export function listVisualNovelWorks() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVisualNovelWorkDetail(profileId: string) {
|
export function getVisualNovelWorkDetail(
|
||||||
|
profileId: string,
|
||||||
|
options: VisualNovelWorksRequestOptions = {},
|
||||||
|
) {
|
||||||
return requestJson<VisualNovelWorkResponse>(
|
return requestJson<VisualNovelWorkResponse>(
|
||||||
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取视觉小说作品详情失败',
|
'读取视觉小说作品详情失败',
|
||||||
{
|
{
|
||||||
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user