feat(finance): add USDT address lookup

This commit is contained in:
zhx 2026-07-15 10:39:13 +08:00
parent 2eb18502a2
commit ee900d0127
12 changed files with 449 additions and 0 deletions

View File

@ -4571,6 +4571,28 @@
"x-permissions": ["finance-order:coin-seller-recharge:grant"]
}
},
"/admin/finance/usdt-addresses/{app_code}": {
"get": {
"operationId": "getFinanceUSDTAddresses",
"parameters": [
{
"in": "path",
"name": "app_code",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/FinanceUSDTAddressesResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:view",
"x-permissions": ["finance-order:coin-seller-recharge:view"]
}
},
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
"get": {
"operationId": "getFinanceCoinSellerRechargeExchangeRate",
@ -8745,6 +8767,16 @@
}
}
},
"FinanceUSDTAddressesResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseFinanceUSDTAddresses"
}
}
}
},
"FinanceCoinSellerRechargeReceiptVerificationResponse": {
"description": "OK",
"content": {
@ -10972,6 +11004,21 @@
}
]
},
"ApiResponseFinanceUSDTAddresses": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FinanceUSDTAddresses"
}
}
}
]
},
"ApiResponseFinanceCoinSellerRechargeReceiptVerification": {
"allOf": [
{
@ -13400,6 +13447,38 @@
}
}
},
"FinanceUSDTAddress": {
"type": "object",
"required": ["chain", "address", "updatedAtMs"],
"properties": {
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
},
"address": {
"type": "string"
},
"updatedAtMs": {
"type": "integer",
"format": "int64"
}
}
},
"FinanceUSDTAddresses": {
"type": "object",
"required": ["appCode", "items"],
"properties": {
"appCode": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FinanceUSDTAddress"
}
}
}
},
"FinanceCoinSellerRechargeExchangeRateInput": {
"type": "object",
"required": ["tiers", "whitelist"],

View File

@ -10,6 +10,7 @@ import {
fetchFinanceSession,
grantFinanceCoinSellerRechargeOrder,
getFinanceCoinSellerRechargeExchangeRate,
getFinanceUSDTAddresses,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
@ -514,6 +515,8 @@ export function FinanceApp() {
[],
);
const loadUSDTAddresses = useCallback((appCode) => getFinanceUSDTAddresses(appCode), []);
const saveCoinSellerRechargeExchangeRate = useCallback(
async (appCode, payload) => {
const saved = await replaceFinanceCoinSellerRechargeExchangeRate(appCode, cleanPayload(payload));
@ -727,6 +730,7 @@ export function FinanceApp() {
orders={coinSellerRechargeOrdersState.data}
onCreate={submitCoinSellerRechargeOrder}
onLoadExchangeRate={loadCoinSellerRechargeExchangeRate}
onLoadUSDTAddresses={loadUSDTAddresses}
onFiltersChange={updateCoinSellerRechargeOrderFilters}
onGrant={grantCoinSellerRechargeOrder}
onReload={loadCoinSellerRechargeOrders}

View File

@ -26,6 +26,7 @@ vi.mock("./api.js", () => ({
fetchFinanceSession: vi.fn(),
grantFinanceCoinSellerRechargeOrder: vi.fn(),
getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
getFinanceUSDTAddresses: vi.fn(),
listFinanceCoinSellerRechargeOrders: vi.fn(),
listFinanceRechargeDetails: vi.fn(),
listFinanceRechargeRegions: vi.fn(),

View File

@ -105,6 +105,23 @@ export async function getFinanceCoinSellerRechargeExchangeRate(appCode) {
});
}
export async function getFinanceUSDTAddresses(appCode) {
const endpoint = API_ENDPOINTS.getFinanceUSDTAddresses;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceUSDTAddresses, { app_code: appCode }), {
method: endpoint.method
});
return {
appCode: stringValue(data?.appCode ?? appCode),
items: listItems(data?.items)
.map((item) => ({
address: stringValue(item?.address),
chain: stringValue(item?.chain).toUpperCase(),
updatedAtMs: Number(item?.updatedAtMs || 0)
}))
.filter((item) => item.address && item.chain)
};
}
export async function replaceFinanceCoinSellerRechargeExchangeRate(appCode, payload) {
const endpoint = API_ENDPOINTS.replaceFinanceCoinSellerRechargeExchangeRate;
return apiRequest(apiEndpointPath(API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), {

View File

@ -6,6 +6,7 @@ import {
fetchFinanceOptions,
fetchFinanceSession,
getFinanceCoinSellerRechargeExchangeRate,
getFinanceUSDTAddresses,
grantFinanceCoinSellerRechargeOrder,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
@ -174,6 +175,33 @@ test("finance coin seller quote and exchange rate APIs use generated endpoint pa
expect(calls[2][1]?.method).toBe("PUT");
});
test("finance USDT address API uses the selected app path and normalizes SQL rows", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
code: 0,
data: {
appCode: "aslan",
items: [{ address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", chain: "TRON", updatedAtMs: 123 }]
}
})
)
)
);
const result = await getFinanceUSDTAddresses("aslan");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/admin/finance/usdt-addresses/aslan");
expect(init?.method).toBe("GET");
expect(result).toEqual({
appCode: "aslan",
items: [{ address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", chain: "TRON", updatedAtMs: 123 }]
});
});
test("finance recharge details query every app with app header and merge by recharge time", async () => {
vi.stubGlobal(
"fetch",

View File

@ -1,4 +1,5 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
@ -33,6 +34,7 @@ import {
import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx";
import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx";
import { FinanceCoinSellerGrantDialog } from "./FinanceCoinSellerGrantDialog.jsx";
import { FinanceUSDTAddressDialog } from "./FinanceUSDTAddressDialog.jsx";
export function FinanceCoinSellerRechargeOrderList({
actionLoading,
@ -46,6 +48,7 @@ export function FinanceCoinSellerRechargeOrderList({
loading,
onCreate,
onLoadExchangeRate,
onLoadUSDTAddresses,
onFiltersChange,
onGrant,
onReload,
@ -62,6 +65,7 @@ export function FinanceCoinSellerRechargeOrderList({
const [quoteError, setQuoteError] = useState("");
const [quoteLoading, setQuoteLoading] = useState(false);
const [rateConfigOpen, setRateConfigOpen] = useState(false);
const [usdtAddressOpen, setUSDTAddressOpen] = useState(false);
const [receiptVerification, setReceiptVerification] = useState(null);
const [receiptLoading, setReceiptLoading] = useState(false);
const [grantTarget, setGrantTarget] = useState(null);
@ -281,6 +285,13 @@ export function FinanceCoinSellerRechargeOrderList({
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
<Button
startIcon={<AccountBalanceWalletOutlined fontSize="small" />}
variant="outlined"
onClick={() => setUSDTAddressOpen(true)}
>
USDT 收款地址
</Button>
{canConfigureExchangeRate ? (
<Button startIcon={<SettingsOutlined fontSize="small" />} variant="outlined" onClick={() => setRateConfigOpen(true)}>
金币汇率配置
@ -495,6 +506,13 @@ export function FinanceCoinSellerRechargeOrderList({
onLoad={onLoadExchangeRate}
onSave={onSaveExchangeRate}
/>
<FinanceUSDTAddressDialog
apps={apps}
initialAppCode={filters.appCode}
open={usdtAddressOpen}
onClose={() => setUSDTAddressOpen(false)}
onLoad={onLoadUSDTAddresses}
/>
<FinanceCoinSellerGrantDialog
loading={Boolean(grantTarget && actionLoading === `grant:${grantTarget.id}`)}
order={grantTarget}

View File

@ -274,6 +274,7 @@ function baseProps(overrides = {}) {
loading: false,
onCreate: vi.fn(),
onLoadExchangeRate: vi.fn(async () => ({ appCode: "lalu", tiers: [], whitelist: [] })),
onLoadUSDTAddresses: vi.fn(async () => ({ appCode: "lalu", items: [] })),
onFiltersChange: vi.fn(),
onGrant: vi.fn(),
onReload: vi.fn(),

View File

@ -0,0 +1,144 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import LinearProgress from "@mui/material/LinearProgress";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { formatTime } from "../format.js";
const EMPTY_DATA = { appCode: "", items: [] };
export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose, onLoad }) {
const [appCode, setAppCode] = useState("");
const [data, setData] = useState(EMPTY_DATA);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [copiedChain, setCopiedChain] = useState("");
useEffect(() => {
if (!open) {
return;
}
setAppCode(initialAppCode || apps[0]?.appCode || "");
setCopiedChain("");
setError("");
}, [apps, initialAppCode, open]);
useEffect(() => {
if (!open || !appCode) {
setData(EMPTY_DATA);
return undefined;
}
// App active
let active = true;
setLoading(true);
setError("");
setCopiedChain("");
onLoad(appCode)
.then((result) => {
if (active) {
setData({ appCode, items: result?.items || [] });
}
})
.catch((loadError) => {
if (active) {
setData({ appCode, items: [] });
setError(loadError.message || "加载 USDT 收款地址失败");
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [appCode, onLoad, open]);
const copyAddress = async (item) => {
try {
if (!navigator.clipboard?.writeText) {
throw new Error("clipboard unavailable");
}
// 使 API
await navigator.clipboard.writeText(item.address);
setCopiedChain(item.chain);
setError("");
} catch {
setCopiedChain("");
setError("复制失败,请手动选择地址复制");
}
};
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<DialogTitle>USDT 收款地址</DialogTitle>
<DialogContent className="finance-usdt-address-dialog">
{error ? <Alert severity="error">{error}</Alert> : null}
<TextField
disabled={loading || !apps.length}
label="APP"
select
value={appCode}
onChange={(event) => setAppCode(event.target.value)}
>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
<AppIdentity app={app} size="small" />
</MenuItem>
))}
</TextField>
{loading ? <LinearProgress aria-label="加载 USDT 收款地址" /> : null}
{!loading && data.items.length ? (
<div className="finance-usdt-address-list">
{data.items.map((item) => (
<section className="finance-usdt-address-item" key={item.chain}>
<div className="finance-usdt-address-item__meta">
<strong>{chainLabel(item.chain)}</strong>
<small>{item.updatedAtMs ? `更新于 ${formatTime(item.updatedAtMs)}` : ""}</small>
</div>
<div className="finance-usdt-address-item__value">
<code>{item.address}</code>
<Button
aria-label={`复制 ${item.chain} 地址`}
size="small"
startIcon={<ContentCopyOutlined fontSize="small" />}
variant="outlined"
onClick={() => copyAddress(item)}
>
{copiedChain === item.chain ? "已复制" : "复制"}
</Button>
</div>
</section>
))}
</div>
) : null}
{!loading && appCode && !data.items.length && !error ? (
<div className="finance-usdt-address-empty">当前 APP 未配置 USDT 收款地址</div>
) : null}
</DialogContent>
<DialogActions>
<Button disabled={loading} variant="outlined" onClick={onClose}>
关闭
</Button>
</DialogActions>
</Dialog>
);
}
function chainLabel(chain) {
if (chain === "TRON") {
return "TRON · TRC20";
}
if (chain === "BSC") {
return "BSC · BEP20";
}
return chain;
}

View File

@ -0,0 +1,43 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { FinanceUSDTAddressDialog } from "./FinanceUSDTAddressDialog.jsx";
afterEach(() => {
vi.unstubAllGlobals();
});
test("loads the selected app USDT address and copies the exact SQL value", async () => {
const writeText = vi.fn(async () => undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const onLoad = vi.fn(async (appCode) => ({
appCode,
items: [
{
address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
chain: "TRON",
updatedAtMs: 1760000000000,
},
],
}));
render(
<FinanceUSDTAddressDialog
apps={[
{ appCode: "lalu", appName: "Lalu" },
{ appCode: "aslan", appName: "Aslan" },
]}
initialAppCode="aslan"
open
onClose={vi.fn()}
onLoad={onLoad}
/>,
);
expect(await screen.findByText("TRON · TRC20")).toBeInTheDocument();
expect(screen.getByText("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")).toBeInTheDocument();
expect(onLoad).toHaveBeenCalledWith("aslan");
fireEvent.click(screen.getByRole("button", { name: "复制 TRON 地址" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"));
expect(screen.getByRole("button", { name: "复制 TRON 地址" })).toHaveTextContent("已复制");
});

View File

@ -1850,6 +1850,59 @@ a {
margin-left: auto;
}
.finance-usdt-address-dialog {
display: grid;
gap: var(--space-4);
padding-top: var(--space-2) !important;
}
.finance-usdt-address-list {
display: grid;
gap: var(--space-3);
}
.finance-usdt-address-item {
display: grid;
gap: var(--space-2);
border-bottom: 1px solid var(--border);
padding-bottom: var(--space-3);
}
.finance-usdt-address-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.finance-usdt-address-item__meta,
.finance-usdt-address-item__value {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.finance-usdt-address-item__meta small {
color: var(--text-tertiary);
}
.finance-usdt-address-item__value code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
.finance-usdt-address-item__value .MuiButton-root {
flex: 0 0 auto;
}
.finance-usdt-address-empty {
color: var(--text-secondary);
padding: var(--space-5) 0;
text-align: center;
}
.finance-fchip {
display: inline-flex;
align-items: center;

View File

@ -137,6 +137,7 @@ export const API_OPERATIONS = {
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
getFinanceScope: "getFinanceScope",
getFinanceUSDTAddresses: "getFinanceUSDTAddresses",
getFirstRechargeRewardConfig: "getFirstRechargeRewardConfig",
getHumanRoomRobotConfig: "getHumanRoomRobotConfig",
getInviteActivityRewardConfig: "getInviteActivityRewardConfig",
@ -1267,6 +1268,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "finance:view",
permissions: ["finance:view"]
},
getFinanceUSDTAddresses: {
method: "GET",
operationId: API_OPERATIONS.getFinanceUSDTAddresses,
path: "/v1/admin/finance/usdt-addresses/{app_code}",
permission: "finance-order:coin-seller-recharge:view",
permissions: ["finance-order:coin-seller-recharge:view"]
},
getFirstRechargeRewardConfig: {
method: "GET",
operationId: API_OPERATIONS.getFirstRechargeRewardConfig,

View File

@ -2588,6 +2588,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/finance/usdt-addresses/{app_code}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getFinanceUSDTAddresses"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
parameters: {
query?: never;
@ -5424,6 +5440,9 @@ export interface components {
ApiResponseFinanceCoinSellerRechargeExchangeRate: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceCoinSellerRechargeExchangeRate"];
};
ApiResponseFinanceUSDTAddresses: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceUSDTAddresses"];
};
ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"];
};
@ -6200,6 +6219,17 @@ export interface components {
whitelist: components["schemas"]["FinanceCoinSellerRechargeExchangeRateWhitelist"][];
updatedAtMs: number;
};
FinanceUSDTAddress: {
/** @enum {string} */
chain: "TRON" | "BSC";
address: string;
/** Format: int64 */
updatedAtMs: number;
};
FinanceUSDTAddresses: {
appCode: string;
items: components["schemas"]["FinanceUSDTAddress"][];
};
FinanceCoinSellerRechargeExchangeRateInput: {
tiers: {
minUsdAmount: number;
@ -7056,6 +7086,15 @@ export interface components {
};
};
/** @description OK */
FinanceUSDTAddressesResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseFinanceUSDTAddresses"];
};
};
/** @description OK */
FinanceCoinSellerRechargeReceiptVerificationResponse: {
headers: {
[name: string]: unknown;
@ -10780,6 +10819,20 @@ export interface operations {
200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
};
};
getFinanceUSDTAddresses: {
parameters: {
query?: never;
header?: never;
path: {
app_code: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["FinanceUSDTAddressesResponse"];
};
};
getFinanceCoinSellerRechargeExchangeRate: {
parameters: {
query?: never;