点赞和改造开关加入后台配置

This commit is contained in:
2026-06-10 14:36:56 +08:00
parent 9db467d23f
commit e29992cf01
33 changed files with 1644 additions and 380 deletions

View File

@@ -372,9 +372,7 @@ import {
type CreationWorkShelfItem,
isPersistedBarkBattleDraftGenerating,
} from '../custom-world-home/creationWorkShelf';
import {
selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import {
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
@@ -1194,7 +1192,9 @@ const PuzzleClearResultView = lazy(async () => {
});
const PuzzleClearRuntimeShell = lazy(async () => {
const module = await import('../puzzle-clear-runtime/PuzzleClearRuntimeShell');
const module = await import(
'../puzzle-clear-runtime/PuzzleClearRuntimeShell'
);
return {
default: module.PuzzleClearRuntimeShell,
};
@@ -1712,6 +1712,10 @@ export function PlatformEntryFlowShellImpl({
const entries = creationEntryConfig?.creationTypes ?? [];
return new Map(entries.map((entry) => [entry.id, entry]));
}, [creationEntryConfig]);
const publicWorkInteractions = useMemo(
() => creationEntryConfig?.publicWorkInteractions ?? [],
[creationEntryConfig],
);
const getUnifiedSpec = useCallback(
(playId: UnifiedCreationPlayId) =>
getUnifiedCreationSpec(playId, unifiedCreationConfigById.get(playId)),
@@ -2900,8 +2904,10 @@ export function PlatformEntryFlowShellImpl({
woodenFishGalleryEntries,
],
);
const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } =
publicGalleryFeeds;
const {
featuredEntries: featuredGalleryEntries,
latestEntries: latestGalleryEntries,
} = publicGalleryFeeds;
const recommendRuntimeEntries = useMemo(
() =>
buildPlatformRecommendedEntries({
@@ -3084,23 +3090,22 @@ export function PlatformEntryFlowShellImpl({
]);
useEffect(() => {
const progressTickDecision =
resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
puzzle: puzzleGenerationState,
match3d: match3dGenerationState,
'big-fish': bigFishGenerationState,
'square-hole': squareHoleGenerationState,
'jump-hop': jumpHopGenerationState,
'wooden-fish': woodenFishGenerationState,
'baby-object-match': babyObjectMatchGenerationState,
},
visualNovel: {
startedAtMs: visualNovelGenerationStartedAtMs,
phase: visualNovelGenerationPhase,
},
});
const progressTickDecision = resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
puzzle: puzzleGenerationState,
match3d: match3dGenerationState,
'big-fish': bigFishGenerationState,
'square-hole': squareHoleGenerationState,
'jump-hop': jumpHopGenerationState,
'wooden-fish': woodenFishGenerationState,
'baby-object-match': babyObjectMatchGenerationState,
},
visualNovel: {
startedAtMs: visualNovelGenerationStartedAtMs,
phase: visualNovelGenerationPhase,
},
});
if (!progressTickDecision.shouldTick) {
return undefined;
@@ -3603,7 +3608,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('match3d', session.sessionId);
markDraftFailed(
'match3d',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
try {
@@ -3885,7 +3894,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('square-hole', session.sessionId);
markDraftFailed(
'square-hole',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
void refreshSquareHoleShelf().catch(() => undefined);
@@ -3965,15 +3978,18 @@ export function PlatformEntryFlowShellImpl({
if (!isPuzzleCompileActionReady(response.session)) {
const nextPayload =
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
);
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
puzzleGenerationState ?? fallbackGenerationState,
response.session,
);
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
const fallbackGenerationState =
createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
);
const nextGenerationState =
mergePuzzleSessionProgressIntoGenerationState(
puzzleGenerationState ?? fallbackGenerationState,
response.session,
);
activePuzzleGenerationSessionIdRef.current =
response.session.sessionId;
setSelectionStage('puzzle-generating');
markDraftGenerating('puzzle', [
response.session.sessionId,
@@ -7726,8 +7742,7 @@ export function PlatformEntryFlowShellImpl({
...current.filter(
(item) =>
item.workId !== response.work!.summary.workId &&
item.sourceSessionId !==
response.work!.summary.sourceSessionId,
item.sourceSessionId !== response.work!.summary.sourceSessionId,
),
]);
markPendingDraftReady(
@@ -7849,7 +7864,8 @@ export function PlatformEntryFlowShellImpl({
workTitle: puzzleClearSession.draft?.workTitle,
workDescription: puzzleClearSession.draft?.workDescription,
themePrompt: puzzleClearSession.draft?.themePrompt,
boardBackgroundPrompt: puzzleClearSession.draft?.boardBackgroundPrompt,
boardBackgroundPrompt:
puzzleClearSession.draft?.boardBackgroundPrompt,
generateBoardBackground:
puzzleClearSession.draft?.generateBoardBackground,
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset,
@@ -7874,11 +7890,9 @@ export function PlatformEntryFlowShellImpl({
);
setPuzzleClearError(errorMessage);
setPuzzleClearGenerationState(
resolveFinishedMiniGameDraftGenerationState(
generationState,
'failed',
{ error: errorMessage },
),
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
error: errorMessage,
}),
);
} finally {
setIsPuzzleClearBusy(false);
@@ -7905,7 +7919,9 @@ export function PlatformEntryFlowShellImpl({
setPuzzleClearWork(response.item);
setPuzzleClearWorks((current) => [
response.item.summary,
...current.filter((item) => item.workId !== response.item.summary.workId),
...current.filter(
(item) => item.workId !== response.item.summary.workId,
),
]);
void refreshPuzzleClearShelf();
void refreshPuzzleClearGallery();
@@ -7918,7 +7934,9 @@ export function PlatformEntryFlowShellImpl({
setPublicWorkDetailError(null);
selectionStageRef.current = 'work-detail';
setSelectionStage('work-detail');
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', publicWorkCode),
);
openPublishShareModal({
title: response.item.summary.workTitle || '拼消消',
publicWorkCode,
@@ -8020,7 +8038,9 @@ export function PlatformEntryFlowShellImpl({
return;
}
setPuzzleClearError(null);
setPuzzleClearRun(retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork));
setPuzzleClearRun(
retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork),
);
return;
}
@@ -10653,7 +10673,10 @@ export function PlatformEntryFlowShellImpl({
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
const intent = resolvePlatformPublicWorkLikeIntent(entry);
const intent = resolvePlatformPublicWorkLikeIntent(
entry,
publicWorkInteractions,
);
if (intent.type === 'like-big-fish') {
void likeBigFishGalleryWork(intent.profileId)
@@ -10763,6 +10786,7 @@ export function PlatformEntryFlowShellImpl({
[
isPublicWorkDetailBusy,
platformBootstrap,
publicWorkInteractions,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
runProtectedAction,
@@ -11049,7 +11073,8 @@ export function PlatformEntryFlowShellImpl({
notices: draftGenerationNotices,
generation: {
activeSessionId: jumpHopSession?.sessionId,
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
hasActiveGenerationFailure:
jumpHopGenerationState?.phase === 'failed',
},
});
markDraftNoticeSeen(openIntent.noticeKeys);
@@ -11133,7 +11158,9 @@ export function PlatformEntryFlowShellImpl({
try {
const detail = await puzzleClearClient.getRuntimeWorkDetail(profileId);
setPuzzleClearWork(detail.item);
openPublicWorkDetail(mapPuzzleClearWorkToPlatformGalleryCard(detail.item));
openPublicWorkDetail(
mapPuzzleClearWorkToPlatformGalleryCard(detail.item),
);
} catch (error) {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '读取拼消消详情失败。'),
@@ -11435,8 +11462,7 @@ export function PlatformEntryFlowShellImpl({
notices: draftGenerationNotices,
generation: {
activeSessionId: puzzleSession?.sessionId,
hasActiveGenerationFailure:
activeGenerationState?.phase === 'failed',
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
hasActiveGenerationRunning: isMiniGameDraftGenerating(
activeGenerationState ?? null,
),
@@ -11483,9 +11509,8 @@ export function PlatformEntryFlowShellImpl({
const failedError = backgroundTask?.error ?? openIntent.errorMessage;
if (!failedSession) {
try {
const { session: latestSession } = await getPuzzleAgentSession(
sourceSessionId,
);
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
failedSession = latestSession;
failedPayload = buildPuzzleFormPayloadFromSession(latestSession);
} catch {
@@ -11568,9 +11593,8 @@ export function PlatformEntryFlowShellImpl({
if (openIntent.type === 'restore-generating') {
try {
const { session: latestSession } = await getPuzzleAgentSession(
sourceSessionId,
);
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
@@ -11619,9 +11643,7 @@ export function PlatformEntryFlowShellImpl({
markDraftNoticeSeen(noticeKeys);
const restoredSession = await puzzleFlow.restoreDraft(
sourceSessionId,
);
const restoredSession = await puzzleFlow.restoreDraft(sourceSessionId);
if (!restoredSession) {
await refreshPuzzleShelf().catch(() => undefined);
return;
@@ -11669,8 +11691,7 @@ export function PlatformEntryFlowShellImpl({
forceDraft: options.forceDraft,
generation: {
activeSessionId: match3dSession?.sessionId,
hasActiveGenerationFailure:
activeGenerationState?.phase === 'failed',
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
hasActiveGenerationRunning: isMiniGameDraftGenerating(
activeGenerationState ?? null,
),
@@ -11865,9 +11886,7 @@ export function PlatformEntryFlowShellImpl({
markDraftNoticeSeen(noticeKeys);
const restoredSession = await match3dFlow.restoreDraft(
sourceSessionId,
);
const restoredSession = await match3dFlow.restoreDraft(sourceSessionId);
if (!restoredSession) {
await refreshMatch3DShelf().catch(() => undefined);
return;
@@ -13316,8 +13335,7 @@ export function PlatformEntryFlowShellImpl({
activeRecommendEntryKey && !isDesktopLayout
? (recommendRuntimeEntries.find(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) ===
activeRecommendEntryKey,
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
) ?? null)
: null;
const isActiveRecommendRuntimeReady =
@@ -13333,7 +13351,8 @@ export function PlatformEntryFlowShellImpl({
hasVisualNovelRun: Boolean(visualNovelRun),
hasWoodenFishRun: Boolean(woodenFishRun),
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
puzzleRunCurrentLevelProfileId:
puzzleRun?.currentLevel?.profileId ?? null,
});
useEffect(() => {
@@ -13407,7 +13426,10 @@ export function PlatformEntryFlowShellImpl({
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
const intent = resolvePlatformPublicWorkRemixIntent(entry);
const intent = resolvePlatformPublicWorkRemixIntent(
entry,
publicWorkInteractions,
);
if (intent.type === 'remix-big-fish') {
void remixBigFishGalleryWork(intent.profileId)
@@ -13482,6 +13504,7 @@ export function PlatformEntryFlowShellImpl({
isPublicWorkDetailBusy,
platformBootstrap,
puzzleFlow,
publicWorkInteractions,
resetRecommendRuntimeSelection,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
@@ -13698,10 +13721,7 @@ export function PlatformEntryFlowShellImpl({
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
return (
canExposePublicWork(detailEntry) &&
isSamePuzzleClearPublicWorkCode(
normalizedKeyword,
entry.profileId,
)
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
@@ -14126,7 +14146,9 @@ export function PlatformEntryFlowShellImpl({
jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [],
woodenFishItems: woodenFishShelfItems,
match3dItems: match3dShelfItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [],
squareHoleItems: isSquareHoleCreationVisible
? squareHoleShelfItems
: [],
puzzleItems: puzzleShelfItems,
babyObjectMatchItems: isBabyObjectMatchVisible
? babyObjectMatchDrafts
@@ -14358,7 +14380,7 @@ export function PlatformEntryFlowShellImpl({
puzzleShelfError ??
puzzleError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError ??
babyObjectMatchError ??
puzzleClearError ??
barkBattleError)
}
@@ -15766,7 +15788,9 @@ export function PlatformEntryFlowShellImpl({
profile={jumpHopWork}
isBusy={isJumpHopBusy}
error={jumpHopError}
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
runtimeRequestOptions={
jumpHopRuntimeRequestOptions ?? undefined
}
onBack={() => {
setSelectionStage(jumpHopRuntimeReturnStage);
}}
@@ -16226,7 +16250,9 @@ export function PlatformEntryFlowShellImpl({
<UnifiedCreationPage
spec={getUnifiedSpec('visual-novel')}
onBack={leaveVisualNovelFlow}
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
isBackDisabled={
isVisualNovelBusy || isVisualNovelStreamingReply
}
>
<VisualNovelAgentWorkspace
session={visualNovelSession}

View File

@@ -899,6 +899,23 @@ test('platform public work detail flow resolves like intent', () => {
});
});
test('platform public work detail flow respects configured like disable', () => {
expect(
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'), [
{
sourceType: 'puzzle',
likeEnabled: false,
remixEnabled: true,
likeDisabledMessage: '拼图点赞维护中。',
remixDisabledMessage: '拼图改造维护中。',
},
]),
).toEqual({
type: 'unsupported',
errorMessage: '拼图点赞维护中。',
});
});
test('platform public work detail flow resolves remix intent', () => {
expect(
resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')),
@@ -969,13 +986,31 @@ test('platform public work detail flow resolves remix intent', () => {
});
});
test('platform public work detail flow respects configured remix disable', () => {
expect(
resolvePlatformPublicWorkRemixIntent(buildRpgEntry(), [
{
sourceType: 'custom-world',
likeEnabled: true,
remixEnabled: false,
likeDisabledMessage: 'RPG 点赞维护中。',
remixDisabledMessage: 'RPG 改造维护中。',
},
]),
).toEqual({
type: 'unsupported',
errorMessage: 'RPG 改造维护中。',
});
});
test('platform public work detail flow resolves edit intent for draft-backed works', () => {
const bigFishEntry = buildTypedEntry('big-fish');
expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()))
.toEqual({
type: 'edit-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
});
expect(
resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()),
).toEqual({
type: 'edit-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
});
const selectedPuzzleDetail = buildPuzzleWork({
profileId: 'puzzle-profile',
@@ -1153,7 +1188,10 @@ test('platform public work detail flow resolves edit intent for unsupported and
const edutainmentEntry = buildTypedEntry('edutainment');
expect(
resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()),
resolvePlatformPublicWorkEditIntent(
edutainmentEntry,
buildEditIntentDeps(),
),
).toEqual({
type: 'resolve-edutainment-draft',
entry: edutainmentEntry,

View File

@@ -97,6 +97,14 @@ export type PlatformPublicWorkDetailOpenStrategy =
export type PlatformPublicWorkActionMode = 'edit' | 'remix';
export type PlatformPublicWorkInteractionConfig = {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
};
export type PlatformPublicWorkLikeIntent =
| {
type: 'like-big-fish';
@@ -678,9 +686,55 @@ export function resolvePlatformPublicWorkActionMode(
: 'remix';
}
export function getPlatformPublicWorkInteractionSourceType(
entry: PlatformPublicGalleryCard,
) {
return 'sourceType' in entry ? entry.sourceType : 'custom-world';
}
function resolveConfiguredPublicWorkInteractionBlock(
entry: PlatformPublicGalleryCard,
configs: readonly PlatformPublicWorkInteractionConfig[] | null | undefined,
action: 'like' | 'remix',
): PlatformPublicWorkLikeIntent | PlatformPublicWorkRemixIntent | null {
const sourceType = getPlatformPublicWorkInteractionSourceType(entry);
const config = configs?.find((item) => item.sourceType === sourceType);
if (!config) {
return null;
}
if (action === 'like' && !config.likeEnabled) {
return {
type: 'unsupported',
errorMessage:
config.likeDisabledMessage.trim() || '该作品类型暂不支持点赞。',
};
}
if (action === 'remix' && !config.remixEnabled) {
return {
type: 'unsupported',
errorMessage:
config.remixDisabledMessage.trim() || '该作品类型暂不支持改造。',
};
}
return null;
}
export function resolvePlatformPublicWorkLikeIntent(
entry: PlatformPublicGalleryCard,
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
): PlatformPublicWorkLikeIntent {
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
entry,
configs,
'like',
);
if (configuredBlock) {
return configuredBlock as PlatformPublicWorkLikeIntent;
}
if (isBigFishGalleryEntry(entry)) {
return {
type: 'like-big-fish',
@@ -760,7 +814,17 @@ export function resolvePlatformPublicWorkLikeIntent(
export function resolvePlatformPublicWorkRemixIntent(
entry: PlatformPublicGalleryCard,
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
): PlatformPublicWorkRemixIntent {
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
entry,
configs,
'remix',
);
if (configuredBlock) {
return configuredBlock as PlatformPublicWorkRemixIntent;
}
if (isBigFishGalleryEntry(entry)) {
return {
type: 'remix-big-fish',
@@ -933,8 +997,9 @@ export function resolvePlatformPublicWorkEditIntent(
if (isVisualNovelGalleryEntry(entry)) {
const work =
deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ??
null;
deps.visualNovelWorks?.find(
(item) => item.profileId === entry.profileId,
) ?? null;
if (!work) {
return {
type: 'blocked',

View File

@@ -81,7 +81,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -156,7 +155,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -334,6 +332,64 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('keeps local token when refresh fails with transient server unavailable', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
status: 503,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('keeps local token when refresh cannot reach the restarting server', async () => {
setStoredAccessToken('usable-local-token', { emit: false });
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('usable-local-token');
});
it('clears local token when refresh confirms the session is unauthorized', async () => {
setStoredAccessToken('expired-local-token', { emit: false });
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
status: 401,
});
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('');
});
it('does not clear auth when protected request refresh fails transiently', async () => {
setStoredAccessToken('expired-token-during-restart', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(dispatchEventMock).not.toHaveBeenCalled();
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
});
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
@@ -344,7 +400,6 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
@@ -366,7 +421,7 @@ describe('apiClient', () => {
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('rejects refresh responses that do not return a renewed bearer token', async () => {
it('rejects malformed refresh responses without treating them as logout', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
@@ -397,8 +452,8 @@ describe('apiClient', () => {
message: '读取受保护数据失败',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getStoredAccessToken()).toBe('');
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(getStoredAccessToken()).toBe('expired-token');
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('keeps the current access token when a public request explicitly skips auth', async () => {

View File

@@ -497,6 +497,13 @@ function withAuthorizationHeaders(
let refreshAccessTokenPromise: Promise<string> | null = null;
function shouldClearAuthAfterRefreshFailure(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
return refreshAccessTokenPromise;
@@ -522,11 +529,11 @@ async function refreshAccessToken() {
)
: null;
if (payload?.ok !== true || !payload.token?.trim()) {
const nextToken = payload?.token?.trim();
if (!nextToken) {
throw new Error('刷新登录状态失败');
}
const nextToken = payload.token.trim();
setStoredAccessToken(nextToken, { emit: false });
return nextToken;
})();
@@ -556,7 +563,10 @@ export async function refreshStoredAccessToken(
try {
return await refreshAccessToken();
} catch (error) {
if (options.clearOnFailure !== false) {
if (
options.clearOnFailure !== false &&
shouldClearAuthAfterRefreshFailure(error)
) {
clearStoredAccessToken({ emit: false });
}
throw error;
@@ -629,11 +639,15 @@ export async function fetchWithApiAuth(
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
continue;
} catch {
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
} catch (refreshError) {
const shouldClearAuth =
hasAuthHeader &&
authFailurePolicy.clearAuthOnUnauthorized &&
shouldClearAuthAfterRefreshFailure(refreshError);
if (shouldClearAuth) {
clearStoredAccessToken({ emit: false });
}
if (authFailurePolicy.notifyAuthStateChange) {
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
emitAuthStateChange();
}
}

View File

@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
htmlCode?: string | null;
};
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
export type PublicWorkInteractionConfig = {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
};
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
export type CreationEntryConfig = {
startCard: {
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
eventBanner: CreationEntryEventBannerConfig;
/** 底部加号创作入口页的多公告轮播配置。 */
eventBanners?: CreationEntryEventBannerConfig[];
/** 公开作品详情页点赞 / 改造能力矩阵。 */
publicWorkInteractions?: PublicWorkInteractionConfig[];
creationTypes: CreationEntryTypeConfig[];
};