feat: 增强钱包 API 与玩家会话管理

- 新增钱包 API 函数:getWalletLogs(获取钱包日志)、postWalletTransferIn(充值)及 postWalletTransferOut(提现)
- 更新钱包相关类型定义,提升类型安全性
- 改进玩家会话管理:若当前无玩家资料,则自动拉取玩家信息
- 增强入口网关对过期会话的错误处理能力
- 更新 UI 组件,以适配新的结构与功能
This commit is contained in:
2026-05-09 15:22:08 +08:00
parent 14c297fe1a
commit 7743c14e83
28 changed files with 1719 additions and 33 deletions

View File

@@ -1,4 +1,16 @@
export { API_V1_PREFIX } from "@/api/paths";
export { getHealth } from "@/api/health";
export { getPlayerPing, getPlayerMe } from "@/api/player";
export { getWalletBalance, type GetWalletBalanceParams } from "@/api/wallet";
export {
getWalletBalance,
getWalletLogs,
postWalletTransferIn,
postWalletTransferOut,
type GetWalletBalanceParams,
type GetWalletLogsParams,
type WalletLogItem,
type WalletLogsData,
type WalletPendingTransfer,
type WalletTransferBody,
type WalletTransferResultData,
} from "@/api/wallet";

View File

@@ -1,7 +1,11 @@
import { lotteryRequest } from "@/lib/lottery-http";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import { API_V1_PREFIX } from "@/api/paths";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import type {
WalletTransferBody,
WalletTransferResultData,
} from "@/types/api/wallet-transfer";
import type { GetWalletLogsParams, WalletLogsData } from "@/types/api/wallet-logs";
export type GetWalletBalanceParams = {
/** Query `currency`,不传则用玩家默认币种 */
@@ -17,3 +21,41 @@ export function getWalletBalance(
{ params },
);
}
/** `GET /api/v1/wallet/logs`(需玩家 Token */
export function getWalletLogs(
params?: GetWalletLogsParams,
): Promise<WalletLogsData> {
return lotteryRequest.get<WalletLogsData>(
`${API_V1_PREFIX}/wallet/logs`,
{ params },
);
}
/** `POST /api/v1/wallet/transfer-in`(主站 → 彩票) */
export function postWalletTransferIn(
body: WalletTransferBody,
): Promise<WalletTransferResultData> {
return lotteryRequest.post<WalletTransferResultData>(
`${API_V1_PREFIX}/wallet/transfer-in`,
body,
);
}
/** `POST /api/v1/wallet/transfer-out`(彩票 → 主站) */
export function postWalletTransferOut(
body: WalletTransferBody,
): Promise<WalletTransferResultData> {
return lotteryRequest.post<WalletTransferResultData>(
`${API_V1_PREFIX}/wallet/transfer-out`,
body,
);
}
export type { WalletTransferBody, WalletTransferResultData } from "@/types/api/wallet-transfer";
export type {
GetWalletLogsParams,
WalletLogItem,
WalletLogsData,
WalletPendingTransfer,
} from "@/types/api/wallet-logs";

View File

@@ -1,24 +1,5 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { HallScreen } from "@/features/hall/hall-screen";
/** 下注大厅占位§4.2 再接表格与期号) */
export default function LotteryHallPlaceholderPage() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
</CardContent>
</Card>
);
export default function LotteryHallPage() {
return <HallScreen />;
}

View File

@@ -0,0 +1,5 @@
import { WalletLogsScreen } from "@/features/wallet/wallet-logs-screen";
export default function WalletLogsPage() {
return <WalletLogsScreen />;
}

View File

@@ -0,0 +1,6 @@
import { WalletScreen } from "@/features/wallet/wallet-screen";
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */
export default function WalletPage() {
return <WalletScreen />;
}

View File

@@ -0,0 +1,5 @@
import { TransferInScreen } from "@/features/wallet/transfer-in-screen";
export default function WalletTransferInPage() {
return <TransferInScreen />;
}

View File

@@ -0,0 +1,5 @@
import { TransferOutScreen } from "@/features/wallet/transfer-out-screen";
export default function WalletTransferOutPage() {
return <TransferOutScreen />;
}

