再次合并 master

合入 origin/master 最新订阅消息与计费相关更新

保留作品架 actions 收口并接入统一分享弹窗

修复创作生成泥点预检与本地余额扣减回归
This commit is contained in:
2026-06-08 17:18:38 +08:00
342 changed files with 4153 additions and 2483 deletions

View File

@@ -172,6 +172,7 @@ describe('barkBattleCreationClient', () => {
body: JSON.stringify({
slot: 'player-character',
draftId: 'draft-1',
billingPurpose: null,
config: {
title: '汪汪冠军杯',
description: '',
@@ -224,5 +225,16 @@ describe('barkBattleCreationClient', () => {
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
});
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/creation/bark-battle/images/generate',
expect.objectContaining({
body: expect.stringContaining(
'"billingPurpose":"initial_draft_generation"',
),
}),
'生成汪汪声浪素材失败',
expect.anything(),
);
});
});

View File

@@ -376,10 +376,12 @@ export function regenerateBarkBattleImageAsset(payload: {
slot: BarkBattleAssetSlot;
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
billingPurpose?: BarkBattleImageAssetGenerateRequest['billingPurpose'];
}): Promise<BarkBattleGeneratedImageAsset> {
const request: BarkBattleImageAssetGenerateRequest = {
slot: payload.slot,
draftId: payload.draftId ?? null,
billingPurpose: payload.billingPurpose ?? null,
config: payload.config,
};
return requestJson<BarkBattleGeneratedImageAsset>(
@@ -418,6 +420,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
slot,
config: payload.config,
draftId: payload.draftId,
billingPurpose: 'initial_draft_generation',
}),
slot,
)

View File

@@ -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 (

View File

@@ -1,13 +1,3 @@
const PUZZLE_HISTORY_ASSET_FALLBACK_NAME = '历史拼图素材';
function safeDecodePathSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parsePuzzleHistoryTimestamp(value: string) {
const trimmed = value.trim();
if (!trimmed) {
@@ -47,24 +37,6 @@ function parsePuzzleHistoryTimestamp(value: string) {
return new Date(timestampMs);
}
export function getPuzzleHistoryAssetDisplayName(
imageSrc: string | null | undefined,
) {
const trimmed = imageSrc?.trim() ?? '';
if (!trimmed) {
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
const pathOnly = trimmed.split(/[?#]/u)[0]?.trim() ?? '';
if (!pathOnly) {
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
const fileName = pathOnly.replace(/^\/+/u, '').split('/').filter(Boolean).pop();
const displayName = safeDecodePathSegment(fileName ?? '').trim();
return displayName || PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
export function formatPuzzleHistoryAssetCreatedAt(value: string) {
const parsedDate = parsePuzzleHistoryTimestamp(value);
if (!parsedDate) {
@@ -82,12 +54,7 @@ export function formatPuzzleHistoryAssetCreatedAt(value: string) {
}
export function getPuzzleHistoryAssetReferenceLabel(
imageSrc: string | null | undefined,
_imageSrc: string | null | undefined,
) {
const displayName = getPuzzleHistoryAssetDisplayName(imageSrc);
if (displayName === PUZZLE_HISTORY_ASSET_FALLBACK_NAME) {
return '历史素材';
}
return `历史素材 · ${displayName}`;
return '历史素材';
}

View File

@@ -0,0 +1,104 @@
/* @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 and resumes generation after return', async () => {
const navigateTo = vi.fn((options) => {
options.success?.();
window.setTimeout(() => {
window.dispatchEvent(new Event('focus'));
}, 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(navigateTo.mock.calls[0]?.[0].url).not.toContain('autoRequest=1');
expect(window.location.hash).toBe('');
});
test('keeps waiting even when native page returns immediately after navigate success', async () => {
const navigateTo = vi.fn((options) => {
window.dispatchEvent(new Event('focus'));
options.success?.();
});
window.history.replaceState(
null,
'',
'/creation/puzzle?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
navigateTo,
},
};
const requested = await requestGenerationResultSubscribePermission();
expect(requested).toBe(true);
});
test('still accepts legacy hash result from 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(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();
});
});

View File

@@ -0,0 +1,175 @@
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 = 2_500;
const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
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;
let resumeFallbackTimer: 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);
}
if (resumeFallbackTimer !== null) {
window.clearTimeout(resumeFallbackTimer);
}
};
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;
}
if (consume()) {
return;
}
// 中文注释:订阅授权只影响后续通知,不应阻断生成;原生页返回但没有 hash
// 回灌时,按已返回处理,让原本的生成提交流程继续执行。
if (resumeFallbackTimer === null) {
resumeFallbackTimer = window.setTimeout(
() => finish('returned'),
SUBSCRIBE_RESULT_RETURN_FALLBACK_MS,
);
}
};
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 resultPromise = waitSubscribeResultFromHash();
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 result = await resultPromise;
return Boolean(result);
}