refactor(game): 重构项目结构,优化链路, 移动端适配

- 移除 useGameBoardVm 数据层实施说明文档
- 移除核心玩法与前端规则摘要文档
- 移除游戏模块数据与界面分层第一阶段实施稿文档
- 清理与数据层重构相关的技术方案说明
- 删除关于 PC 和 Mobile 界面分离的设计规划
- 移除 view-model hooks 架构设计相关内容
This commit is contained in:
JiaJun
2026-06-03 17:21:13 +08:00
parent 3efcb3bba6
commit bfb4b76611
129 changed files with 4534 additions and 4227 deletions

233
src/api/auth-api.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
export * from './auth-api'
export * from './finance-api'
export * from './game-api'
export * from './period-history-api'

View 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>,
)
}