修复拼图生成前订阅授权
新增小程序原生订阅消息授权页,在用户点击后请求生成结果通知授权。 拼图 compile_puzzle_draft 前等待授权页返回或跳过后再发起生成 action。 移除 web-view message 订阅授权路径,改用 storage/hash 回写订阅结果。 补充订阅授权测试、文档和团队踩坑记录。
This commit is contained in:
@@ -70,7 +70,6 @@ import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
|
||||
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||
import type {
|
||||
PuzzleRunSnapshot,
|
||||
@@ -224,17 +223,12 @@ import {
|
||||
buildSquareHoleGenerationAnchorEntries,
|
||||
buildWoodenFishGenerationAnchorEntries,
|
||||
createMiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationPhase,
|
||||
type MiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
} from '../unified-creation/unifiedCreationSpecs';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
@@ -369,6 +363,7 @@ import {
|
||||
publishVisualNovelWork,
|
||||
updateVisualNovelWork,
|
||||
} from '../../services/visual-novel-works';
|
||||
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
|
||||
import {
|
||||
woodenFishClient,
|
||||
type WoodenFishGalleryCardResponse,
|
||||
@@ -421,6 +416,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
} from '../unified-creation/unifiedCreationSpecs';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
@@ -439,7 +439,6 @@ import {
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
filterGeneralPublicWorks,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -471,11 +470,13 @@ import {
|
||||
type PlatformErrorDialogPayload,
|
||||
} from './PlatformErrorDialog';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import {
|
||||
PlatformTaskCompletionDialog,
|
||||
type PlatformTaskCompletionDialogPayload,
|
||||
} from './PlatformTaskCompletionDialog';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
||||
@@ -6738,7 +6739,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}
|
||||
},
|
||||
beforeExecuteAction: ({ payload, session }) => {
|
||||
beforeExecuteAction: async ({ payload, session }) => {
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
if (formPayload) {
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
@@ -6747,6 +6748,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (payload.action !== 'compile_puzzle_draft') {
|
||||
return;
|
||||
}
|
||||
await requestGenerationResultSubscribePermission();
|
||||
markDraftGenerating('puzzle', [
|
||||
session.sessionId,
|
||||
buildPuzzleResultWorkId(session.sessionId),
|
||||
@@ -7979,6 +7981,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const actionPayload = buildPuzzleCompileActionFromFormPayload(payload);
|
||||
await requestGenerationResultSubscribePermission();
|
||||
const response = await executePuzzleAgentAction(
|
||||
nextSession.sessionId,
|
||||
actionPayload,
|
||||
|
||||
@@ -300,6 +300,91 @@ function ActionCompleteHarness({
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeActionHarness({ events }: { events: string[] }) {
|
||||
const hasOpenedRef = useRef(false);
|
||||
const flow = usePlatformCreationAgentFlowController<
|
||||
ActionTestSession,
|
||||
Record<string, never>,
|
||||
{ session: ActionTestSession },
|
||||
TestMessagePayload,
|
||||
{ action: string },
|
||||
{ session: ActionTestSession }
|
||||
>({
|
||||
client: {
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
getSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
streamMessage: async () => ({
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
}),
|
||||
executeAction: async () => {
|
||||
events.push('executeAction');
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-ready-1' },
|
||||
},
|
||||
};
|
||||
},
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: () => true,
|
||||
resolveErrorMessage: (error, fallback) =>
|
||||
error instanceof Error ? error.message : fallback,
|
||||
errorMessages: {
|
||||
open: '打开失败',
|
||||
restoreMissingSession: '缺少会话',
|
||||
restore: '恢复失败',
|
||||
submit: '发送失败',
|
||||
execute: '执行失败',
|
||||
},
|
||||
enterCreateTab: () => {},
|
||||
setSelectionStage: () => {},
|
||||
beforeExecuteAction: async () => {
|
||||
events.push('beforeExecuteAction');
|
||||
await Promise.resolve();
|
||||
events.push('permissionResolved');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasOpenedRef.current = true;
|
||||
void flow.openWorkspace({});
|
||||
}, [flow]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void flow.executeAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionChangeHarness({
|
||||
onSessionChanged,
|
||||
}: {
|
||||
@@ -547,6 +632,28 @@ test('creation agent flow suppresses compile result stage for background complet
|
||||
);
|
||||
});
|
||||
|
||||
test('creation agent flow waits for beforeExecuteAction before network action', async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
render(<BeforeActionHarness events={events} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: '执行' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
'beforeExecuteAction',
|
||||
'permissionResolved',
|
||||
'executeAction',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent flow notifies session changes after open restore and compile', async () => {
|
||||
const onSessionChanged = vi.fn();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { TextStreamOptions } from '../../services/aiTypes';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
@@ -90,7 +90,7 @@ type PlatformCreationAgentFlowControllerOptions<
|
||||
beforeExecuteAction?: (params: {
|
||||
payload: TActionPayload;
|
||||
session: TSession;
|
||||
}) => void;
|
||||
}) => void | Promise<void>;
|
||||
onActionError?: (params: {
|
||||
payload: TActionPayload;
|
||||
error: unknown;
|
||||
@@ -211,7 +211,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, resetStreamingReply],
|
||||
[isBusy, options, resetStreamingReply, setSession],
|
||||
);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
@@ -249,7 +249,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[options, resetStreamingReply],
|
||||
[options, resetStreamingReply, setSession],
|
||||
);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
@@ -309,7 +309,13 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsStreamingReply(false);
|
||||
}
|
||||
},
|
||||
[isStreamingReply, options, session, updateStreamingReplyText],
|
||||
[
|
||||
isStreamingReply,
|
||||
options,
|
||||
session,
|
||||
setSession,
|
||||
updateStreamingReplyText,
|
||||
],
|
||||
);
|
||||
|
||||
const executeAction = useCallback(
|
||||
@@ -323,7 +329,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
options.beforeExecuteAction?.({ payload, session: targetSession });
|
||||
await options.beforeExecuteAction?.({ payload, session: targetSession });
|
||||
const response = await options.client.executeAction(
|
||||
targetSession.sessionId,
|
||||
payload,
|
||||
@@ -358,7 +364,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, session],
|
||||
[isBusy, options, session, setSession],
|
||||
);
|
||||
|
||||
const leaveFlow = useCallback(() => {
|
||||
|
||||
@@ -72,8 +72,9 @@ export function isManualMockPaymentChannel(paymentChannel: string) {
|
||||
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
function isWechatMiniProgramRuntime(
|
||||
location: Pick<Location, 'search'> | null | undefined,
|
||||
export function isWechatMiniProgramRuntime(
|
||||
location: Pick<Location, 'search'> | null | undefined =
|
||||
typeof window !== 'undefined' ? window.location : null,
|
||||
) {
|
||||
const params = new URLSearchParams(location?.search ?? '');
|
||||
return (
|
||||
|
||||
58
src/services/wechatMiniProgramSubscribe.test.ts
Normal file
58
src/services/wechatMiniProgramSubscribe.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
requestGenerationResultSubscribePermission,
|
||||
} from './wechatMiniProgramSubscribe';
|
||||
|
||||
describe('wechatMiniProgramSubscribe', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.wx = undefined;
|
||||
});
|
||||
|
||||
test('requests generation result subscription permission through native mini program page', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
window.setTimeout(() => {
|
||||
window.location.hash = 'wx_subscribe_result=req-1:success';
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}, 0);
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u),
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
expect(window.location.hash).toBe('');
|
||||
});
|
||||
|
||||
test('skips permission request outside mini program web-view', async () => {
|
||||
const navigateTo = vi.fn();
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestGenerationResultSubscribePermission();
|
||||
|
||||
expect(requested).toBe(false);
|
||||
expect(navigateTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
160
src/services/wechatMiniProgramSubscribe.ts
Normal file
160
src/services/wechatMiniProgramSubscribe.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { isWechatMiniProgramRuntime } from './payment/paymentPlatform';
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
|
||||
const SUBSCRIBE_RESULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function clearSubscribeResultHash() {
|
||||
const rawHash = window.location.hash.replace(/^#/, '');
|
||||
if (!rawHash.includes(`${SUBSCRIBE_RESULT_HASH_KEY}=`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(rawHash);
|
||||
params.delete(SUBSCRIBE_RESULT_HASH_KEY);
|
||||
const nextHash = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
function readSubscribeResultFromHash() {
|
||||
const value = new URLSearchParams(window.location.hash.replace(/^#/, '')).get(
|
||||
SUBSCRIBE_RESULT_HASH_KEY,
|
||||
);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
clearSubscribeResultHash();
|
||||
return value;
|
||||
}
|
||||
|
||||
function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) {
|
||||
const immediateResult = readSubscribeResultFromHash();
|
||||
if (immediateResult) {
|
||||
return Promise.resolve(immediateResult);
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let timer: number | null = null;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
window.removeEventListener('focus', handleResume);
|
||||
window.removeEventListener('pageshow', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const finish = (result: string | null) => {
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
const consume = () => {
|
||||
const result = readSubscribeResultFromHash();
|
||||
if (result) {
|
||||
finish(result);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const handleHashChange = () => {
|
||||
consume();
|
||||
};
|
||||
const handleResume = () => {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
consume();
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
window.addEventListener('focus', handleResume);
|
||||
window.addEventListener('pageshow', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
timer = window.setTimeout(() => finish(null), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
function loadWechatJsSdk() {
|
||||
if (
|
||||
!isWechatMiniProgramRuntime() ||
|
||||
typeof window === 'undefined'
|
||||
) {
|
||||
return Promise.reject(new Error('not_mini_program'));
|
||||
}
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('wechat_js_sdk_unavailable'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), {
|
||||
once: true,
|
||||
});
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestGenerationResultSubscribePermission() {
|
||||
if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let wxBridge: NonNullable<Window['wx']>;
|
||||
try {
|
||||
wxBridge = await loadWechatJsSdk();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = `subscribe_generation_result_${Date.now()}`;
|
||||
const navigated = await new Promise<boolean>((resolve) => {
|
||||
miniProgram.navigateTo?.({
|
||||
url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
|
||||
success() {
|
||||
resolve(true);
|
||||
},
|
||||
fail() {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!navigated) {
|
||||
return false;
|
||||
}
|
||||
const resultPromise = waitSubscribeResultFromHash();
|
||||
const result = await resultPromise;
|
||||
return Boolean(result);
|
||||
}
|
||||
Reference in New Issue
Block a user