import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ClaimProfileTaskRewardResponse, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, ProfileReferralInviteCenterResponse, ProfileRechargeCenterResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileTaskCenterResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeResponse, RuntimeSettings, 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, type RuntimeRequestOptions, } from '../rpg-runtime/rpgRuntimeRequest'; export type { RuntimeRequestOptions }; /** * RPG profile 域 client。 * 工作包 C 需要把继续游戏归档与资料读取收进新域目录,避免继续堆在 `storageService`。 */ export function getRpgProfileSettings(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/settings', { method: 'GET' }, '读取设置失败', options, ); } export function putRpgProfileSettings( settings: RuntimeSettings, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings), }, '保存设置失败', options, ); } export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/dashboard', { method: 'GET' }, '读取个人看板失败', options, ); } export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/wallet-ledger', { method: 'GET' }, '读取资产流水失败', options, ); } export function getRpgProfileRechargeCenter( options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/profile/recharge-center', { method: 'GET' }, '读取账户充值失败', options, ); } export function createRpgProfileRechargeOrder( productId: string, paymentChannel: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/profile/recharge/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId, paymentChannel }), }, '充值失败', options, ); } export function confirmWechatRpgProfileRechargeOrder( orderId: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( `/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`, { method: 'POST' }, '确认微信支付订单失败', options, ); } 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; } catch { return null; } } function normalizeRechargeOrderSseEvent( eventName: string, parsed: Record, ): 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 { 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 = {}, ) { return requestRpgRuntimeJson( '/profile/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '提交反馈失败', options, ); } export function getRpgProfileReferralInviteCenter( options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/profile/referrals/invite-center', { method: 'GET' }, '读取邀请码失败', options, ); } export function redeemRpgProfileReferralInviteCode( inviteCode: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/profile/referrals/redeem-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteCode }), }, '填写邀请码失败', options, ); } export function redeemRpgProfileRewardCode( code: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( '/profile/redeem-codes/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }, '兑换失败', options, ); } export function getRpgProfileTasks(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/tasks', { method: 'GET' }, '读取每日任务失败', options, ); } export function claimRpgProfileTaskReward( taskId: string, options: RuntimeRequestOptions = {}, ) { return requestRpgRuntimeJson( `/profile/tasks/${encodeURIComponent(taskId)}/claim`, { method: 'POST' }, '领取任务奖励失败', options, ); } export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { return requestRpgRuntimeJson( '/profile/play-stats', { method: 'GET' }, '读取游玩统计失败', options, ); } export async function listRpgProfileSaveArchives( options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( '/profile/save-archives', { method: 'GET' }, '读取存档列表失败', { ...RUNTIME_BACKGROUND_AUTH_OPTIONS, ...options, }, ); return Array.isArray(response?.entries) ? response.entries : []; } export async function resumeRpgProfileSaveArchive( worldKey: string, options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( `/profile/save-archives/${encodeURIComponent(worldKey)}`, { method: 'POST' }, '恢复存档失败', options, ); return { entry: response.entry, snapshot: rehydrateSavedSnapshot( response.snapshot as HydratedSavedGameSnapshot, ), }; } export async function listRpgProfileBrowseHistory( options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( '/profile/browse-history', { method: 'GET' }, '读取浏览历史失败', { ...RUNTIME_BACKGROUND_AUTH_OPTIONS, ...options, }, ); return Array.isArray(response?.entries) ? response.entries : []; } export async function upsertRpgProfileBrowseHistory( entry: PlatformBrowseHistoryWriteEntry, options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( '/profile/browse-history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(entry), }, '写入浏览历史失败', { ...RUNTIME_BACKGROUND_AUTH_OPTIONS, ...options, }, ); return Array.isArray(response?.entries) ? response.entries : []; } export async function syncRpgProfileBrowseHistory( entries: PlatformBrowseHistoryWriteEntry[], options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( '/profile/browse-history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entries, } satisfies PlatformBrowseHistoryBatchSyncRequest), }, '同步浏览历史失败', options, ); return Array.isArray(response?.entries) ? response.entries : []; } export async function clearRpgProfileBrowseHistory( options: RuntimeRequestOptions = {}, ) { const response = await requestRpgRuntimeJson( '/profile/browse-history', { method: 'DELETE' }, '清空浏览历史失败', options, ); return Array.isArray(response?.entries) ? response.entries : []; } export const rpgProfileClient = { getDashboard: getRpgProfileDashboard, getPlayStats: getRpgProfilePlayStats, getWalletLedger: getRpgProfileWalletLedger, getRechargeCenter: getRpgProfileRechargeCenter, createRechargeOrder: createRpgProfileRechargeOrder, confirmWechatRechargeOrder: confirmWechatRpgProfileRechargeOrder, submitFeedback: submitRpgProfileFeedback, getReferralInviteCenter: getRpgProfileReferralInviteCenter, redeemReferralInviteCode: redeemRpgProfileReferralInviteCode, getTasks: getRpgProfileTasks, claimTaskReward: claimRpgProfileTaskReward, getSettings: getRpgProfileSettings, putSettings: putRpgProfileSettings, listSaveArchives: listRpgProfileSaveArchives, resumeSaveArchive: resumeRpgProfileSaveArchive, listBrowseHistory: listRpgProfileBrowseHistory, upsertBrowseHistory: upsertRpgProfileBrowseHistory, syncBrowseHistory: syncRpgProfileBrowseHistory, clearBrowseHistory: clearRpgProfileBrowseHistory, };