View File

@@ -1,5 +1,8 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { PlayerSessionBar } from "@/features/player/player-session-bar";
type PlayerAppShellProps = {
children: ReactNode;
};
@@ -12,8 +15,27 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
return (
<div className="flex min-h-full flex-col bg-background text-foreground">
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex h-12 max-w-lg items-center px-4">
<span className="text-sm font-semibold tracking-tight">Lottery</span>
<div className="mx-auto flex h-12 max-w-lg items-center justify-between gap-2 px-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0 text-sm font-semibold tracking-tight">
Lottery
</span>
<PlayerSessionBar className="min-w-0 border-l border-border pl-2" />
</div>
<nav className="flex shrink-0 items-center gap-2.5 text-xs font-medium sm:gap-3">
<Link
href="/hall"
className="text-muted-foreground transition-colors hover:text-foreground"
>
</Link>
<Link
href="/wallet"
className="text-muted-foreground transition-colors hover:text-foreground"
>
</Link>
</nav>
</div>
</header>
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 py-4">

View File

@@ -0,0 +1,34 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
/**
* 下注大厅:顶部钱包条对齐高保真稿;以下为期号/表格占位。
*/
export function HallScreen() {
return (
<div className="flex flex-col gap-5">
<HallWalletStrip />
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
Issue No.2D/3D/4D Submit Bet §4.2
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
WebSocket PRD §2
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getWalletBalance } from "@/api/wallet";
import { buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
import { formatMinorAsCurrency } from "@/lib/money";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
/**
* 高保真稿:大厅顶部红卡 + Transfer In/ Transfer Out白底红边§4.2
*/
export function HallWalletStrip() {
const profile = usePlayerSessionStore((s) => s.profile);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
const currency = useMemo(
() =>
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
[balance?.currency_code, profile?.default_currency],
);
const refresh = useCallback(async () => {
const b = await getWalletBalance();
setBalance(b);
}, []);
useEffect(() => {
let c = false;
void (async () => {
setLoading(true);
try {
await refresh();
} finally {
if (!c) setLoading(false);
}
})();
return () => {
c = true;
};
}, [refresh]);
const lotteryMinor = Number(balance?.balance ?? 0);
const availableMinor = Number(balance?.available_balance ?? 0);
return (
<section className="space-y-2" aria-label="Wallet balance">
<div
className={cn(
"relative overflow-hidden rounded-2xl bg-gradient-to-br from-[#dc2626] to-[#991b1b] px-4 py-3.5 text-white shadow-md",
"ring-1 ring-black/10",
)}
>
<div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-white/15">
<Wallet className="size-6 text-white" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
Wallet Balance
</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-40 rounded-md bg-white/20" />
) : (
<p className="font-heading text-2xl font-bold tabular-nums tracking-tight">
{formatMinorAsCurrency(lotteryMinor, currency)}
</p>
)}
</div>
<Link
href="/wallet"
className={cn(
buttonVariants({
variant: "secondary",
size: "sm",
}),
"shrink-0 border-0 bg-white/15 text-xs text-white hover:bg-white/25",
)}
>
</Link>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<TransferInDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer In"
triggerClassName="w-full min-w-0"
currency={currency}
lotteryMinor={lotteryMinor}
onSuccess={refresh}
/>
<TransferOutDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer Out"
triggerClassName="w-full min-w-0"
currency={currency}
availableMinor={availableMinor}
onSuccess={refresh}
/>
</div>
</section>
);
}

View File

@@ -139,7 +139,12 @@ export function EntryGate(): ReactNode {
if (!token) {
setPhase("error");
setErrorMessage("缺少登录凭证,请从主站重新进入彩票系统。");
const sessionFlag = searchParams.get("session");
setErrorMessage(
sessionFlag === "expired"
? "登录已失效,请从主站重新进入彩票系统。"
: "缺少登录凭证,请从主站重新进入彩票系统。",
);
updateStep("token", "pending");
applyProgress(0);
return;

View File

@@ -2,17 +2,32 @@
import { useEffect } from "react";
import { getPlayerMe } from "@/api/player";
import { usePlayerSessionStore } from "@/stores/player-session-store";
/** 从 sessionStorage 恢复 Bearer避免 `/hall` 等子路由刷新后丢失鉴权头 */
/**
* 从 sessionStorage 恢复 Bearer避免 `/hall` 等子路由刷新后丢失鉴权头;
* 若有 Token 无 `profile`,补拉 `GET /player/me` 供顶栏展示。
*/
export function HydratePlayerAuth(): null {
const restoreBearerToken = usePlayerSessionStore(
(state) => state.restoreBearerToken,
);
const setProfile = usePlayerSessionStore((state) => state.setProfile);
useEffect(() => {
restoreBearerToken();
}, [restoreBearerToken]);
const token = restoreBearerToken();
if (!token) return;
if (usePlayerSessionStore.getState().profile !== null) return;
void (async () => {
try {
const me = await getPlayerMe();
setProfile(me);
} catch {
/* 401 由 lottery-http 拦截跳转 */
}
})();
}, [restoreBearerToken, setProfile]);
return null;
}

View File

@@ -0,0 +1,47 @@
"use client";
import { UserRound } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
/**
* 顶栏:当前玩家称呼 + 默认币种(依赖入口或 Hydrate 拉取的 profile
*/
export function PlayerSessionBar({ className }: { className?: string }) {
const profile = usePlayerSessionStore((s) => s.profile);
const label =
profile?.nickname?.trim() ||
profile?.username?.trim() ||
(profile?.id != null ? `玩家 #${profile.id}` : null);
return (
<div
className={cn(
"flex min-w-0 max-w-[55%] items-center gap-1.5 sm:max-w-[60%]",
className,
)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted">
<UserRound className="size-3.5 text-muted-foreground" aria-hidden />
</span>
<div className="min-w-0 leading-tight">
<p className="truncate text-xs font-medium text-foreground">
{label ?? "…"}
</p>
{profile?.default_currency ? (
<p className="truncate text-[10px] text-muted-foreground">
{profile.default_currency.toUpperCase()}
{profile.locale ? (
<span className="text-muted-foreground/80">
{" "}
· {profile.locale}
</span>
) : null}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getWalletBalance } from "@/api/wallet";
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
/** 独立路由 `/wallet/transfer-in` */
export function TransferInScreen() {
const router = useRouter();
const profile = usePlayerSessionStore((s) => s.profile);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
const currency = useMemo(
() =>
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
[balance?.currency_code, profile?.default_currency],
);
const load = useCallback(async () => {
const b = await getWalletBalance();
setBalance(b);
}, []);
useEffect(() => {
let c = false;
void (async () => {
try {
await load();
} finally {
if (!c) setLoading(false);
}
})();
return () => {
c = true;
};
}, [load]);
const onSuccess = useCallback(async () => {
await load();
router.push("/wallet");
}, [load, router]);
if (loading && !balance) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
return (
<TransferInPage
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={onSuccess}
/>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getWalletBalance } from "@/api/wallet";
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
/** 独立路由 `/wallet/transfer-out` */
export function TransferOutScreen() {
const router = useRouter();
const profile = usePlayerSessionStore((s) => s.profile);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
const currency = useMemo(
() =>
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
[balance?.currency_code, profile?.default_currency],
);
const load = useCallback(async () => {
const b = await getWalletBalance();
setBalance(b);
}, []);
useEffect(() => {
let c = false;
void (async () => {
try {
await load();
} finally {
if (!c) setLoading(false);
}
})();
return () => {
c = true;
};
}, [load]);
const onSuccess = useCallback(async () => {
await load();
router.push("/wallet");
}, [load, router]);
if (loading && !balance) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
return (
<TransferOutPage
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={onSuccess}
/>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatMinorAsCurrency } from "@/lib/money";
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
{ value: "", label: "全部" },
{ value: "transfer_in", label: "转入" },
{ value: "transfer_out", label: "转出" },
{ value: "bet", label: "下注扣款" },
{ value: "prize", label: "派彩" },
{ value: "refund", label: "退本/冲正" },
];
export function logTypeLabel(t: string): string {
const map: Record<string, string> = {
transfer_in: "转入",
transfer_out: "转出",
refund: "退本/冲正",
bet: "下注扣款",
prize: "派彩",
};
return map[t] ?? t;
}
function txnStatusLabel(status: string): string {
if (status === "posted") return "成功";
if (status === "pending_reconcile") return "待对账";
return status;
}
type WalletLogsBlockProps = {
logs: WalletLogsData | null;
logsLoading: boolean;
filter: string;
onFilterChange: (value: string) => void;
currency: string;
/** 独立流水页可隐藏标题或改文案 */
title?: string;
};
/** 待对账 + 类型筛选 + 列表(供钱包主页与 `/wallet/logs` 共用) */
export function WalletLogsBlock({
logs,
logsLoading,
filter,
onFilterChange,
currency,
title = "资金流水",
}: WalletLogsBlockProps) {
return (
<>
{logs && logs.pending_reconcile.length > 0 ? (
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[#d48806]"></CardTitle>
<CardDescription className="text-xs">
§4.10
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{logs.pending_reconcile.map((p) => (
<div
key={p.transfer_no}
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-border py-2 last:border-0"
>
<span className="text-muted-foreground">
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
{formatMinorAsCurrency(p.amount, p.currency_code)}
</span>
<span className="text-xs text-amber-700"></span>
</div>
))}
</CardContent>
</Card>
) : null}
<section className="space-y-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-medium">{title}</h2>
<div className="flex flex-wrap gap-1.5">
{WALLET_FLOW_FILTERS.map((f) => (
<Button
key={f.value || "all"}
type="button"
size="sm"
variant={filter === f.value ? "default" : "outline"}
className="h-8 rounded-full text-xs"
onClick={() => onFilterChange(f.value)}
>
{f.label}
</Button>
))}
</div>
</div>
{logsLoading && !logs ? (
<Skeleton className="h-40 w-full rounded-lg" />
) : null}
{logs ? (
<>
<p className="text-xs text-muted-foreground">
{logs.total}
</p>
<ul className="space-y-2">
{logs.items.length === 0 ? (
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
</li>
) : (
logs.items.map((row) => (
<LogRow key={row.log_id} item={row} currency={currency} />
))
)}
</ul>
</>
) : null}
</section>
</>
);
}
export function LogRow({
item,
currency,
}: {
item: WalletLogItem;
currency: string;
}) {
const ccy = item.currency_code || currency;
const isIn = item.direction === "in";
return (
<li className="rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm">
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-medium">
{logTypeLabel(item.type)}{" "}
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
{isIn ? "+" : ""}
{formatMinorAsCurrency(item.amount_abs, ccy)}
</span>
</span>
<p className="mt-1 text-xs text-muted-foreground">
{item.created_at?.replace("T", " ").slice(0, 19) ?? "—"}{" "}
<span className="text-foreground/80">
· {txnStatusLabel(item.status)}
</span>
</p>
{item.ref_id ? (
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground">
{item.ref_id}
</p>
) : null}
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
/** 独立路由 `/wallet/logs` */
export function WalletLogsScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currency = useMemo(
() => (profile?.default_currency ?? "NPR").toUpperCase(),
[profile?.default_currency],
);
const fetchPassRef = useRef(true);
const load = useCallback(async () => {
setError(null);
if (fetchPassRef.current) {
setLoading(true);
fetchPassRef.current = false;
} else {
setLogsLoading(true);
}
try {
const L = await getWalletLogs({
page: 1,
size: 50,
type: filter || undefined,
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
} finally {
setLoading(false);
setLogsLoading(false);
}
}, [filter]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-lg font-semibold tracking-tight"></h1>
<Link
href="/wallet"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void load()}>
</Button>
</CardContent>
</Card>
) : null}
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
title="类型筛选"
/>
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
export function WalletScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currency = useMemo(() => {
return (
balance?.currency_code ??
profile?.default_currency ??
"NPR"
).toUpperCase();
}, [balance?.currency_code, profile?.default_currency]);
const fetchPassRef = useRef(true);
useEffect(() => {
let cancelled = false;
void (async () => {
setError(null);
if (fetchPassRef.current) {
setLoading(true);
fetchPassRef.current = false;
} else {
setLogsLoading(true);
}
try {
const b = await getWalletBalance();
if (cancelled) return;
setBalance(b);
const L = await getWalletLogs({
page: 1,
size: 50,
type: filter || undefined,
});
if (cancelled) return;
setLogs(L);
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e));
}
} finally {
if (!cancelled) {
setLoading(false);
setLogsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [filter]);
const refreshAll = useCallback(async () => {
setError(null);
setLogsLoading(true);
try {
const b = await getWalletBalance();
setBalance(b);
const L = await getWalletLogs({
page: 1,
size: 50,
type: filter || undefined,
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
} finally {
setLogsLoading(false);
setLoading(false);
}
}, [filter]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight"></h1>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/wallet/transfer-in"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
<Link
href="/wallet/transfer-out"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
<Link
href="/wallet/logs"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
</div>
</div>
<Link
href="/hall"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"shrink-0 self-start",
)}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void refreshAll()}>
</Button>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Wallet className="size-5 opacity-80" aria-hidden />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<Skeleton className="h-12 w-full max-w-xs rounded-lg" />
) : (
<>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-heading text-2xl font-semibold tabular-nums text-[#52c41a]">
{formatMinorAsCurrency(
balance?.balance ?? 0,
currency,
)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{formatMinorAsCurrency(
balance?.available_balance ?? 0,
currency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{" "}
<span className="font-medium text-foreground">
{balance?.main_balance == null
? "—(待接入主站)"
: formatMinorAsCurrency(balance.main_balance, currency)}
</span>
</div>
</>
)}
<div className="flex gap-2">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
</div>
</CardContent>
</Card>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
/>
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
TransferInPanel,
TransferOutPanel,
} from "@/features/wallet/wallet-transfer-forms";
import { cn } from "@/lib/utils";
type BaseProps = {
currency: string;
onSuccess: () => Promise<void>;
/** 避免同页多实例 input id 冲突 */
idPrefix?: string;
};
const defaultInTrigger =
"!bg-[#52c41a] !text-white shadow-none hover:!bg-[#52c41a]/90";
const defaultOutTrigger = "flex-1";
/** 高保真稿:蓝底白字 */
const hallInTrigger =
"rounded-lg border-0 !bg-[#1677ff] !text-white shadow-sm hover:!bg-[#1677ff]/90";
/** 高保真稿:白底红框红字 */
const hallOutTrigger =
"rounded-lg !border-2 !border-[#ff4d4f] !bg-white !text-[#ff4d4f] shadow-sm hover:!bg-red-50 dark:!bg-card dark:hover:!bg-red-950/30";
export function TransferInDialog({
currency,
lotteryMinor,
onSuccess,
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转入",
}: BaseProps & {
lotteryMinor: number;
triggerClassName?: string;
triggerVariant?: "wallet" | "hall";
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
triggerVariant === "hall" ? hallInTrigger : defaultInTrigger,
triggerClassName,
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button
type="button"
variant="ghost"
className={triggerCombined}
onClick={() => setOpen(true)}
>
<ArrowDownLeft className="size-4 shrink-0" />
{triggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
1.00{" "}
{currency}
</DialogDescription>
</DialogHeader>
<TransferInPanel
variant="dialog"
currency={currency}
lotteryMinor={lotteryMinor}
idPrefix={idPrefix}
onCancel={() => setOpen(false)}
onSuccess={async () => {
await onSuccess();
setOpen(false);
}}
/>
</DialogContent>
</Dialog>
);
}
export function TransferOutDialog({
currency,
availableMinor,
onSuccess,
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转出",
}: BaseProps & {
availableMinor: number;
triggerClassName?: string;
triggerVariant?: "wallet" | "hall";
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
triggerVariant === "hall"
? hallOutTrigger
: cn(
"!border-2 !border-input !bg-secondary !text-secondary-foreground hover:!bg-secondary/80",
defaultOutTrigger,
),
triggerClassName,
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button
type="button"
variant="ghost"
className={triggerCombined}
onClick={() => setOpen(true)}
>
<ArrowUpRight className="size-4 shrink-0" />
{triggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<TransferOutPanel
variant="dialog"
currency={currency}
availableMinor={availableMinor}
idPrefix={idPrefix}
onCancel={() => setOpen(false)}
onSuccess={async () => {
await onSuccess();
setOpen(false);
}}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,414 @@
"use client";
import { isAxiosError } from "axios";
import { ChevronLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { LotteryApiBizError } from "@/types/api/errors";
/** 处理中 / 待对账:刷新数据后提示用文案即可 */
async function handleTransferMaybePending(
e: unknown,
onRefresh: () => Promise<void>,
): Promise<boolean> {
if (e instanceof LotteryApiBizError && e.code === 1002) {
toast.message(e.message || "处理中…");
await onRefresh();
return true;
}
if (isAxiosError(e) && e.response?.status === 409) {
toast.message("转账处理中,请稍后刷新。");
await onRefresh();
return true;
}
return false;
}
type PanelBase = {
currency: string;
idPrefix?: string;
/** 提交成功后刷新余额等 */
onSuccess: () => Promise<void>;
};
type PanelVariant = "dialog" | "page";
/** 弹窗内:取消关闭;独立页:仅展示提交(返回用顶栏或上方链接) */
export function TransferInPanel({
currency,
lotteryMinor,
onSuccess,
idPrefix = "",
onCancel,
variant = "dialog",
}: PanelBase & {
lotteryMinor: number;
onCancel: () => void;
variant?: PanelVariant;
}) {
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const tid = `${idPrefix}in-amount`;
const parsedMinor = useMemo(
() => parseDecimalInputToMinor(amountText),
[amountText],
);
const previewAfter =
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
return;
}
setSubmitting(true);
try {
await postWalletTransferIn({
amount: parsedMinor,
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转入成功,彩票钱包余额已更新。");
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
return;
}
setLocalError(formatWalletClientError(e));
} finally {
setSubmitting(false);
}
};
const footer =
variant === "page" ? (
<Button
type="button"
className="w-full"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
</>
) : (
"确认转入"
)}
</Button>
) : (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={onCancel}
>
</Button>
<Button
type="button"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
</>
) : (
"确认转入"
)}
</Button>
</div>
);
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<p>
:{" "}
<span className="text-muted-foreground"></span>
</p>
<p className="mt-1">
:{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(lotteryMinor, currency)}
</span>
</p>
</div>
<div className="grid gap-2">
<Label htmlFor={tid}></Label>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 1000.00"
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
</p>
</div>
{localError ? (
<p className="text-sm text-[#ff4d4f]">{localError}</p>
) : null}
</div>
{footer}
</>
);
}
export function TransferOutPanel({
currency,
availableMinor,
onSuccess,
idPrefix = "",
onCancel,
variant = "dialog",
}: PanelBase & {
availableMinor: number;
onCancel: () => void;
variant?: PanelVariant;
}) {
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const tid = `${idPrefix}out-amount`;
const parsedMinor = useMemo(
() => parseDecimalInputToMinor(amountText),
[amountText],
);
const previewAfter =
parsedMinor != null
? Math.max(0, availableMinor - parsedMinor)
: availableMinor;
const fillAll = () => {
const major = availableMinor / 100;
setAmountText(major.toFixed(2));
};
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
return;
}
if (parsedMinor > availableMinor) {
setLocalError("转出金额不能超过可用余额。");
return;
}
setSubmitting(true);
try {
await postWalletTransferOut({
amount: parsedMinor,
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转出成功,资金将返回主站钱包。");
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
return;
}
setLocalError(formatWalletClientError(e));
} finally {
setSubmitting(false);
}
};
const footer =
variant === "page" ? (
<Button
type="button"
className="w-full"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
</>
) : (
"确认转出"
)}
</Button>
) : (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={onCancel}
>
</Button>
<Button
type="button"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
</>
) : (
"确认转出"
)}
</Button>
</div>
);
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<p>
:{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(availableMinor, currency)}
</span>
</p>
</div>
<div className="grid gap-2">
<div className="flex items-end justify-between gap-2">
<Label htmlFor={tid}></Label>
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs"
onClick={fillAll}
disabled={submitting || availableMinor < 1}
>
{" "}
{formatMinorAsCurrency(availableMinor, currency)}
</Button>
</div>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 500.00"
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
</p>
</div>
{localError ? (
<p className="text-sm text-[#ff4d4f]">{localError}</p>
) : null}
</div>
{footer}
</>
);
}
/** 独立路由页:转入(仅组合 Card + Panel */
export function TransferInPage({
currency,
lotteryMinor,
onSuccess,
}: PanelBase & { lotteryMinor: number }) {
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
1.00{" "}
{currency}
</CardDescription>
</CardHeader>
<CardContent>
<TransferInPanel
variant="page"
currency={currency}
lotteryMinor={lotteryMinor}
idPrefix="page-"
onSuccess={onSuccess}
onCancel={() => {}}
/>
</CardContent>
</Card>
</div>
);
}
/** 独立路由页:转出 */
export function TransferOutPage({
currency,
availableMinor,
onSuccess,
}: PanelBase & { availableMinor: number }) {
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<TransferOutPanel
variant="page"
currency={currency}
availableMinor={availableMinor}
idPrefix="page-"
onSuccess={onSuccess}
onCancel={() => {}}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -4,7 +4,8 @@ import axios, {
type AxiosResponse,
} from "axios";
import { withPlayerAuthHeader } from "@/lib/lottery-auth";
import { setPlayerBearerToken, withPlayerAuthHeader } from "@/lib/lottery-auth";
import { clearPersistedPlayerBearerToken } from "@/lib/player-session";
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
import {
LotteryApiBizError,
@@ -23,6 +24,25 @@ export const lotteryHttp = axios.create({
headers: { Accept: "application/json" },
});
/** 站内接口 401清本地会话并回入口与 {@link EntryGate} `session=expired` 衔接 */
lotteryHttp.interceptors.response.use(
(response) => response,
(error: unknown) => {
if (
isAxiosError(error) &&
error.response?.status === 401 &&
typeof window !== "undefined"
) {
clearPersistedPlayerBearerToken();
setPlayerBearerToken(null);
if (window.location.pathname !== "/") {
window.location.replace("/?session=expired");
}
}
return Promise.reject(error);
},
);
/**
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`
* `code !== 0` 抛 {@link LotteryApiBizError}。

37
src/lib/money.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* 与后端约定:金额存最小货币单位(如 NPR 2 位小数 → 分);展示时除以 10^decimals。
*/
const DEFAULT_DECIMAL_PLACES = 2;
export function formatMinorAsCurrency(
minor: number | string,
currencyCode: string,
decimalPlaces = DEFAULT_DECIMAL_PLACES,
): string {
const n = typeof minor === "string" ? Number(minor) : minor;
if (!Number.isFinite(n)) return `${currencyCode}`;
const divisor = 10 ** decimalPlaces;
const major = n / divisor;
return `${currencyCode} ${major.toLocaleString(undefined, {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
})}`;
}
/**
* 用户输入如 `1000` 或 `1000.5` → 最小货币单位整数。
*/
export function parseDecimalInputToMinor(
raw: string,
decimalPlaces = DEFAULT_DECIMAL_PLACES,
): number | null {
const cleaned = raw.replace(/,/g, "").trim();
if (cleaned === "") return null;
const n = Number(cleaned);
if (!Number.isFinite(n) || n < 0) return null;
const factor = 10 ** decimalPlaces;
const minor = Math.round(n * factor);
if (!Number.isSafeInteger(minor)) return null;
return minor;
}

View File

@@ -0,0 +1,43 @@
import { isAxiosError } from "axios";
import { LotteryApiBizError } from "@/types/api/errors";
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP */
export function formatWalletClientError(error: unknown): string {
if (error instanceof LotteryApiBizError) {
const m = WALLET_CODE_MESSAGES[error.code];
if (m) return m;
if (error.message.trim()) return error.message;
}
if (isAxiosError(error)) {
if (error.response?.status === 401) {
return "登录已失效,请返回重新进入。";
}
if (error.response?.status === 409) {
return "转账处理中,请稍后刷新;若长时间未到账请联系客服。";
}
if (!error.response) {
return "网络异常,请检查连接后重试。";
}
if (error.code === "ECONNABORTED") {
return "请求超时,请稍后重试。";
}
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return "请求失败,请稍后重试。";
}
const WALLET_CODE_MESSAGES: Record<number, string> = {
1001: "彩票钱包余额不足,请转入或减少转出金额。",
1002: "转账处理中,请稍后在本页刷新余额;若长时间未到账请联系客服。",
1003: "金额超出单笔或每日限额,请调整金额。",
1004: "当前已暂停转入,请稍后再试或联系客服。",
1005: "币种无效或未开通。",
1006: "当前已暂停转出,请稍后再试或联系客服。",
1007: "彩票钱包已冻结,暂无法划转。",
1008: "金额格式不正确。",
1009: "主站未能完成本次划转,请稍后重试。",
1010: "请勿用同一幂等键发起不同金额的转账。",
};

View File

@@ -9,3 +9,10 @@ export type { HealthData } from "./health";
export type { ScopePingData } from "./ping";
export type { PlayerMeData } from "./player-me";
export type { WalletBalanceData } from "./wallet-balance";
export type { WalletTransferBody, WalletTransferResultData } from "./wallet-transfer";
export type {
GetWalletLogsParams,
WalletLogItem,
WalletLogsData,
WalletPendingTransfer,
} from "./wallet-logs";

View File

@@ -3,8 +3,12 @@ export type PlayerMeData = {
id: number;
site_code: string;
site_player_id: string;
username: string;
username: string | null;
nickname: string | null;
default_currency: string;
status: number;
/** 当次请求解析后的界面语言zh / en / ne */
locale: string;
last_login_at: string | null;
created_at: string | null;
};

View File

@@ -2,6 +2,8 @@
export type WalletBalanceData = {
/** 最小货币单位 bigint序列化可能为 string */
balance: string | number;
/** 可用余额 = balance - frozen_balance服务端保证 ≥0 */
available_balance: string | number;
main_balance: null;
currency_code: string;
wallet_type: string;

View File

@@ -0,0 +1,47 @@
/** `GET /api/v1/wallet/logs` 单条流水 */
export type WalletLogItem = {
log_id: string;
type: string;
biz_type: string;
/** 正数为加款,负数为扣款(最小货币单位) */
amount: number;
amount_abs: number;
direction: "in" | "out";
currency_code: string;
balance_after: number;
ref_id: string | null;
idempotent_key: string | null;
external_ref_no: string | null;
status: string;
remark: string | null;
created_at: string | null;
};
/** 待对账划转主站超时等PRD §6.2 / §6.7 */
export type WalletPendingTransfer = {
transfer_no: string;
direction: string;
type: string;
currency_code: string;
amount: number;
status: string;
fail_reason: string | null;
idempotent_key: string;
created_at: string | null;
};
export type WalletLogsData = {
items: WalletLogItem[];
total: number;
page: number;
per_page: number;
pending_reconcile: WalletPendingTransfer[];
};
export type GetWalletLogsParams = {
page?: number;
/** 每页条数PRD 示例 `size` */
size?: number;
/** 逗号分隔transfer_in,transfer_out,bet,prize,refund */
type?: string;
};

View File

@@ -0,0 +1,26 @@
/** `POST /api/v1/wallet/transfer-in` | `transfer-out` 请求体 */
export type WalletTransferBody = {
/** 最小货币单位正整数 */
amount: number;
/** 客户端生成的幂等键,重复请求须携带相同键与参数 */
idempotent_key: string;
/** 不传则使用玩家 `default_currency` / 系统默认 */
currency?: string;
};
/** 转入/转出成功时 `data`(含 PRD §10.1.1 示例字段 `balance` / `log_id` */
export type WalletTransferResultData = {
transfer_no: string;
direction: "in" | "out";
currency_code: string;
amount: number;
status: string;
external_ref_no: string | null;
/** 成功后彩票钱包余额,与 `lottery_balance_after` 相同 */
balance: number;
/** 本笔对应主流水号(`wallet_txns.txn_no` */
log_id: string | null;
lottery_balance_after: number;
lottery_available_after: number;
finished_at: string | null;
};