refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
233
src/api/auth-api.ts
Normal file
233
src/api/auth-api.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
AUTH_ENDPOINTS,
|
||||
AUTH_SKIP_REFRESH_CONTEXT_KEY,
|
||||
SMS_SEND_EVENT_REGISTER,
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import {
|
||||
mergeAuthUsers,
|
||||
normalizeAuthSession,
|
||||
normalizeAuthUserProfile,
|
||||
normalizeRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-normalizers'
|
||||
import { getAuthDeviceId } from '@/store/auth'
|
||||
import type {
|
||||
ApiResponse,
|
||||
AuthSessionDto,
|
||||
AuthSessionInput,
|
||||
AuthUserProfileDto,
|
||||
LoginPayload,
|
||||
LoginRequestDto,
|
||||
LogoutPayload,
|
||||
LogoutRequestDto,
|
||||
RefreshTokenDto,
|
||||
RefreshTokenRequestDto,
|
||||
RegisterPayload,
|
||||
RegisterRequestDto,
|
||||
SendSmsCodeDto,
|
||||
SendSmsCodePayload,
|
||||
SendSmsCodeRequestDto,
|
||||
SendSmsCodeResult,
|
||||
} from '@/type'
|
||||
|
||||
const shouldLogAuthLifecycle =
|
||||
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
function unwrapEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackErrorKey = 'auth.errors.requestFailed',
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: fallbackErrorKey,
|
||||
})
|
||||
}
|
||||
|
||||
function logAuthSessionExpiry(action: string, session: AuthSessionInput) {
|
||||
if (!shouldLogAuthLifecycle || !session.accessTokenExpiresAt) {
|
||||
return
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[auth] ${action} user-token expires at ${new Date(
|
||||
session.accessTokenExpiresAt,
|
||||
).toISOString()} (${session.accessTokenExpiresAt})`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getCurrentUserProfileByToken(userToken: string) {
|
||||
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
'user-token': userToken,
|
||||
},
|
||||
})
|
||||
|
||||
return normalizeAuthUserProfile(
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<AuthUserProfileDto>,
|
||||
'auth.errors.requestFailed',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function buildEnrichedAuthSession(dto: AuthSessionDto) {
|
||||
const session = normalizeAuthSession(dto)
|
||||
|
||||
try {
|
||||
const profileUser = await getCurrentUserProfileByToken(session.accessToken)
|
||||
|
||||
return {
|
||||
...session,
|
||||
currentUser: mergeAuthUsers(session.currentUser, profileUser),
|
||||
} satisfies AuthSessionInput
|
||||
} catch {
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithPassword(
|
||||
payload: LoginPayload,
|
||||
): Promise<AuthSessionInput> {
|
||||
const response = await api.post<AuthSessionDto, LoginRequestDto>(
|
||||
AUTH_ENDPOINTS.login,
|
||||
{
|
||||
json: {
|
||||
device_id: getAuthDeviceId(),
|
||||
password: payload.password,
|
||||
username: payload.username,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const session = await buildEnrichedAuthSession(
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<AuthSessionDto>,
|
||||
'auth.login.errors.submitFailed',
|
||||
),
|
||||
)
|
||||
|
||||
logAuthSessionExpiry('login', session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export async function logoutWithPassword(
|
||||
payload: LogoutPayload,
|
||||
): Promise<void> {
|
||||
const response = await api.post<null, LogoutRequestDto>(
|
||||
AUTH_ENDPOINTS.logout,
|
||||
{
|
||||
json: {
|
||||
password: payload.password,
|
||||
username: payload.username,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<null>,
|
||||
'auth.logout.errors.submitFailed',
|
||||
)
|
||||
}
|
||||
|
||||
export async function registerWithPassword(
|
||||
payload: RegisterPayload,
|
||||
): Promise<AuthSessionInput> {
|
||||
const response = await api.post<AuthSessionDto, RegisterRequestDto>(
|
||||
AUTH_ENDPOINTS.register,
|
||||
{
|
||||
json: {
|
||||
captcha: payload.captcha,
|
||||
device_id: getAuthDeviceId(),
|
||||
invite_code: payload.inviteCode,
|
||||
password: payload.password,
|
||||
username: payload.mobile,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const session = await buildEnrichedAuthSession(
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<AuthSessionDto>,
|
||||
'auth.register.errors.submitFailed',
|
||||
),
|
||||
)
|
||||
|
||||
logAuthSessionExpiry('register', session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export async function sendSmsCode(
|
||||
payload: SendSmsCodePayload,
|
||||
): Promise<SendSmsCodeResult> {
|
||||
const response = await api.post<SendSmsCodeDto, SendSmsCodeRequestDto>(
|
||||
AUTH_ENDPOINTS.sendSmsCode,
|
||||
{
|
||||
json: {
|
||||
event: SMS_SEND_EVENT_REGISTER,
|
||||
mobile: payload.mobile,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const data = unwrapEnvelope(
|
||||
response as ApiResponse<SendSmsCodeDto>,
|
||||
'auth.register.sms.errors.submitFailed',
|
||||
)
|
||||
|
||||
return {
|
||||
expiresIn: data.expires_in,
|
||||
messageId: data.message_id,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentUserProfile() {
|
||||
const response = await api.post<AuthUserProfileDto>(AUTH_ENDPOINTS.profile)
|
||||
|
||||
return normalizeAuthUserProfile(
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<AuthUserProfileDto>,
|
||||
'auth.errors.requestFailed',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function refreshAuthSession(
|
||||
refreshToken: string,
|
||||
): Promise<AuthSessionInput | null> {
|
||||
const response = await api.post<RefreshTokenDto, RefreshTokenRequestDto>(
|
||||
AUTH_ENDPOINTS.refreshToken,
|
||||
{
|
||||
context: {
|
||||
[AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
|
||||
},
|
||||
json: {
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const session = normalizeRefreshAuthSession(
|
||||
unwrapEnvelope(
|
||||
response as ApiResponse<RefreshTokenDto>,
|
||||
'auth.errors.requestFailed',
|
||||
),
|
||||
)
|
||||
|
||||
logAuthSessionExpiry('refresh', session)
|
||||
|
||||
return session
|
||||
}
|
||||
377
src/api/finance-api.ts
Normal file
377
src/api/finance-api.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
FINANCE_API_ENDPOINTS,
|
||||
} from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DepositCreateRequestDto,
|
||||
DepositCreateResponseDto,
|
||||
DepositTierItem,
|
||||
DepositTierItemDto,
|
||||
DepositWithdrawConfig,
|
||||
DepositWithdrawConfigDto,
|
||||
FinanceCurrencyConfigDto,
|
||||
FinanceOrderItemDto,
|
||||
FinanceOrderList,
|
||||
FinanceOrderListDto,
|
||||
FinancePayChannelDto,
|
||||
FinanceRateConfigDto,
|
||||
FinanceWithdrawBankDto,
|
||||
FinanceWithdrawConfigDto,
|
||||
WalletRecordItemDto,
|
||||
WalletRecordList,
|
||||
WalletRecordListDto,
|
||||
WalletRecordType,
|
||||
WithdrawCreateRequestDto,
|
||||
WithdrawCreateResponseDto,
|
||||
} from '@/type'
|
||||
|
||||
function unwrapFinanceEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Finance request failed',
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
const responseMessage =
|
||||
typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: fallbackMessage
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message: responseMessage,
|
||||
})
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: string | number | null | undefined) {
|
||||
const numericValue = Number(value)
|
||||
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
function normalizeCurrency(dto: FinanceCurrencyConfigDto) {
|
||||
return {
|
||||
code: dto.code,
|
||||
depositCoinsPerFiat: dto.deposit_coins_per_fiat,
|
||||
depositCoinsPerFiatValue: toFiniteNumber(dto.deposit_coins_per_fiat),
|
||||
label: dto.label,
|
||||
withdrawCoinsPerFiat: dto.withdraw_coins_per_fiat,
|
||||
withdrawCoinsPerFiatValue: toFiniteNumber(dto.withdraw_coins_per_fiat),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRate(dto: FinanceRateConfigDto) {
|
||||
return {
|
||||
currency: dto.currency,
|
||||
diamondsPerFiatUnit: dto.diamonds_per_fiat_unit,
|
||||
diamondsPerFiatUnitValue: toFiniteNumber(dto.diamonds_per_fiat_unit),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayChannel(dto: FinancePayChannelDto) {
|
||||
return {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
sort: Number.isFinite(dto.sort) ? dto.sort : 0,
|
||||
status: dto.status,
|
||||
tierIds: Array.isArray(dto.tier_ids) ? dto.tier_ids : [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWithdrawBank(dto: FinanceWithdrawBankDto, index: number) {
|
||||
const code = dto.code ?? dto.id ?? dto.name ?? `bank-${index + 1}`
|
||||
const label = dto.label ?? dto.name ?? code
|
||||
|
||||
return {
|
||||
code,
|
||||
label,
|
||||
sort:
|
||||
typeof dto.sort === 'number' && Number.isFinite(dto.sort)
|
||||
? dto.sort
|
||||
: index,
|
||||
status: typeof dto.status === 'number' ? dto.status : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWithdrawConfig(dto: FinanceWithdrawConfigDto) {
|
||||
return {
|
||||
banks: (dto.banks ?? []).map(normalizeWithdrawBank),
|
||||
feeNote: dto.fee_note,
|
||||
minBank: dto.min_bank,
|
||||
minEwallet: dto.min_ewallet,
|
||||
processingNote: dto.processing_note,
|
||||
rateHint: dto.rate_hint,
|
||||
rateMode: dto.rate_mode,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepositWithdrawConfig(
|
||||
dto: DepositWithdrawConfigDto,
|
||||
): DepositWithdrawConfig {
|
||||
return {
|
||||
currencies: (dto.currencies ?? []).map(normalizeCurrency),
|
||||
payChannels: (dto.pay_channels ?? []).map(normalizePayChannel),
|
||||
platformCoinLabel: dto.platform_coin_label,
|
||||
rates: (dto.rates ?? []).map(normalizeRate),
|
||||
withdraw: normalizeWithdrawConfig(dto.withdraw),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepositTierItem(
|
||||
dto: DepositTierItemDto,
|
||||
index: number,
|
||||
): DepositTierItem {
|
||||
const id = String(dto.id ?? dto.tier_id ?? `tier-${index + 1}`)
|
||||
const amount = toFiniteNumber(dto.amount)
|
||||
const bonusAmount = toFiniteNumber(dto.bonus_amount ?? dto.bonus_coins)
|
||||
const payAmount = toFiniteNumber(dto.pay_amount ?? dto.amount)
|
||||
const totalAmount = toFiniteNumber(
|
||||
dto.total_amount ?? dto.coins ?? amount + bonusAmount,
|
||||
)
|
||||
const coins = totalAmount
|
||||
const currency =
|
||||
typeof dto.currency === 'string' && dto.currency.length > 0
|
||||
? dto.currency
|
||||
: null
|
||||
const payChannelCode =
|
||||
typeof dto.pay_channel_code === 'string' && dto.pay_channel_code.length > 0
|
||||
? dto.pay_channel_code
|
||||
: null
|
||||
const sort = toFiniteNumber(dto.sort ?? dto.pay_amount ?? dto.amount)
|
||||
const status = toFiniteNumber(dto.status ?? 1)
|
||||
const channels = (dto.channels ?? [])
|
||||
.map((channel, channelIndex) => ({
|
||||
code: channel.code ?? `channel-${channelIndex + 1}`,
|
||||
name: channel.name ?? channel.code ?? '--',
|
||||
sort: toFiniteNumber(channel.sort ?? channelIndex),
|
||||
}))
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
|
||||
return {
|
||||
amount,
|
||||
bonusAmount,
|
||||
channels,
|
||||
coins,
|
||||
currency,
|
||||
desc: dto.desc ?? '',
|
||||
id,
|
||||
name:
|
||||
typeof dto.name === 'string' && dto.name.length > 0
|
||||
? dto.name
|
||||
: `${amount}`,
|
||||
payAmount,
|
||||
payChannelCode,
|
||||
sort,
|
||||
status,
|
||||
tierKey:
|
||||
typeof dto.tier_key === 'string' && dto.tier_key.length > 0
|
||||
? dto.tier_key
|
||||
: null,
|
||||
totalAmount,
|
||||
title:
|
||||
typeof dto.title === 'string' && dto.title.length > 0
|
||||
? dto.title
|
||||
: typeof dto.name === 'string' && dto.name.length > 0
|
||||
? dto.name
|
||||
: `${amount}`,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
|
||||
return {
|
||||
amount: String(dto.amount ?? ''),
|
||||
bonusAmount: String(dto.bonus_amount ?? ''),
|
||||
orderNo: dto.order_no,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? 1,
|
||||
page_size: dto.pagination?.page_size ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
total: dto.pagination?.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyNullableValue(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
function normalizeWalletRecordItem(dto: WalletRecordItemDto, index: number) {
|
||||
const createdAt = dto.created_at ?? dto.create_time ?? dto.time ?? null
|
||||
const id =
|
||||
dto.id ??
|
||||
dto.record_id ??
|
||||
dto.wallet_record_id ??
|
||||
dto.order_no ??
|
||||
`${createdAt ?? 'wallet-record'}-${index + 1}`
|
||||
|
||||
return {
|
||||
amount: stringifyNullableValue(
|
||||
dto.amount ?? dto.change_amount ?? dto.coin ?? '',
|
||||
),
|
||||
balanceAfter: stringifyNullableValue(
|
||||
dto.balance_after ?? dto.after_balance ?? dto.balance ?? '',
|
||||
),
|
||||
balanceBefore: stringifyNullableValue(
|
||||
dto.balance_before ?? dto.before_balance ?? '',
|
||||
),
|
||||
createdAt,
|
||||
id: String(id),
|
||||
remark: stringifyNullableValue(dto.remark ?? dto.description ?? dto.memo),
|
||||
type: stringifyNullableValue(
|
||||
dto.type ?? dto.change_type ?? dto.biz_type ?? dto.scene,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWalletRecordList(dto: WalletRecordListDto): WalletRecordList {
|
||||
return {
|
||||
list: (dto.list ?? []).map(normalizeWalletRecordItem),
|
||||
pagination: {
|
||||
page: dto.pagination?.page ?? dto.page ?? 1,
|
||||
page_size:
|
||||
dto.pagination?.page_size ?? dto.page_size ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
total: dto.pagination?.total ?? dto.total ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDepositWithdrawConfig() {
|
||||
const response = await api.post<DepositWithdrawConfigDto>(
|
||||
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<DepositWithdrawConfigDto>,
|
||||
'Failed to load deposit and withdrawal config',
|
||||
)
|
||||
|
||||
return normalizeDepositWithdrawConfig(dto)
|
||||
}
|
||||
|
||||
export async function getDepositTierList() {
|
||||
const response = await api.post<
|
||||
| DepositTierItemDto[]
|
||||
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] },
|
||||
undefined
|
||||
>(FINANCE_API_ENDPOINTS.depositTierList)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<
|
||||
| DepositTierItemDto[]
|
||||
| { items?: DepositTierItemDto[]; list?: DepositTierItemDto[] }
|
||||
>,
|
||||
'Failed to load deposit tier list',
|
||||
)
|
||||
|
||||
const tierItems = Array.isArray(dto) ? dto : (dto?.list ?? dto?.items ?? [])
|
||||
|
||||
return tierItems
|
||||
.map(normalizeDepositTierItem)
|
||||
.filter((item) => item.status === 1)
|
||||
.sort((left, right) => left.sort - right.sort)
|
||||
}
|
||||
|
||||
export async function createDeposit(payload: DepositCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
DepositCreateResponseDto,
|
||||
DepositCreateRequestDto
|
||||
>(FINANCE_API_ENDPOINTS.depositCreate, {
|
||||
json: payload,
|
||||
})
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<DepositCreateResponseDto>,
|
||||
'Failed to create deposit',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getDepositOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.depositList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load deposit order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWithdrawOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<FinanceOrderListDto>(
|
||||
FINANCE_API_ENDPOINTS.withdrawList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<FinanceOrderListDto>,
|
||||
'Failed to load withdraw order list',
|
||||
)
|
||||
|
||||
return normalizeFinanceOrderList(dto)
|
||||
}
|
||||
|
||||
export async function getWalletRecordList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
type?: WalletRecordType
|
||||
}) {
|
||||
const response = await api.get<WalletRecordListDto>(
|
||||
FINANCE_API_ENDPOINTS.walletRecordList,
|
||||
{
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
type: params?.type ?? 'payout',
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<WalletRecordListDto>,
|
||||
'Failed to load wallet record list',
|
||||
)
|
||||
|
||||
return normalizeWalletRecordList(dto)
|
||||
}
|
||||
|
||||
export async function createWithdraw(payload: WithdrawCreateRequestDto) {
|
||||
const response = await api.post<
|
||||
WithdrawCreateResponseDto,
|
||||
WithdrawCreateRequestDto
|
||||
>(FINANCE_API_ENDPOINTS.withdrawCreate, {
|
||||
json: payload,
|
||||
})
|
||||
const dto = unwrapFinanceEnvelope(
|
||||
response as ApiResponse<WithdrawCreateResponseDto>,
|
||||
'Failed to create withdraw',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
501
src/api/game-api.ts
Normal file
501
src/api/game-api.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import {
|
||||
API_SUCCESS_CODE,
|
||||
DEFAULT_GAME_CHIP_COLORS,
|
||||
DEFAULT_LIST_PAGE_SIZE,
|
||||
GAME_API_ENDPOINTS,
|
||||
GAME_GRID_COLUMNS,
|
||||
GAME_MAX_SELECTION_CELLS,
|
||||
} from '@/constants'
|
||||
import {
|
||||
createEmptyGameBootstrapSnapshot,
|
||||
deriveTrendEntries,
|
||||
} from '@/features/game/shared'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type {
|
||||
AnnouncementItem,
|
||||
AnnouncementState,
|
||||
AnnouncementStateDto,
|
||||
ApiResponse,
|
||||
BetSelection,
|
||||
BetSelectionDto,
|
||||
ChipDto,
|
||||
ConnectionState,
|
||||
ConnectionStateDto,
|
||||
DashboardState,
|
||||
DashboardStateDto,
|
||||
GameAnnouncementsDto,
|
||||
GameBetOrdersDto,
|
||||
GameBootstrapDto,
|
||||
GameBootstrapSnapshot,
|
||||
GameCell,
|
||||
GameCellDto,
|
||||
GameLobbyInitDto,
|
||||
GameLobbyInitResult,
|
||||
GameLobbyPeriodDto,
|
||||
GamePeriodTickDto,
|
||||
GamePlaceBetDto,
|
||||
GamePlaceBetRequestDto,
|
||||
GameRoundFeedDto,
|
||||
HistoryEntry,
|
||||
HistoryEntryDto,
|
||||
NoticeConfirmDto,
|
||||
NoticeDetailDto,
|
||||
NoticeListDto,
|
||||
RoundPhase,
|
||||
RoundSnapshot,
|
||||
RoundSnapshotDto,
|
||||
TrendEntry,
|
||||
TrendEntryDto,
|
||||
} from '@/type'
|
||||
|
||||
function unwrapGameEnvelope<T>(
|
||||
response: ApiResponse<T>,
|
||||
fallbackMessage = 'Game request failed',
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: fallbackMessage,
|
||||
})
|
||||
}
|
||||
|
||||
function assertLobbyInitDto(
|
||||
dto: GameLobbyInitDto,
|
||||
): asserts dto is GameLobbyInitDto {
|
||||
if (
|
||||
!Number.isFinite(dto.server_time) ||
|
||||
!Array.isArray(dto.dictionary) ||
|
||||
!dto.bet_config ||
|
||||
!Number.isFinite(dto.bet_config.default_bet_chip_id)
|
||||
) {
|
||||
throw new ApiError({
|
||||
data: dto,
|
||||
message: 'Invalid game lobby init payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGameCell(dto: GameCellDto) {
|
||||
return dto satisfies GameCell
|
||||
}
|
||||
|
||||
function normalizeChip(dto: ChipDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
color: dto.color,
|
||||
id: dto.id,
|
||||
isDefault: dto.is_default,
|
||||
label: dto.label,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBetSelection(dto: BetSelectionDto) {
|
||||
return {
|
||||
amount: dto.amount,
|
||||
cellId: dto.cell_id,
|
||||
chipId: dto.chip_id,
|
||||
id: dto.id,
|
||||
placedAt: dto.placed_at,
|
||||
source: dto.source,
|
||||
} satisfies BetSelection
|
||||
}
|
||||
|
||||
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
|
||||
return {
|
||||
bettingClosesAt: dto.betting_closes_at,
|
||||
id: dto.id,
|
||||
phase: dto.phase,
|
||||
revealingAt: dto.revealing_at,
|
||||
settledAt: dto.settled_at,
|
||||
startedAt: dto.started_at,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(dto: HistoryEntryDto) {
|
||||
return {
|
||||
payoutMultiplier: dto.payout_multiplier,
|
||||
roundId: dto.round_id,
|
||||
settledAt: dto.settled_at,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
winningCellId: dto.winning_cell_id,
|
||||
} satisfies HistoryEntry
|
||||
}
|
||||
|
||||
function normalizeTrendEntry(dto: TrendEntryDto) {
|
||||
return {
|
||||
cellId: dto.cell_id,
|
||||
currentStreak: dto.current_streak,
|
||||
direction: dto.direction,
|
||||
hitCount: dto.hit_count,
|
||||
lastHitRoundId: dto.last_hit_round_id,
|
||||
missCount: dto.miss_count,
|
||||
} satisfies TrendEntry
|
||||
}
|
||||
|
||||
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
|
||||
return {
|
||||
activeAnnouncementId: dto.active_announcement_id,
|
||||
items: dto.items.map(
|
||||
(item) =>
|
||||
({
|
||||
createdAt: item.created_at,
|
||||
expiresAt: item.expires_at,
|
||||
id: item.id,
|
||||
isPinned: item.is_pinned,
|
||||
isRead: item.is_read,
|
||||
message: item.message,
|
||||
title: item.title,
|
||||
tone: item.tone,
|
||||
}) satisfies AnnouncementItem,
|
||||
),
|
||||
lastUpdatedAt: dto.last_updated_at,
|
||||
} satisfies AnnouncementState
|
||||
}
|
||||
|
||||
function normalizeDashboardState(dto: DashboardStateDto) {
|
||||
return {
|
||||
countdownMs: dto.countdown_ms,
|
||||
featuredCellId: dto.featured_cell_id,
|
||||
onlinePlayers: dto.online_players,
|
||||
tableLimitMax: dto.table_limit_max,
|
||||
tableLimitMin: dto.table_limit_min,
|
||||
totalPoolAmount: dto.total_pool_amount,
|
||||
updatedAt: dto.updated_at,
|
||||
} satisfies DashboardState
|
||||
}
|
||||
|
||||
function normalizeConnectionState(dto: ConnectionStateDto) {
|
||||
return {
|
||||
connectedAt: dto.connected_at,
|
||||
lastError: dto.last_error,
|
||||
lastMessageAt: dto.last_message_at,
|
||||
latencyMs: dto.latency_ms,
|
||||
reconnectAttempt: dto.reconnect_attempt,
|
||||
status: dto.status,
|
||||
transport: dto.transport,
|
||||
} satisfies ConnectionState
|
||||
}
|
||||
|
||||
function toIsoFromUnixSeconds(seconds: number) {
|
||||
const timestamp = Number(seconds)
|
||||
const date = new Date(timestamp * 1000)
|
||||
|
||||
if (!Number.isFinite(timestamp) || Number.isNaN(date.valueOf())) {
|
||||
throw new ApiError({
|
||||
data: { seconds },
|
||||
message: 'Invalid unix timestamp',
|
||||
})
|
||||
}
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
export function normalizeLobbyRoundPhase(
|
||||
status: GameLobbyPeriodDto['status'],
|
||||
runtimeEnabled: boolean,
|
||||
): RoundPhase {
|
||||
if (!runtimeEnabled && status === 'betting') {
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'betting':
|
||||
return 'betting'
|
||||
case 'locked':
|
||||
return 'locked'
|
||||
case 'settling':
|
||||
return 'revealing'
|
||||
case 'payouting':
|
||||
case 'finished':
|
||||
case 'void':
|
||||
return 'settled'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLobbyChips(
|
||||
chips: Record<string, string>,
|
||||
defaultBetChipId: number,
|
||||
) {
|
||||
return Object.entries(chips)
|
||||
.sort(([leftId], [rightId]) => Number(leftId) - Number(rightId))
|
||||
.map(([chipId, chipAmount], index) => {
|
||||
const amount = Number(chipAmount)
|
||||
|
||||
return {
|
||||
amount: Number.isFinite(amount) ? amount : 0,
|
||||
color:
|
||||
DEFAULT_GAME_CHIP_COLORS[index % DEFAULT_GAME_CHIP_COLORS.length] ??
|
||||
DEFAULT_GAME_CHIP_COLORS[0],
|
||||
id: `chip-${chipId}`,
|
||||
isDefault: Number(chipId) === defaultBetChipId,
|
||||
label: chipAmount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) {
|
||||
return [...dictionary]
|
||||
.sort((left, right) => left.number - right.number)
|
||||
.map(
|
||||
(item, index) =>
|
||||
({
|
||||
column: (index % GAME_GRID_COLUMNS) + 1,
|
||||
id: item.number,
|
||||
label: item.name,
|
||||
odds: 36,
|
||||
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
|
||||
}) satisfies GameCell,
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePeriodTickRound(
|
||||
period: GamePeriodTickDto,
|
||||
previousRound?: Pick<RoundSnapshot, 'id' | 'startedAt'> | null,
|
||||
) {
|
||||
const startedAt =
|
||||
previousRound?.id === period.period_no
|
||||
? previousRound.startedAt
|
||||
: toIsoFromUnixSeconds(period.server_time)
|
||||
const countdownSeconds = Math.max(0, period.countdown)
|
||||
const betCloseSeconds = Math.max(0, period.bet_close_in)
|
||||
const phase = normalizeLobbyRoundPhase(period.status, period.runtime_enabled)
|
||||
const nextPhaseAt = toIsoFromUnixSeconds(
|
||||
period.server_time + countdownSeconds,
|
||||
)
|
||||
|
||||
return {
|
||||
bettingClosesAt: toIsoFromUnixSeconds(period.server_time + betCloseSeconds),
|
||||
id: period.period_no,
|
||||
phase,
|
||||
revealingAt: nextPhaseAt,
|
||||
settledAt: nextPhaseAt,
|
||||
startedAt,
|
||||
winningCellId:
|
||||
typeof period.result_number === 'number' ? period.result_number : null,
|
||||
} satisfies RoundSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
|
||||
const baseIso = toIsoFromUnixSeconds(dto.server_time)
|
||||
const template = createEmptyGameBootstrapSnapshot(baseIso)
|
||||
const cells = normalizeLobbyCells(dto.dictionary)
|
||||
const chips = normalizeLobbyChips(
|
||||
dto.bet_config.chips,
|
||||
dto.bet_config.default_bet_chip_id,
|
||||
)
|
||||
const trends = deriveTrendEntries([])
|
||||
|
||||
return {
|
||||
announcements: {
|
||||
activeAnnouncementId: null,
|
||||
items: [],
|
||||
lastUpdatedAt: null,
|
||||
} satisfies AnnouncementState,
|
||||
cells,
|
||||
chips: chips.length > 0 ? chips : template.chips,
|
||||
connection: {
|
||||
...template.connection,
|
||||
transport: 'polling',
|
||||
},
|
||||
dashboard: {
|
||||
countdownMs: 0,
|
||||
featuredCellId: null,
|
||||
onlinePlayers: 0,
|
||||
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
|
||||
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,
|
||||
totalPoolAmount: 0,
|
||||
updatedAt: baseIso,
|
||||
} satisfies DashboardState,
|
||||
history: [],
|
||||
maxSelectionCount:
|
||||
Number.isFinite(dto.bet_config.pick_max_number_count) &&
|
||||
dto.bet_config.pick_max_number_count > 0
|
||||
? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count))
|
||||
: GAME_MAX_SELECTION_CELLS,
|
||||
round: template.round,
|
||||
selections: [],
|
||||
trends,
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
|
||||
return {
|
||||
announcements: normalizeAnnouncementState(dto.announcements),
|
||||
cells: dto.cells.map(normalizeGameCell),
|
||||
chips: dto.chips.map(normalizeChip),
|
||||
connection: normalizeConnectionState(dto.connection),
|
||||
dashboard: normalizeDashboardState(dto.dashboard),
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
maxSelectionCount:
|
||||
typeof dto.max_selection_count === 'number' &&
|
||||
Number.isFinite(dto.max_selection_count) &&
|
||||
dto.max_selection_count > 0
|
||||
? Math.min(36, Math.floor(dto.max_selection_count))
|
||||
: GAME_MAX_SELECTION_CELLS,
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies GameBootstrapSnapshot
|
||||
}
|
||||
|
||||
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
|
||||
return {
|
||||
history: dto.history.map(normalizeHistoryEntry),
|
||||
round: normalizeRoundSnapshot(dto.round),
|
||||
selections: dto.selections.map(normalizeBetSelection),
|
||||
trends: dto.trends.map(normalizeTrendEntry),
|
||||
} satisfies Pick<
|
||||
GameBootstrapSnapshot,
|
||||
'history' | 'round' | 'selections' | 'trends'
|
||||
>
|
||||
}
|
||||
|
||||
export async function getGameBootstrap() {
|
||||
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameBootstrapDto>,
|
||||
'Failed to load game bootstrap',
|
||||
)
|
||||
|
||||
return normalizeGameBootstrap(dto)
|
||||
}
|
||||
|
||||
export async function getGameRoundFeed() {
|
||||
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameRoundFeedDto>,
|
||||
'Failed to load game round feed',
|
||||
)
|
||||
|
||||
return normalizeGameRoundFeed(dto)
|
||||
}
|
||||
|
||||
export async function getGameAnnouncements() {
|
||||
const response = await api.get<GameAnnouncementsDto>(
|
||||
GAME_API_ENDPOINTS.announcements,
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameAnnouncementsDto>,
|
||||
'Failed to load game announcements',
|
||||
)
|
||||
|
||||
return normalizeAnnouncementState(dto.announcements)
|
||||
}
|
||||
|
||||
export async function getGameLobbyInit() {
|
||||
const response = await api.post<GameLobbyInitDto>(
|
||||
GAME_API_ENDPOINTS.lobbyInit,
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameLobbyInitDto>,
|
||||
'Failed to load game lobby init',
|
||||
)
|
||||
assertLobbyInitDto(dto)
|
||||
|
||||
return {
|
||||
runtimeEnabled: dto.runtime_enabled,
|
||||
serverTime: dto.server_time,
|
||||
snapshot: normalizeGameLobbyInit(dto),
|
||||
userSnapshot: dto.user_snapshot,
|
||||
} satisfies GameLobbyInitResult
|
||||
}
|
||||
|
||||
export async function getNoticeList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.get<NoticeListDto>(GAME_API_ENDPOINTS.noticeList, {
|
||||
searchParams: {
|
||||
page: String(params?.page ?? 1),
|
||||
page_size: String(params?.pageSize ?? DEFAULT_LIST_PAGE_SIZE),
|
||||
},
|
||||
})
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeListDto>,
|
||||
'Failed to load notice list',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getNoticeDetail(id: number) {
|
||||
const response = await api.get<NoticeDetailDto>(
|
||||
GAME_API_ENDPOINTS.noticeDetail,
|
||||
{
|
||||
searchParams: {
|
||||
notice_id: String(id),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeDetailDto>,
|
||||
'Failed to load notice detail',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function confirmNotice(noticeId: number) {
|
||||
const response = await api.get<NoticeConfirmDto>(
|
||||
GAME_API_ENDPOINTS.noticeConfirm,
|
||||
{
|
||||
searchParams: {
|
||||
notice_id: String(noticeId),
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<NoticeConfirmDto>,
|
||||
'Failed to confirm notice',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function getGameBetMyOrders(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
const response = await api.post<GameBetOrdersDto>(
|
||||
GAME_API_ENDPOINTS.betMyOrders,
|
||||
{
|
||||
json: {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.pageSize ?? DEFAULT_LIST_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GameBetOrdersDto>,
|
||||
'Failed to load bet orders',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
export async function placeGameBet(payload: GamePlaceBetRequestDto) {
|
||||
const response = await api.post<GamePlaceBetDto>(
|
||||
GAME_API_ENDPOINTS.placeBet,
|
||||
{
|
||||
json: payload,
|
||||
},
|
||||
)
|
||||
const dto = unwrapGameEnvelope(
|
||||
response as ApiResponse<GamePlaceBetDto>,
|
||||
'Failed to place game bet',
|
||||
)
|
||||
|
||||
return dto
|
||||
}
|
||||
4
src/api/index.ts
Normal file
4
src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth-api'
|
||||
export * from './finance-api'
|
||||
export * from './game-api'
|
||||
export * from './period-history-api'
|
||||
41
src/api/period-history-api.ts
Normal file
41
src/api/period-history-api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
|
||||
import { api } from '@/lib/api/api-client'
|
||||
import { ApiError } from '@/lib/api/api-error'
|
||||
import type { ApiResponse, GamePeriodHistoryItemDto } from '@/type'
|
||||
|
||||
interface GamePeriodHistoryDto {
|
||||
list: GamePeriodHistoryItemDto[]
|
||||
}
|
||||
|
||||
function unwrapPeriodHistoryEnvelope(
|
||||
response: ApiResponse<GamePeriodHistoryDto>,
|
||||
) {
|
||||
if (response.code === API_SUCCESS_CODE) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new ApiError({
|
||||
data: response,
|
||||
message:
|
||||
typeof response.msg === 'string' && response.msg.length > 0
|
||||
? response.msg
|
||||
: typeof response.message === 'string' && response.message.length > 0
|
||||
? response.message
|
||||
: 'Failed to load period history',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGamePeriodHistory(params: { limit?: number } = {}) {
|
||||
const response = await api.get<GamePeriodHistoryDto>(
|
||||
GAME_API_ENDPOINTS.periodHistory,
|
||||
{
|
||||
searchParams: {
|
||||
limit: String(params.limit ?? 30),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return unwrapPeriodHistoryEnvelope(
|
||||
response as ApiResponse<GamePeriodHistoryDto>,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user