Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment

# Conflicts:
#	.hermes/shared-memory/decision-log.md
This commit is contained in:
kdletters
2026-05-27 20:35:32 +08:00
256 changed files with 10164 additions and 6985 deletions

View File

@@ -20,8 +20,8 @@ import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntry,
bindWechatPhone,
changePhoneNumber,
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
@@ -34,6 +34,7 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
@@ -408,6 +409,84 @@ describe('authService', () => {
);
});
it('requests mini program phone login by opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
wx: {
miniProgram: {
navigateTo,
},
},
}),
);
const result = await requestWechatMiniProgramPhoneLogin();
expect(result).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
const scriptListeners = new Map<string, EventListener>();
const existingScript = {
addEventListener: vi.fn(
(type: string, listener: EventListener) => {
scriptListeners.set(type, listener);
},
),
};
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
}),
);
vi.stubGlobal('document', {
querySelector: vi.fn(() => existingScript),
head: {
appendChild: vi.fn(),
},
createElement: vi.fn(),
});
const request = requestWechatMiniProgramPhoneLogin();
window.wx = {
miniProgram: {
navigateTo,
},
};
scriptListeners.get('load')?.(new Event('load'));
await expect(request).resolves.toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('loads available login methods for the unauthenticated login screen', async () => {
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],

View File

@@ -20,8 +20,8 @@ import type {
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
AuthWechatBindPhoneResponse,
AuthWechatBindPhoneRequest,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
PublicUserSearchResponse,
@@ -55,6 +55,10 @@ export type ConsumedAuthCallback = {
error: string | null;
};
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const MINI_PROGRAM_AUTH_PAGE_URL =
'/pages/web-view/index?authAction=login&returnTo=previous';
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
@@ -80,6 +84,92 @@ export function clearRuntimeGuestTokenCache() {
runtimeGuestTokenCache.value = null;
}
export function isWechatMiniProgramWebViewRuntime() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search || '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program' ||
Boolean(window.wx?.miniProgram?.postMessage)
);
}
function loadWechatMiniProgramBridge() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成登录'));
}
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('请在微信小程序内完成登录'));
}
};
if (existingScript) {
if (window.wx?.miniProgram?.navigateTo) {
complete();
return;
}
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成登录')),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
document.head.appendChild(script);
});
}
export async function requestWechatMiniProgramPhoneLogin() {
if (!isWechatMiniProgramWebViewRuntime()) {
return false;
}
const wxBridge = await loadWechatMiniProgramBridge();
const miniProgram = wxBridge.miniProgram;
const navigateTo = miniProgram?.navigateTo;
if (typeof navigateTo !== 'function') {
return false;
}
await new Promise<void>((resolve, reject) => {
navigateTo({
url: MINI_PROGRAM_AUTH_PAGE_URL,
success() {
resolve();
},
fail(error) {
reject(
new Error(error?.errMsg || '请在微信小程序内完成登录'),
);
},
});
});
return true;
}
export async function ensureRuntimeGuestToken() {
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
return runtimeGuestTokenCache.value!;

View File

@@ -279,7 +279,7 @@ async function generateBabyObjectMatchAssets(
const assets = normalizeGeneratedAssets(response.assets, itemNames);
const visualPackage = normalizeGeneratedVisualPackage(response.visualPackage);
if (!assets || !visualPackage) {
throw new Error('宝贝识物 image-2 资源生成结果不完整,请重试。');
throw new Error('宝贝识物素材生成结果不完整,请重试。');
}
return { assets, visualPackage };

View File

@@ -117,15 +117,6 @@ test('local Match3D runtime adapter exposes the same runtime seam as the server
expect(stopped.run.status).toBe('Stopped');
});
test('local Match3D runtime adapter keeps the requested profile id on restart', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
const started = await adapter.startRun('match3d-demo-20260525');
const restarted = await adapter.restartRun(started.run.runId);
expect(started.run.profileId).toBe('match3d-demo-20260525');
expect(restarted.run.profileId).toBe('match3d-demo-20260525');
});
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
const first = await adapter.getRun('unused-run-id');

View File

@@ -37,7 +37,7 @@ describe('miniGameDraftGenerationProgress', () => {
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
'生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);

View File

@@ -167,7 +167,7 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
detail: '生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
@@ -177,15 +177,15 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
id: 'puzzle-level-scene',
label: '生成关卡画面',
detail: shouldSkipPuzzleCoverGeneration(state)
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。'
: '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
id: 'puzzle-ui-assets',
label: '生成UI与背景',
detail:
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
},
{
@@ -305,7 +305,7 @@ const MATCH3D_STEPS = [
{
id: 'match3d-level-scene',
label: '生成关卡整图',
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
detail: '生成 9:16 完整抓大鹅关卡画面。',
weight: 28,
},
{

View File

@@ -1,5 +1,7 @@
import { beforeEach, expect, test, vi } from 'vitest';
const requestJsonMock = vi.hoisted(() => vi.fn());
const { createCreationAgentClientMock } = vi.hoisted(() => ({
createCreationAgentClientMock: vi.fn(),
}));
@@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({
}));
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
requestJson: requestJsonMock,
}));
beforeEach(() => {
@@ -22,6 +24,7 @@ beforeEach(() => {
streamMessage: vi.fn(),
executeAction: vi.fn(),
});
requestJsonMock.mockReset();
});
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
@@ -34,3 +37,16 @@ test('wooden fish creation keeps image2 generation requests alive long enough',
}),
);
});
test('wooden fish list works uses creation works endpoint', async () => {
const { woodenFishClient } = await import('./woodenFishClient');
requestJsonMock.mockResolvedValueOnce({ items: [] });
await woodenFishClient.listWorks();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/wooden-fish/works',
{ method: 'GET' },
'读取敲木鱼作品列表失败',
);
});

View File

@@ -13,6 +13,7 @@ import type {
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorksResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
@@ -57,6 +58,7 @@ export type {
WoodenFishWorkDetailResponse,
WoodenFishWorkMutationResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorksResponse,
WoodenFishWorkspaceCreateRequest,
};
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
@@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) {
return normalizeWoodenFishWorkDetailResponse(response);
}
export async function listWoodenFishWorks() {
const response = await requestJson<WoodenFishWorksResponse>(
WOODEN_FISH_WORKS_API_BASE,
{ method: 'GET' },
'读取敲木鱼作品列表失败',
);
return response;
}
export async function listWoodenFishGallery() {
return requestJson<WoodenFishGalleryResponse>(
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
@@ -312,6 +323,7 @@ export const woodenFishClient = {
getSession: getWoodenFishCreationSession,
getWorkDetail: getWoodenFishWorkDetail,
listGallery: listWoodenFishGallery,
listWorks: listWoodenFishWorks,
publishWork: publishWoodenFishWork,
startRun: startWoodenFishRuntimeRun,
};