Files
Genarrative/src/services/rpg-entry/rpgProfileClient.ts

566 lines
14 KiB
TypeScript

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<RuntimeSettings>(
'/settings',
{ method: 'GET' },
'读取设置失败',
options,
);
}
export function putRpgProfileSettings(
settings: RuntimeSettings,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<RuntimeSettings>(
'/settings',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
},
'保存设置失败',
options,
);
}
export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileDashboardSummary>(
'/profile/dashboard',
{ method: 'GET' },
'读取个人看板失败',
options,
);
}
export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
'/profile/wallet-ledger',
{ method: 'GET' },
'读取资产流水失败',
options,
);
}
export function getRpgProfileRechargeCenter(
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ProfileRechargeCenterResponse>(
'/profile/recharge-center',
{ method: 'GET' },
'读取账户充值失败',
options,
);
}
export function createRpgProfileRechargeOrder(
productId: string,
paymentChannel: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
'/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<ConfirmWechatProfileRechargeOrderResponse>(
`/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<string, unknown>;
} catch {
return null;
}
}
function normalizeRechargeOrderSseEvent(
eventName: string,
parsed: Record<string, unknown>,
): 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<ConfirmWechatProfileRechargeOrderResponse> {
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<SubmitProfileFeedbackResponse>(
'/profile/feedback',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交反馈失败',
options,
);
}
export function getRpgProfileReferralInviteCenter(
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ProfileReferralInviteCenterResponse>(
'/profile/referrals/invite-center',
{ method: 'GET' },
'读取邀请码失败',
options,
);
}
export function redeemRpgProfileReferralInviteCode(
inviteCode: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<RedeemProfileReferralInviteCodeResponse>(
'/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<RedeemProfileRewardCodeResponse>(
'/profile/redeem-codes/redeem',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
},
'兑换失败',
options,
);
}
export function getRpgProfileTasks(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileTaskCenterResponse>(
'/profile/tasks',
{ method: 'GET' },
'读取每日任务失败',
options,
);
}
export function claimRpgProfileTaskReward(
taskId: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ClaimProfileTaskRewardResponse>(
`/profile/tasks/${encodeURIComponent(taskId)}/claim`,
{ method: 'POST' },
'领取任务奖励失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',
{ method: 'GET' },
'读取游玩统计失败',
options,
);
}
export async function listRpgProfileSaveArchives(
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<ProfileSaveArchiveListResponse>(
'/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<ProfileSaveArchiveResumeResponse>(
`/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<PlatformBrowseHistoryResponse>(
'/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<PlatformBrowseHistoryResponse>(
'/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<PlatformBrowseHistoryResponse>(
'/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<PlatformBrowseHistoryResponse>(
'/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,
};