Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/project-overview.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # scripts/dev.test.ts # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/wooden_fish.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/spacetime-client/src/wooden_fish.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/rpgEntryWorldPresentation.ts # src/services/miniGameDraftGenerationProgress.test.ts # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
|
||||
export type CreationEntryTypeConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -13,8 +14,40 @@ export type CreationEntryTypeConfig = {
|
||||
categoryLabel: string;
|
||||
categorySortOrder: number;
|
||||
updatedAtMicros: number;
|
||||
unifiedCreationSpec?: UnifiedCreationSpec | null;
|
||||
};
|
||||
|
||||
/** 统一创作工作台字段契约,用于表单型玩法的最小输入描述。 */
|
||||
export type UnifiedCreationField = {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
label: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
|
||||
export type UnifiedCreationSpec = {
|
||||
playId: string;
|
||||
title: string;
|
||||
workspaceStage: string;
|
||||
generationStage: string;
|
||||
resultStage: string;
|
||||
fields: UnifiedCreationField[];
|
||||
};
|
||||
|
||||
/** 创作入口公告位配置,HTML 模式仅用于沙箱预览,结构化字段保留旧数据兼容。 */
|
||||
export type CreationEntryEventBannerConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
renderMode?: 'structured' | 'html';
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
title: string;
|
||||
@@ -26,17 +59,14 @@ export type CreationEntryConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
eventBanner: {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
};
|
||||
/** 旧单条公告位兼容字段,新代码优先读取 eventBanners。 */
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
/** 拉取底部加号创作入口配置;所有入口和公告都以后端事实源为准。 */
|
||||
export async function fetchCreationEntryConfig(): Promise<CreationEntryConfig> {
|
||||
return requestJson<CreationEntryConfig>(
|
||||
'/api/creation-entry/config',
|
||||
|
||||
@@ -37,9 +37,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
|
||||
);
|
||||
expect(progress?.steps[2]?.detail).toBe(
|
||||
'生成 1:1 拼图首图,预计 4 分钟。',
|
||||
);
|
||||
expect(progress?.steps[2]?.detail).toBe('生成 1:1 拼图首图,预计 4 分钟。');
|
||||
expect(progress?.estimatedRemainingMs).toBe(446_500);
|
||||
expect(progress?.overallProgress).toBe(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
@@ -73,7 +71,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 7000);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 130_000);
|
||||
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.overallProgress).toBeLessThan(88);
|
||||
@@ -106,8 +104,8 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(longRunningProgress?.phaseId).toBe('compile');
|
||||
expect(longRunningProgress?.steps[0]?.status).toBe('active');
|
||||
expect(longRunningProgress?.steps[1]?.status).toBe('pending');
|
||||
expect(longRunningProgress?.overallProgress).toBeLessThanOrEqual(98);
|
||||
expect(longRunningProgress?.overallProgress).toBeGreaterThan(40);
|
||||
expect(longRunningProgress?.overallProgress).toBeLessThan(5);
|
||||
expect(longRunningProgress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(realProgress?.phaseId).toBe('puzzle-cover-image');
|
||||
expect(realProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(realProgress?.steps[2]?.status).toBe('active');
|
||||
@@ -146,7 +144,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 20_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
206_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
@@ -176,13 +177,14 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
|
||||
expect(progress?.phaseId).toBe('compile');
|
||||
expect(progress?.overallProgress).toBeLessThan(88);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(80);
|
||||
expect(progress?.overallProgress).toBeLessThan(5);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[0]?.status).toBe('active');
|
||||
expect(progress?.steps[0]?.completed).toBe(0.98);
|
||||
expect(progress?.steps.slice(1).every((step) => step.status === 'pending')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
progress?.steps.slice(1).every((step) => step.status === 'pending'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('puzzle draft generation advances steps from backend progress percent only', () => {
|
||||
@@ -222,9 +224,11 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
|
||||
expect(imageProgress?.overallProgress).toBeLessThan(88);
|
||||
expect(imageProgress?.steps[2]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[3]?.status).toBe('pending');
|
||||
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
|
||||
expect(uiProgress?.overallProgress).toBeLessThan(94);
|
||||
expect(uiProgress?.steps[4]?.status).toBe('active');
|
||||
expect(uiProgress?.steps[5]?.status).toBe('pending');
|
||||
expect(writeProgress?.phaseId).toBe('puzzle-select-image');
|
||||
@@ -250,6 +254,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 121_000);
|
||||
|
||||
expect(progress?.phaseId).toBe('puzzle-cover-image');
|
||||
expect(progress?.overallProgress).toBeLessThan(88);
|
||||
expect(progress?.steps[2]?.status).toBe('active');
|
||||
expect(progress?.steps[2]?.completed).toBeLessThan(0.02);
|
||||
});
|
||||
@@ -600,11 +605,11 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
floatingWords: ['幸运+1', '功德+1'],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '敲击物',
|
||||
value: '金色小木鱼',
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '敲击物',
|
||||
value: '金色小木鱼',
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
@@ -615,9 +620,9 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
id: 'wooden-fish-words',
|
||||
label: '飘字',
|
||||
value: '幸运+1、功德+1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle clear draft generation exposes atlas and slice pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('puzzle-clear');
|
||||
@@ -669,56 +674,59 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'locked',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
status: 'locked',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '清晰、适合拼图切块',
|
||||
status: 'inferred',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '主体轮廓、色块分区、局部细节',
|
||||
status: 'inferred',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜、拼图;禁止标题字',
|
||||
status: 'inferred',
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries(
|
||||
{
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_anchors',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫街',
|
||||
status: 'locked',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '一只猫在雨夜灯牌下回头。',
|
||||
status: 'locked',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '清晰、适合拼图切块',
|
||||
status: 'inferred',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '主体轮廓、色块分区、局部细节',
|
||||
status: 'inferred',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜、拼图;禁止标题字',
|
||||
status: 'inferred',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
}, {
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
{
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
|
||||
@@ -226,7 +226,10 @@ function resolvePuzzleBackendProgressPercent(
|
||||
state: MiniGameDraftGenerationState,
|
||||
) {
|
||||
const progressPercent = state.metadata?.puzzleProgressPercent;
|
||||
if (typeof progressPercent !== 'number' || !Number.isFinite(progressPercent)) {
|
||||
if (
|
||||
typeof progressPercent !== 'number' ||
|
||||
!Number.isFinite(progressPercent)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -512,6 +515,24 @@ function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function resolveMiniGameDraftGenerationStartedAtMs(
|
||||
startedAt: string | number | null | undefined,
|
||||
fallbackMs = Date.now(),
|
||||
) {
|
||||
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
if (typeof startedAt === 'string') {
|
||||
const parsed = Date.parse(startedAt);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'puzzle') {
|
||||
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
|
||||
@@ -590,6 +611,7 @@ function buildMiniGameProgressSteps(
|
||||
|
||||
export function createMiniGameDraftGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
@@ -600,16 +622,16 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: kind === 'puzzle-clear'
|
||||
? 'puzzle-clear-draft'
|
||||
: kind === 'wooden-fish'
|
||||
? 'wooden-fish-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: kind === 'puzzle-clear'
|
||||
? 'puzzle-clear-draft'
|
||||
: kind === 'wooden-fish'
|
||||
? 'wooden-fish-draft'
|
||||
: 'compile',
|
||||
startedAtMs,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
@@ -833,10 +855,7 @@ function resolvePuzzleActiveStepElapsedProgressRatio(
|
||||
// 中文注释:未收到后端真实里程碑时,跨步骤必须卡住;
|
||||
// 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。
|
||||
const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(0.98, elapsedMs / fallbackDurationMs),
|
||||
);
|
||||
return Math.max(0, Math.min(0.98, elapsedMs / fallbackDurationMs));
|
||||
}
|
||||
|
||||
function resolveElapsedActiveStepProgressRatio(
|
||||
@@ -866,6 +885,7 @@ function resolveElapsedActiveStepProgressRatio(
|
||||
);
|
||||
}
|
||||
|
||||
/** 计算拼图生成总进度,后端里程碑决定跨步骤,当前步骤内使用平滑假进度。 */
|
||||
function resolvePuzzleOverallProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
activeStepProgressRatio: number,
|
||||
@@ -918,7 +938,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
}
|
||||
|
||||
const effectiveNowMs =
|
||||
typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs)
|
||||
typeof state.finishedAtMs === 'number' &&
|
||||
Number.isFinite(state.finishedAtMs)
|
||||
? state.finishedAtMs
|
||||
: nowMs;
|
||||
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
|
||||
@@ -945,34 +966,34 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: woodenFishTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'square-hole' &&
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'match3d' &&
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'baby-object-match' &&
|
||||
: state.kind === 'match3d' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
}
|
||||
: state.kind === 'baby-object-match' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'jump-hop' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
@@ -980,14 +1001,14 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'puzzle-clear' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolvePuzzleClearPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'puzzle-clear' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolvePuzzleClearPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const puzzleTimedSteps =
|
||||
normalizedState.kind === 'puzzle'
|
||||
@@ -1002,22 +1023,23 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const activeStepProgressRatio =
|
||||
normalizedState.kind === 'puzzle'
|
||||
normalizedState.phase === 'failed'
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: normalizedState.phase === 'failed'
|
||||
? 0
|
||||
: resolvePuzzleActiveStepElapsedProgressRatio(
|
||||
normalizedState,
|
||||
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
|
||||
activeStepIndex,
|
||||
elapsedMs,
|
||||
effectiveNowMs,
|
||||
)
|
||||
: resolvePuzzleActiveStepElapsedProgressRatio(
|
||||
normalizedState,
|
||||
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
|
||||
activeStepIndex,
|
||||
elapsedMs,
|
||||
effectiveNowMs,
|
||||
)
|
||||
: normalizedState.totalAssetCount > 0
|
||||
? Math.min(
|
||||
1,
|
||||
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
|
||||
normalizedState.completedAssetCount /
|
||||
normalizedState.totalAssetCount,
|
||||
)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
@@ -1026,16 +1048,16 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? resolveElapsedActiveStepProgressRatio(
|
||||
normalizedState.kind,
|
||||
@@ -1065,20 +1087,11 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? Math.max(1, completedWeight)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 100
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? resolvePuzzleOverallProgress(
|
||||
normalizedState,
|
||||
activeStepProgressRatio,
|
||||
)
|
||||
: completedWeight + activeStep.weight * activeStepProgressRatio;
|
||||
: completedWeight + activeStep.weight * activeStepProgressRatio;
|
||||
const cappedOverallProgress =
|
||||
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
|
||||
? overallProgress
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
|
||||
: overallProgress;
|
||||
: Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress);
|
||||
|
||||
return {
|
||||
phaseId: normalizedState.phase,
|
||||
@@ -1099,11 +1112,11 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: normalizedState.kind === 'puzzle-clear'
|
||||
? '拼消消草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: normalizedState.kind === 'puzzle-clear'
|
||||
? '拼消消草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
@@ -1111,10 +1124,13 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
normalizedState.phase === 'ready'
|
||||
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, resolvePuzzleEstimatedWaitMs(normalizedState) - elapsedMs)
|
||||
? Math.max(
|
||||
0,
|
||||
resolvePuzzleEstimatedWaitMs(normalizedState) - elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
@@ -1122,17 +1138,17 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, MATCH3D_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? Math.max(
|
||||
0,
|
||||
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
|
||||
)
|
||||
? Math.max(0, BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'puzzle-clear'
|
||||
? Math.max(0, PUZZLE_CLEAR_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: null,
|
||||
: normalizedState.kind === 'puzzle-clear'
|
||||
? Math.max(
|
||||
0,
|
||||
PUZZLE_CLEAR_ESTIMATED_WAIT_MS - elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
|
||||
@@ -2,20 +2,45 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
resolveProfileRechargeProductPaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
} from './paymentPlatform';
|
||||
|
||||
describe('resolveProfileRechargePaymentChannel', () => {
|
||||
test('小程序运行态选择 wechat_mp', () => {
|
||||
test('小程序运行态基础通道选择 wechat_mp_virtual', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
}),
|
||||
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
|
||||
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('点数商品在小程序运行态选择 wechat_mp_virtual', () => {
|
||||
expect(
|
||||
resolveProfileRechargeProductPaymentChannel(
|
||||
{ kind: 'points' },
|
||||
{
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
},
|
||||
),
|
||||
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('会员商品在小程序运行态也选择 wechat_mp_virtual', () => {
|
||||
expect(
|
||||
resolveProfileRechargeProductPaymentChannel(
|
||||
{ kind: 'membership' },
|
||||
{
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
},
|
||||
),
|
||||
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('移动网页选择 wechat_h5', () => {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
|
||||
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
||||
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
|
||||
export const MOCK_PAYMENT_CHANNEL = 'mock';
|
||||
|
||||
export type ProfileRechargeWechatPaymentChannel =
|
||||
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||
| typeof WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL
|
||||
| typeof WECHAT_H5_PAYMENT_CHANNEL
|
||||
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||
|
||||
export type ProfileRechargeProductPaymentMode = {
|
||||
kind: 'points' | 'membership';
|
||||
};
|
||||
|
||||
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
|
||||
|
||||
export type PaymentPlatformContext = {
|
||||
@@ -45,7 +51,7 @@ export function resolveProfileRechargePaymentChannel(
|
||||
: null);
|
||||
|
||||
if (isWechatMiniProgramRuntime(location)) {
|
||||
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
|
||||
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
|
||||
@@ -55,6 +61,13 @@ export function resolveProfileRechargePaymentChannel(
|
||||
return WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
export function resolveProfileRechargeProductPaymentChannel(
|
||||
_product: ProfileRechargeProductPaymentMode,
|
||||
context: PaymentPlatformContext = {},
|
||||
): ProfileRechargeWechatPaymentChannel {
|
||||
return resolveProfileRechargePaymentChannel(context);
|
||||
}
|
||||
|
||||
export function isManualMockPaymentChannel(paymentChannel: string) {
|
||||
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
|
||||
fetchWithApiAuthMock: vi.fn(),
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
submitRpgProfileFeedback,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
watchWechatRpgProfileRechargeOrder,
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
@@ -21,9 +23,30 @@ vi.mock('../apiClient', () => ({
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
},
|
||||
fetchWithApiAuth: fetchWithApiAuthMock,
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
function createSseResponse(bodyText: string) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bodyText));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
describe('rpgProfileClient browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
@@ -231,3 +254,86 @@ describe('rpgProfileClient feedback routes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient recharge order events', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('waits for a non-pending order event before completing the SSE watch', async () => {
|
||||
const pendingOrder = {
|
||||
orderId: 'order-wechat-sse-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60泥点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending',
|
||||
paymentChannel: 'wechat_mp_virtual',
|
||||
paidAt: null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
};
|
||||
const center = {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
};
|
||||
const paidOrder = {
|
||||
...pendingOrder,
|
||||
status: 'paid',
|
||||
paidAt: '2026-04-25T10:01:00Z',
|
||||
providerTransactionId: 'wx-sse-1',
|
||||
pointsDelta: 120,
|
||||
};
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
|
||||
'',
|
||||
'event: order',
|
||||
`data: ${JSON.stringify({
|
||||
order: paidOrder,
|
||||
center: {
|
||||
...center,
|
||||
walletBalance: 120,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"orderId":"order-wechat-sse-1","status":"paid"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await watchWechatRpgProfileRechargeOrder(
|
||||
'order-wechat-sse-1',
|
||||
);
|
||||
|
||||
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
||||
'/api/profile/recharge/orders/order-wechat-sse-1/wechat/events',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.order.status).toBe('paid');
|
||||
expect(result.center.walletBalance).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ import type {
|
||||
SubmitProfileFeedbackRequest,
|
||||
SubmitProfileFeedbackResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { fetchWithApiAuth } from '../apiClient';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
requestRpgRuntimeJson,
|
||||
@@ -116,6 +118,235 @@ export function confirmWechatRpgProfileRechargeOrder(
|
||||
);
|
||||
}
|
||||
|
||||
type RechargeOrderSseEvent =
|
||||
| {
|
||||
type: 'order';
|
||||
payload: ConfirmWechatProfileRechargeOrderResponse;
|
||||
}
|
||||
| {
|
||||
type: 'done';
|
||||
payload: { orderId: string; status: string };
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
payload: { message: string };
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRechargeOrderSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): RechargeOrderSseEvent | null {
|
||||
if (eventName === 'order' && parsed.order && parsed.center) {
|
||||
return {
|
||||
type: 'order',
|
||||
payload: parsed as ConfirmWechatProfileRechargeOrderResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventName === 'done') {
|
||||
const orderId =
|
||||
typeof parsed.orderId === 'string' ? parsed.orderId.trim() : '';
|
||||
const status = typeof parsed.status === 'string' ? parsed.status.trim() : '';
|
||||
if (orderId && status) {
|
||||
return {
|
||||
type: 'done',
|
||||
payload: { orderId, status },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '';
|
||||
return {
|
||||
type: 'error',
|
||||
payload: { message },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function watchWechatRpgProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
signal: options.signal,
|
||||
},
|
||||
{
|
||||
skipRefresh: options.skipRefresh,
|
||||
skipAuth: options.skipAuth,
|
||||
authImpact: options.authImpact,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(
|
||||
appendApiErrorRequestId(
|
||||
parseApiErrorMessage(responseText, '订阅充值订单状态失败'),
|
||||
response.headers.get('x-request-id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let streamDone = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
streamDone = true;
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (finalResponse) {
|
||||
break;
|
||||
}
|
||||
if (streamDone) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalResponse) {
|
||||
if (lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('充值订单状态流返回不完整');
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
export function submitRpgProfileFeedback(
|
||||
payload: SubmitProfileFeedbackRequest,
|
||||
options: RuntimeRequestOptions = {},
|
||||
|
||||
Reference in New Issue
Block a user