合并 master 最新更新
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
APP_HOME_TITLE,
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './activeAppTitle';
|
||||
|
||||
const hostBridgeMock = vi.hoisted(() => ({
|
||||
setHostAppTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./host-bridge/hostBridge', () => ({
|
||||
setHostAppTitle: hostBridgeMock.setHostAppTitle,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
document.title = '';
|
||||
hostBridgeMock.setHostAppTitle.mockReset();
|
||||
});
|
||||
|
||||
describe('activeAppTitle', () => {
|
||||
test('maps only active platform stages', () => {
|
||||
expect(resolveAppTitleForSelectionStage('platform')).toBe(APP_HOME_TITLE);
|
||||
expect(resolveAppTitleForSelectionStage('creation-home')).toBe(
|
||||
APP_HOME_TITLE,
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('project')).toBe('项目 - 陶泥儿');
|
||||
expect(resolveAppTitleForSelectionStage('profile')).toBe('我的 - 陶泥儿');
|
||||
expect(resolveAppTitleForSelectionStage('image-editor')).toBe(
|
||||
'美术编辑器 - 陶泥儿',
|
||||
);
|
||||
});
|
||||
|
||||
test('syncs browser and host titles', () => {
|
||||
syncAppTitle(' 项目 - 陶泥儿 ');
|
||||
|
||||
expect(document.title).toBe('项目 - 陶泥儿');
|
||||
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
|
||||
title: '项目 - 陶泥儿',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to the brand for an empty title', () => {
|
||||
syncAppTitle(' ');
|
||||
|
||||
expect(document.title).toBe('陶泥儿');
|
||||
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
|
||||
title: '陶泥儿',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SelectionStage } from '../components/platform-entry/platformEntryActiveTypes';
|
||||
import { setHostAppTitle } from './host-bridge/hostBridge';
|
||||
|
||||
const APP_TITLE_BRAND = '陶泥儿';
|
||||
export const APP_HOME_TITLE = '陶泥儿 Genarrative|美术编辑器与项目工作台';
|
||||
|
||||
const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
|
||||
platform: APP_HOME_TITLE,
|
||||
'creation-home': APP_HOME_TITLE,
|
||||
project: '项目 - 陶泥儿',
|
||||
profile: '我的 - 陶泥儿',
|
||||
'image-editor': '美术编辑器 - 陶泥儿',
|
||||
};
|
||||
|
||||
export function resolveAppTitleForSelectionStage(stage: SelectionStage) {
|
||||
return APP_TITLE_BY_SELECTION_STAGE[stage];
|
||||
}
|
||||
|
||||
export function syncAppTitle(title: string) {
|
||||
const normalizedTitle = title.trim() || APP_TITLE_BRAND;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = normalizedTitle;
|
||||
}
|
||||
|
||||
void setHostAppTitle({ title: normalizedTitle });
|
||||
}
|
||||
@@ -20,10 +20,9 @@ vi.mock('./apiClient', async () => {
|
||||
});
|
||||
|
||||
vi.mock('./host-bridge/hostBridge', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./host-bridge/hostBridge')>(
|
||||
'./host-bridge/hostBridge',
|
||||
);
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./host-bridge/hostBridge')
|
||||
>('./host-bridge/hostBridge');
|
||||
return {
|
||||
...actual,
|
||||
openHostExternalUrl: hostBridgeMocks.openHostExternalUrl,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
normalizePhoneInput,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
revokeAuthSession,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
startWechatBind,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
validateAndNormalizeMainlandChinaPhoneInput,
|
||||
} from './authService';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
@@ -104,6 +105,26 @@ describe('authService', () => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('normalizes mainland China browser autofill phone numbers to national format', () => {
|
||||
expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210');
|
||||
expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210');
|
||||
expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210');
|
||||
});
|
||||
|
||||
it('validates mainland China phone numbers before calling auth APIs', async () => {
|
||||
expect(
|
||||
validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'),
|
||||
).toBe('19876543210');
|
||||
expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe(
|
||||
'19876543210',
|
||||
);
|
||||
|
||||
await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow(
|
||||
'仅支持中国大陆手机号(+86)',
|
||||
);
|
||||
expect(apiClientMocks.requestJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auth entry posts phone password credentials and 写入 access token', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-entry-token',
|
||||
@@ -126,7 +147,8 @@ describe('authService', () => {
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
countryCode: '86',
|
||||
purePhoneNumber: '13800138000',
|
||||
password: 'secret123',
|
||||
}),
|
||||
}),
|
||||
@@ -217,14 +239,15 @@ describe('authService', () => {
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
const result = await sendPhoneLoginCode('+86 138 0013 8000');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
countryCode: '86',
|
||||
purePhoneNumber: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
@@ -277,7 +300,7 @@ describe('authService', () => {
|
||||
});
|
||||
|
||||
const response = await loginWithPhoneCode(
|
||||
'13800138000',
|
||||
'+86 138 0013 8000',
|
||||
'123456',
|
||||
'spring-2026',
|
||||
);
|
||||
@@ -287,7 +310,8 @@ describe('authService', () => {
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
countryCode: '86',
|
||||
purePhoneNumber: '13800138000',
|
||||
code: '123456',
|
||||
inviteCode: 'SPRING2026',
|
||||
}),
|
||||
@@ -356,6 +380,17 @@ describe('authService', () => {
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
countryCode: '86',
|
||||
purePhoneNumber: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -377,6 +412,17 @@ describe('authService', () => {
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
countryCode: '86',
|
||||
purePhoneNumber: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -504,9 +550,11 @@ 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?.();
|
||||
});
|
||||
const navigateTo = vi.fn(
|
||||
(options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
@@ -555,16 +603,16 @@ describe('authService', () => {
|
||||
});
|
||||
|
||||
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 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);
|
||||
},
|
||||
),
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
scriptListeners.set(type, listener);
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
|
||||
+37
-48
@@ -25,7 +25,6 @@ import type {
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
RuntimeGuestTokenResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
@@ -67,46 +66,39 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
skipRefresh: true,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
const runtimeGuestTokenCache: {
|
||||
value: RuntimeGuestTokenResponse | null;
|
||||
} = {
|
||||
value: null,
|
||||
};
|
||||
|
||||
function isRuntimeGuestTokenFresh(response: RuntimeGuestTokenResponse | null) {
|
||||
if (!response?.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAtMs = Date.parse(response.expiresAt);
|
||||
return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > 15_000;
|
||||
}
|
||||
|
||||
export function clearRuntimeGuestTokenCache() {
|
||||
runtimeGuestTokenCache.value = null;
|
||||
}
|
||||
|
||||
export async function ensureRuntimeGuestToken() {
|
||||
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
||||
return runtimeGuestTokenCache.value!;
|
||||
}
|
||||
|
||||
const response = await requestJson<RuntimeGuestTokenResponse>(
|
||||
'/api/auth/runtime-guest-token',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'获取匿名运行态身份失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
runtimeGuestTokenCache.value = response;
|
||||
return response;
|
||||
}
|
||||
|
||||
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
|
||||
const INVALID_MAINLAND_CHINA_PHONE_MESSAGE = '手机号格式不正确';
|
||||
const UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE = '仅支持中国大陆手机号(+86)';
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, '');
|
||||
const mainlandChinaInternationalPhone =
|
||||
compactPhone.match(/^\+?86(1\d{10})$/u);
|
||||
|
||||
return mainlandChinaInternationalPhone?.[1] ?? compactPhone;
|
||||
}
|
||||
|
||||
export function validateAndNormalizeMainlandChinaPhoneInput(
|
||||
phoneInput: string,
|
||||
) {
|
||||
const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, '');
|
||||
if (compactPhone.startsWith('+') && !compactPhone.startsWith('+86')) {
|
||||
throw new Error(UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE);
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhoneInput(phoneInput);
|
||||
if (!/^1\d{10}$/u.test(normalizedPhone)) {
|
||||
throw new Error(INVALID_MAINLAND_CHINA_PHONE_MESSAGE);
|
||||
}
|
||||
|
||||
return normalizedPhone;
|
||||
}
|
||||
|
||||
function buildMainlandChinaPhoneInput(phoneInput: string) {
|
||||
return {
|
||||
countryCode: '86',
|
||||
purePhoneNumber: validateAndNormalizeMainlandChinaPhoneInput(phoneInput),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
|
||||
@@ -129,10 +121,7 @@ export function setStoredLastLoginPhone(phone: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhoneInput(phone);
|
||||
if (!normalizedPhone) {
|
||||
return;
|
||||
}
|
||||
const normalizedPhone = validateAndNormalizeMainlandChinaPhoneInput(phone);
|
||||
|
||||
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
|
||||
}
|
||||
@@ -183,7 +172,7 @@ export async function sendPhoneLoginCode(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
@@ -208,7 +197,7 @@ export async function loginWithPhoneCode(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
code: code.trim(),
|
||||
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
|
||||
}),
|
||||
@@ -237,7 +226,7 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const payload: AuthWechatBindPhoneRequest = {
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
code: code.trim(),
|
||||
};
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
@@ -261,7 +250,7 @@ export async function changePhoneNumber(phone: string, code: string) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
@@ -326,7 +315,7 @@ export async function authEntry(phone: string, password: string) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
password: password.trim(),
|
||||
}),
|
||||
},
|
||||
@@ -387,7 +376,7 @@ export async function resetPassword(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
...buildMainlandChinaPhoneInput(phone),
|
||||
code: code.trim(),
|
||||
newPassword: newPassword.trim(),
|
||||
}),
|
||||
|
||||
@@ -157,17 +157,20 @@ describe('editorProjectClient', () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-1',
|
||||
canvasId: 'editor-project-1:canvas:default',
|
||||
revision: 8,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await saveEditorProjectLayout('editor-project-1', {
|
||||
viewport: { x: 12, y: 24, scale: 0.5 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
||||
expectedRevision: 7,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'editor-project-1',
|
||||
canvasId: 'editor-project-1:canvas:default',
|
||||
revision: 8,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
@@ -180,6 +183,7 @@ describe('editorProjectClient', () => {
|
||||
layers: [
|
||||
{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 },
|
||||
],
|
||||
expectedRevision: 7,
|
||||
}),
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
|
||||
@@ -541,6 +541,9 @@ export type EditorCanvasSnapshot = {
|
||||
title: string;
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
revision?: number;
|
||||
layoutStorageVersion?: number;
|
||||
backgroundColor?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -552,11 +555,13 @@ export type EditorProjectCreateInput = {
|
||||
export type EditorProjectLayoutSaveInput = {
|
||||
viewport: EditorCanvasViewport;
|
||||
layers: EditorProjectLayerSnapshot[];
|
||||
expectedRevision?: number;
|
||||
};
|
||||
|
||||
export type EditorProjectLayoutSaveResult = {
|
||||
projectId: string;
|
||||
canvasId: string;
|
||||
revision?: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -781,6 +786,7 @@ export async function saveEditorProjectLayout(
|
||||
jsonRequest('PATCH', {
|
||||
viewport: input.viewport,
|
||||
layers: input.layers,
|
||||
expectedRevision: input.expectedRevision,
|
||||
}),
|
||||
'保存图片画布工程失败',
|
||||
);
|
||||
|
||||
@@ -1,5 +1,364 @@
|
||||
/**
|
||||
* 平台首页资料读取入口。
|
||||
* 复用 RPG profile 聚合出口,避免平台入口和 RPG 入口测试、鉴权包装出现两套读取口径。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';
|
||||
import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ExternalApiKeyCreateResponse,
|
||||
ExternalApiKeyListResponse,
|
||||
ExternalApiKeyMutationResponse,
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeOrder,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
appendApiErrorRequestId,
|
||||
parseApiErrorMessage,
|
||||
} from '../../../packages/shared/src/http';
|
||||
import {
|
||||
type ApiAuthImpact,
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
const PLATFORM_PROFILE_API_BASE = '/api/profile';
|
||||
const PLATFORM_PROFILE_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const PLATFORM_PROFILE_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type PlatformProfileRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authImpact?: ApiAuthImpact;
|
||||
notifyAuthStateChange?: boolean;
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
};
|
||||
|
||||
function requestPlatformProfileJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET'
|
||||
? PLATFORM_PROFILE_READ_RETRY
|
||||
: PLATFORM_PROFILE_WRITE_RETRY);
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
return requestJson<T>(
|
||||
`${PLATFORM_PROFILE_API_BASE}${normalizedPath}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
authImpact: options.authImpact,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformProfileDashboard(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ProfileDashboardSummary>(
|
||||
'/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformProfileWalletLedger(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ProfileWalletLedgerResponse>(
|
||||
'/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function listPlatformProfileExternalApiKeys(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ExternalApiKeyListResponse>(
|
||||
'/api-keys',
|
||||
{ method: 'GET' },
|
||||
'读取 API Key 失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlatformProfileExternalApiKey(
|
||||
name: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ExternalApiKeyCreateResponse>(
|
||||
'/api-keys',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
},
|
||||
'创建 API Key 失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function revokePlatformProfileExternalApiKey(
|
||||
keyId: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ExternalApiKeyMutationResponse>(
|
||||
`/api-keys/${encodeURIComponent(keyId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'撤销 API Key 失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformProfileRechargeCenter(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ProfileRechargeCenterResponse>(
|
||||
'/recharge-center',
|
||||
{ method: 'GET' },
|
||||
'读取泥点购买信息失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlatformProfileRechargeOrder(
|
||||
productId: string,
|
||||
paymentChannel: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<CreateProfileRechargeOrderResponse>(
|
||||
'/recharge/orders',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId, paymentChannel }),
|
||||
},
|
||||
'充值失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmWechatPlatformProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ConfirmWechatProfileRechargeOrderResponse>(
|
||||
`/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
|
||||
{ method: 'POST' },
|
||||
'确认微信支付订单失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
type PlatformProfileRechargeOrderSseEvent =
|
||||
| {
|
||||
type: 'order';
|
||||
payload: ConfirmWechatProfileRechargeOrderResponse;
|
||||
}
|
||||
| {
|
||||
type: 'done';
|
||||
payload: { orderId: string; status: string };
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
payload: { message: string };
|
||||
};
|
||||
|
||||
function normalizePlatformProfileRechargeOrderSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): PlatformProfileRechargeOrderSseEvent | 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;
|
||||
}
|
||||
|
||||
function isPlatformProfileRechargeOrderTerminal(
|
||||
order: Pick<ProfileRechargeOrder, 'status' | 'expirationCheckedAt'>,
|
||||
) {
|
||||
if (order.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
if (order.status === 'expired' && !order.expirationCheckedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function watchWechatPlatformProfileRechargeOrder(
|
||||
orderId: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${PLATFORM_PROFILE_API_BASE}/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');
|
||||
}
|
||||
|
||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const normalized = normalizePlatformProfileRechargeOrderSseEvent(
|
||||
eventName,
|
||||
parsed,
|
||||
);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (isPlatformProfileRechargeOrderTerminal(normalized.payload.order)) {
|
||||
finalResponse = normalized.payload;
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
if (
|
||||
!finalResponse &&
|
||||
lastResponse &&
|
||||
isPlatformProfileRechargeOrderTerminal(lastResponse.order)
|
||||
) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
});
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('充值订单状态流返回不完整');
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
export function getPlatformProfileReferralInviteCenter(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<ProfileReferralInviteCenterResponse>(
|
||||
'/referrals/invite-center',
|
||||
{ method: 'GET' },
|
||||
'读取邀请码失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemPlatformProfileReferralInviteCode(
|
||||
inviteCode: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<RedeemProfileReferralInviteCodeResponse>(
|
||||
'/referrals/redeem-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inviteCode }),
|
||||
},
|
||||
'填写邀请码失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemPlatformProfileRewardCode(
|
||||
code: string,
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
) {
|
||||
return requestPlatformProfileJson<RedeemProfileRewardCodeResponse>(
|
||||
'/redeem-codes/redeem',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
'兑换失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RuntimeSettings } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
|
||||
const PLATFORM_SETTINGS_API_PATH = '/api/runtime/settings';
|
||||
const PLATFORM_SETTINGS_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const PLATFORM_SETTINGS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
type PlatformSettingsRequestOptions = ApiRequestOptions & {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export function getPlatformRuntimeSettings(
|
||||
options: PlatformSettingsRequestOptions = {},
|
||||
) {
|
||||
const { signal, ...requestOptions } = options;
|
||||
return requestJson<RuntimeSettings>(
|
||||
PLATFORM_SETTINGS_API_PATH,
|
||||
{ method: 'GET', signal },
|
||||
'读取设置失败',
|
||||
{
|
||||
...requestOptions,
|
||||
retry: requestOptions.retry ?? PLATFORM_SETTINGS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function putPlatformRuntimeSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: PlatformSettingsRequestOptions = {},
|
||||
) {
|
||||
const { signal, ...requestOptions } = options;
|
||||
return requestJson<RuntimeSettings>(
|
||||
PLATFORM_SETTINGS_API_PATH,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
signal,
|
||||
},
|
||||
'保存设置失败',
|
||||
{
|
||||
...requestOptions,
|
||||
retry: requestOptions.retry ?? PLATFORM_SETTINGS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user