566 lines
14 KiB
TypeScript
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,
|
|
};
|