feat(finance): edit app USDT receive addresses
This commit is contained in:
parent
ee900d0127
commit
9045de6ad2
@ -4593,6 +4593,40 @@
|
||||
"x-permissions": ["finance-order:coin-seller-recharge:view"]
|
||||
}
|
||||
},
|
||||
"/admin/finance/usdt-addresses/{app_code}/{chain}": {
|
||||
"put": {
|
||||
"operationId": "upsertFinanceUSDTAddress",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "app_code",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "chain",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"$ref": "#/components/requestBodies/FinanceUSDTAddressRequest"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/FinanceUSDTAddressResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "finance-order:usdt-address:update",
|
||||
"x-permissions": ["finance-order:usdt-address:update"]
|
||||
}
|
||||
},
|
||||
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
|
||||
"get": {
|
||||
"operationId": "getFinanceCoinSellerRechargeExchangeRate",
|
||||
@ -8229,6 +8263,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceUSDTAddressRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FinanceUSDTAddressInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceWithdrawalAuditRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@ -8777,6 +8820,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceUSDTAddressResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResponseFinanceUSDTAddressUpsert"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceCoinSellerRechargeReceiptVerificationResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -11019,6 +11072,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseFinanceUSDTAddressUpsert": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Envelope"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/FinanceUSDTAddressUpsert"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseFinanceCoinSellerRechargeReceiptVerification": {
|
||||
"allOf": [
|
||||
{
|
||||
@ -13137,7 +13205,7 @@
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
"enum": ["C2C", "OFFCHAIN", "TRON", "BSC"]
|
||||
},
|
||||
"externalOrderNo": {
|
||||
"type": "string"
|
||||
@ -13335,7 +13403,7 @@
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
"enum": ["C2C", "OFFCHAIN", "TRON", "BSC"]
|
||||
},
|
||||
"remark": {
|
||||
"type": "string",
|
||||
@ -13479,6 +13547,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceUSDTAddressInput": {
|
||||
"type": "object",
|
||||
"required": ["address"],
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceUSDTAddressUpsert": {
|
||||
"type": "object",
|
||||
"required": ["appCode", "chain", "address", "updatedAtMs"],
|
||||
"properties": {
|
||||
"appCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
},
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAtMs": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FinanceCoinSellerRechargeExchangeRateInput": {
|
||||
"type": "object",
|
||||
"required": ["tiers", "whitelist"],
|
||||
@ -13550,7 +13647,7 @@
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
"enum": ["C2C", "OFFCHAIN", "TRON", "BSC"]
|
||||
},
|
||||
"receiveAddress": {
|
||||
"type": "string"
|
||||
@ -13582,7 +13679,7 @@
|
||||
},
|
||||
"chain": {
|
||||
"type": "string",
|
||||
"enum": ["TRON", "BSC"]
|
||||
"enum": ["C2C", "OFFCHAIN", "TRON", "BSC"]
|
||||
},
|
||||
"usdAmount": {
|
||||
"type": "number",
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||
rejectFinanceWithdrawalApplication,
|
||||
quoteFinanceCoinSellerRechargeOrder,
|
||||
upsertFinanceUSDTAddress,
|
||||
verifyFinanceCoinSellerRechargeReceipt,
|
||||
verifyFinanceCoinSellerRechargeOrder,
|
||||
} from "./api.js";
|
||||
@ -517,6 +518,18 @@ export function FinanceApp() {
|
||||
|
||||
const loadUSDTAddresses = useCallback((appCode) => getFinanceUSDTAddresses(appCode), []);
|
||||
|
||||
const saveUSDTAddress = useCallback(
|
||||
async (appCode, chain, payload) => {
|
||||
if (!session?.canUpdateUSDTAddress) {
|
||||
throw new Error("无修改 USDT 收款地址权限");
|
||||
}
|
||||
const saved = await upsertFinanceUSDTAddress(appCode, chain, cleanPayload(payload));
|
||||
showToast(`${chain} 收款地址已保存`, "success");
|
||||
return saved;
|
||||
},
|
||||
[session?.canUpdateUSDTAddress, showToast],
|
||||
);
|
||||
|
||||
const saveCoinSellerRechargeExchangeRate = useCallback(
|
||||
async (appCode, payload) => {
|
||||
const saved = await replaceFinanceCoinSellerRechargeExchangeRate(appCode, cleanPayload(payload));
|
||||
@ -723,6 +736,7 @@ export function FinanceApp() {
|
||||
canCreate={session.canCreateCoinSellerRechargeOrder}
|
||||
canConfigureExchangeRate={session.canConfigureCoinSellerRechargeExchangeRate}
|
||||
canGrant={session.canGrantCoinSellerRechargeOrder}
|
||||
canUpdateUSDTAddress={session.canUpdateUSDTAddress}
|
||||
canVerify={session.canVerifyCoinSellerRechargeOrder}
|
||||
error={coinSellerRechargeOrdersState.error}
|
||||
filters={coinSellerRechargeOrderFilters}
|
||||
@ -736,6 +750,7 @@ export function FinanceApp() {
|
||||
onReload={loadCoinSellerRechargeOrders}
|
||||
onQuote={quoteCoinSellerRechargeOrder}
|
||||
onSaveExchangeRate={saveCoinSellerRechargeExchangeRate}
|
||||
onSaveUSDTAddress={saveUSDTAddress}
|
||||
onVerify={verifyCoinSellerRechargeOrder}
|
||||
onVerifyReceipt={verifyCoinSellerRechargeReceipt}
|
||||
/>
|
||||
|
||||
@ -34,6 +34,7 @@ vi.mock("./api.js", () => ({
|
||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||
upsertFinanceUSDTAddress: vi.fn(),
|
||||
quoteFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
verifyFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
verifyFinanceCoinSellerRechargeReceipt: vi.fn()
|
||||
@ -45,6 +46,7 @@ const defaultSession = {
|
||||
canConfigureCoinSellerRechargeExchangeRate: false,
|
||||
canCreateCoinSellerRechargeOrder: false,
|
||||
canGrantCoinSellerRechargeOrder: false,
|
||||
canUpdateUSDTAddress: false,
|
||||
canVerifyCoinSellerRechargeOrder: false,
|
||||
canViewAppRechargeDetails: false,
|
||||
canViewCoinSellerRechargeOrders: false,
|
||||
|
||||
@ -16,6 +16,14 @@ import {
|
||||
|
||||
const appCodeHeader = "X-App-Code";
|
||||
|
||||
function normalizeUSDTAddress(item = {}) {
|
||||
return {
|
||||
address: stringValue(item?.address),
|
||||
chain: stringValue(item?.chain).toUpperCase(),
|
||||
updatedAtMs: Number(item?.updatedAtMs || 0)
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFinanceSession() {
|
||||
const session = await currentSession();
|
||||
const permissions = Array.isArray(session.permissions) ? session.permissions : [];
|
||||
@ -26,6 +34,7 @@ export async function fetchFinanceSession() {
|
||||
const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder);
|
||||
const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder);
|
||||
const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder);
|
||||
const canUpdateUSDTAddress = permissions.includes(FINANCE_PERMISSIONS.updateUSDTAddress);
|
||||
const canConfigureCoinSellerRechargeExchangeRate = permissions.includes(FINANCE_PERMISSIONS.configureCoinSellerRechargeExchangeRate);
|
||||
|
||||
return {
|
||||
@ -38,6 +47,7 @@ export async function fetchFinanceSession() {
|
||||
canConfigureCoinSellerRechargeExchangeRate,
|
||||
canCreateCoinSellerRechargeOrder,
|
||||
canGrantCoinSellerRechargeOrder,
|
||||
canUpdateUSDTAddress,
|
||||
canVerifyCoinSellerRechargeOrder,
|
||||
canViewAppRechargeDetails,
|
||||
canViewCoinSellerRechargeOrders,
|
||||
@ -113,15 +123,26 @@ export async function getFinanceUSDTAddresses(appCode) {
|
||||
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)
|
||||
}))
|
||||
.map(normalizeUSDTAddress)
|
||||
.filter((item) => item.address && item.chain)
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertFinanceUSDTAddress(appCode, chain, payload) {
|
||||
const endpoint = API_ENDPOINTS.upsertFinanceUSDTAddress;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.upsertFinanceUSDTAddress, {
|
||||
app_code: appCode,
|
||||
chain
|
||||
}), {
|
||||
body: { address: stringValue(payload?.address) },
|
||||
method: endpoint.method
|
||||
});
|
||||
return {
|
||||
...normalizeUSDTAddress(data),
|
||||
appCode: stringValue(data?.appCode ?? appCode)
|
||||
};
|
||||
}
|
||||
|
||||
export async function replaceFinanceCoinSellerRechargeExchangeRate(appCode, payload) {
|
||||
const endpoint = API_ENDPOINTS.replaceFinanceCoinSellerRechargeExchangeRate;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.replaceFinanceCoinSellerRechargeExchangeRate, { app_code: appCode }), {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
rejectFinanceWithdrawalApplication,
|
||||
quoteFinanceCoinSellerRechargeOrder,
|
||||
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||
upsertFinanceUSDTAddress,
|
||||
verifyFinanceCoinSellerRechargeReceipt,
|
||||
verifyFinanceCoinSellerRechargeOrder
|
||||
} from "./api.js";
|
||||
@ -202,6 +203,36 @@ test("finance USDT address API uses the selected app path and normalizes SQL row
|
||||
});
|
||||
});
|
||||
|
||||
test("finance USDT address update uses the selected app and chain path", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
address: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J",
|
||||
appCode: "aslan",
|
||||
chain: "TRON",
|
||||
updatedAtMs: 456
|
||||
}
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const result = await upsertFinanceUSDTAddress("aslan", "TRON", { address: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" });
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||
|
||||
expect(String(url)).toContain("/api/v1/admin/finance/usdt-addresses/aslan/TRON");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ address: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" });
|
||||
expect(result).toEqual({
|
||||
address: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J",
|
||||
appCode: "aslan",
|
||||
chain: "TRON",
|
||||
updatedAtMs: 456
|
||||
});
|
||||
});
|
||||
|
||||
test("finance recharge details query every app with app header and merge by recharge time", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@ -42,6 +42,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
||||
canCreate,
|
||||
canConfigureExchangeRate,
|
||||
canGrant,
|
||||
canUpdateUSDTAddress,
|
||||
canVerify,
|
||||
error,
|
||||
filters,
|
||||
@ -54,6 +55,7 @@ export function FinanceCoinSellerRechargeOrderList({
|
||||
onReload,
|
||||
onQuote,
|
||||
onSaveExchangeRate,
|
||||
onSaveUSDTAddress,
|
||||
onVerify,
|
||||
onVerifyReceipt,
|
||||
orders,
|
||||
@ -508,10 +510,12 @@ export function FinanceCoinSellerRechargeOrderList({
|
||||
/>
|
||||
<FinanceUSDTAddressDialog
|
||||
apps={apps}
|
||||
canUpdate={canUpdateUSDTAddress}
|
||||
initialAppCode={filters.appCode}
|
||||
open={usdtAddressOpen}
|
||||
onClose={() => setUSDTAddressOpen(false)}
|
||||
onLoad={onLoadUSDTAddresses}
|
||||
onSave={onSaveUSDTAddress}
|
||||
/>
|
||||
<FinanceCoinSellerGrantDialog
|
||||
loading={Boolean(grantTarget && actionLoading === `grant:${grantTarget.id}`)}
|
||||
|
||||
@ -133,6 +133,18 @@ test("makeup order keeps zero coins while creating a normal recharge record", as
|
||||
expect(onQuote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("USDT chain selector exposes Binance Off-chain as an explicit verification source", async () => {
|
||||
render(<FinanceCoinSellerRechargeOrderList {...baseProps()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "USDT" }));
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "USDT 链" }));
|
||||
|
||||
expect(screen.getByRole("option", { name: "Binance 站内转账 / Off-chain" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Binance Pay / C2C" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("coin seller recharge order actions follow verify and grant status", () => {
|
||||
const onVerify = vi.fn();
|
||||
const onGrant = vi.fn();
|
||||
@ -260,6 +272,7 @@ function baseProps(overrides = {}) {
|
||||
canCreate: true,
|
||||
canConfigureExchangeRate: false,
|
||||
canGrant: true,
|
||||
canUpdateUSDTAddress: false,
|
||||
canVerify: true,
|
||||
error: "",
|
||||
filters: {
|
||||
@ -280,6 +293,7 @@ function baseProps(overrides = {}) {
|
||||
onReload: vi.fn(),
|
||||
onQuote: vi.fn(async () => ({ coinAmount: 800000, coinsPerUsd: 80000, rateSource: "tier" })),
|
||||
onSaveExchangeRate: vi.fn(),
|
||||
onSaveUSDTAddress: vi.fn(),
|
||||
onVerify: vi.fn(),
|
||||
onVerifyReceipt: vi.fn(),
|
||||
orders: {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
@ -8,18 +10,22 @@ 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 { useEffect, useMemo, useState } from "react";
|
||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||
import { formatTime } from "../format.js";
|
||||
|
||||
const EMPTY_DATA = { appCode: "", items: [] };
|
||||
const ADDRESS_CHAINS = ["TRON", "BSC"];
|
||||
|
||||
export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose, onLoad }) {
|
||||
export function FinanceUSDTAddressDialog({ apps, canUpdate = false, initialAppCode, open, onClose, onLoad, onSave }) {
|
||||
const [appCode, setAppCode] = useState("");
|
||||
const [data, setData] = useState(EMPTY_DATA);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copiedChain, setCopiedChain] = useState("");
|
||||
const [editingChain, setEditingChain] = useState("");
|
||||
const [addressDraft, setAddressDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -27,6 +33,8 @@ export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose,
|
||||
}
|
||||
setAppCode(initialAppCode || apps[0]?.appCode || "");
|
||||
setCopiedChain("");
|
||||
setEditingChain("");
|
||||
setAddressDraft("");
|
||||
setError("");
|
||||
}, [apps, initialAppCode, open]);
|
||||
|
||||
@ -40,6 +48,8 @@ export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose,
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setCopiedChain("");
|
||||
setEditingChain("");
|
||||
setAddressDraft("");
|
||||
onLoad(appCode)
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
@ -62,6 +72,13 @@ export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose,
|
||||
};
|
||||
}, [appCode, onLoad, open]);
|
||||
|
||||
const itemsByChain = useMemo(
|
||||
() => new Map((data.items || []).map((item) => [item.chain, item])),
|
||||
[data.items],
|
||||
);
|
||||
// 有写权限时始终展示 TRON/BSC 两个可维护槽位,未配置项直接提供“添加”;只读账号仍只看到真实配置。
|
||||
const visibleChains = canUpdate ? ADDRESS_CHAINS : ADDRESS_CHAINS.filter((chain) => itemsByChain.has(chain));
|
||||
|
||||
const copyAddress = async (item) => {
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
@ -77,13 +94,53 @@ export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose,
|
||||
}
|
||||
};
|
||||
|
||||
const beginEdit = (chain) => {
|
||||
setEditingChain(chain);
|
||||
setAddressDraft(itemsByChain.get(chain)?.address || "");
|
||||
setCopiedChain("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const saveAddress = async () => {
|
||||
const address = addressDraft.trim();
|
||||
if (!validUSDTAddress(editingChain, address)) {
|
||||
setError(`${chainLabel(editingChain)} 收款地址格式不正确`);
|
||||
return;
|
||||
}
|
||||
if (!onSave) {
|
||||
setError("收款地址保存接口未配置");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await onSave(appCode, editingChain, { address });
|
||||
const savedItem = {
|
||||
address: saved?.address || address,
|
||||
chain: saved?.chain || editingChain,
|
||||
updatedAtMs: Number(saved?.updatedAtMs || Date.now()),
|
||||
};
|
||||
// 保存成功后直接用服务端回包更新当前行;下一次校验也会由 admin-server 重新读同一 SQL 行。
|
||||
setData((current) => ({
|
||||
...current,
|
||||
items: [...current.items.filter((item) => item.chain !== savedItem.chain), savedItem],
|
||||
}));
|
||||
setEditingChain("");
|
||||
setAddressDraft("");
|
||||
} catch (saveError) {
|
||||
setError(saveError.message || "保存 USDT 收款地址失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading || saving ? undefined : onClose}>
|
||||
<DialogTitle>USDT 收款地址</DialogTitle>
|
||||
<DialogContent className="finance-usdt-address-dialog">
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField
|
||||
disabled={loading || !apps.length}
|
||||
disabled={loading || saving || !apps.length}
|
||||
label="APP"
|
||||
select
|
||||
value={appCode}
|
||||
@ -96,36 +153,82 @@ export function FinanceUSDTAddressDialog({ apps, initialAppCode, open, onClose,
|
||||
))}
|
||||
</TextField>
|
||||
{loading ? <LinearProgress aria-label="加载 USDT 收款地址" /> : null}
|
||||
{!loading && data.items.length ? (
|
||||
{!loading && visibleChains.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>
|
||||
))}
|
||||
{visibleChains.map((chain) => {
|
||||
const item = itemsByChain.get(chain);
|
||||
const editing = editingChain === chain;
|
||||
return (
|
||||
<section className="finance-usdt-address-item" key={chain}>
|
||||
<div className="finance-usdt-address-item__meta">
|
||||
<strong>{chainLabel(chain)}</strong>
|
||||
<small>{item?.updatedAtMs ? `更新于 ${formatTime(item.updatedAtMs)}` : "未配置"}</small>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="finance-usdt-address-item__editor">
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={`${chainLabel(chain)} 收款地址`}
|
||||
size="small"
|
||||
value={addressDraft}
|
||||
onChange={(event) => setAddressDraft(event.target.value)}
|
||||
/>
|
||||
<div className="finance-usdt-address-item__actions">
|
||||
<Button disabled={saving} size="small" variant="outlined" onClick={() => setEditingChain("")}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`保存 ${chain} 地址`}
|
||||
disabled={saving}
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={saveAddress}
|
||||
>
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="finance-usdt-address-item__value">
|
||||
{item ? <code>{item.address}</code> : <span className="finance-usdt-address-item__missing">尚未添加</span>}
|
||||
<div className="finance-usdt-address-item__actions">
|
||||
{item ? (
|
||||
<Button
|
||||
aria-label={`复制 ${chain} 地址`}
|
||||
size="small"
|
||||
startIcon={<ContentCopyOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={() => copyAddress(item)}
|
||||
>
|
||||
{copiedChain === chain ? "已复制" : "复制"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
aria-label={`${item ? "修改" : "添加"} ${chain} 地址`}
|
||||
size="small"
|
||||
startIcon={item ? <EditOutlined fontSize="small" /> : <AddOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={() => beginEdit(chain)}
|
||||
>
|
||||
{item ? "修改" : "添加"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && appCode && !data.items.length && !error ? (
|
||||
{!loading && appCode && !visibleChains.length && !error ? (
|
||||
<div className="finance-usdt-address-empty">当前 APP 未配置 USDT 收款地址</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={loading} variant="outlined" onClick={onClose}>
|
||||
<Button disabled={loading || saving} variant="outlined" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@ -142,3 +245,13 @@ function chainLabel(chain) {
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function validUSDTAddress(chain, address) {
|
||||
if (chain === "TRON") {
|
||||
return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(address);
|
||||
}
|
||||
if (chain === "BSC") {
|
||||
return /^0x[0-9a-fA-F]{40}$/.test(address);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -41,3 +41,52 @@ test("loads the selected app USDT address and copies the exact SQL value", async
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"));
|
||||
expect(screen.getByRole("button", { name: "复制 TRON 地址" })).toHaveTextContent("已复制");
|
||||
});
|
||||
|
||||
test("modifies an existing TRON address and keeps the saved SQL value in the row", async () => {
|
||||
const originalAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
|
||||
const updatedAddress = "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J";
|
||||
const onSave = vi.fn(async (appCode, chain, payload) => ({
|
||||
address: payload.address,
|
||||
appCode,
|
||||
chain,
|
||||
updatedAtMs: 200,
|
||||
}));
|
||||
|
||||
render(
|
||||
<FinanceUSDTAddressDialog
|
||||
apps={[{ appCode: "aslan", appName: "Aslan" }]}
|
||||
canUpdate
|
||||
initialAppCode="aslan"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onLoad={vi.fn(async () => ({ appCode: "aslan", items: [{ address: originalAddress, chain: "TRON" }] }))}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(originalAddress);
|
||||
fireEvent.click(screen.getByRole("button", { name: "修改 TRON 地址" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "TRON · TRC20 收款地址" }), { target: { value: updatedAddress } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存 TRON 地址" }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith("aslan", "TRON", { address: updatedAddress }));
|
||||
expect(await screen.findByText(updatedAddress)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "修改 TRON 地址" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows an add action when the selected app has no TRON address", async () => {
|
||||
render(
|
||||
<FinanceUSDTAddressDialog
|
||||
apps={[{ appCode: "aslan", appName: "Aslan" }]}
|
||||
canUpdate
|
||||
initialAppCode="aslan"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onLoad={vi.fn(async () => ({ appCode: "aslan", items: [] }))}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "添加 TRON 地址" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "添加 BSC 地址" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ export const FINANCE_PERMISSIONS = {
|
||||
configureCoinSellerRechargeExchangeRate: "coin-seller:exchange-rate",
|
||||
createCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:create",
|
||||
grantCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:grant",
|
||||
updateUSDTAddress: "finance-order:usdt-address:update",
|
||||
verifyCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:verify",
|
||||
viewCoinSellerRechargeOrders: "finance-order:coin-seller-recharge:view",
|
||||
viewAppRechargeDetails: "payment-bill:view",
|
||||
@ -22,7 +23,8 @@ export const COIN_SELLER_RECHARGE_PROVIDERS = [
|
||||
];
|
||||
|
||||
export const COIN_SELLER_RECHARGE_CHAINS = [
|
||||
["C2C", "C2C"],
|
||||
["C2C", "Binance Pay / C2C"],
|
||||
["OFFCHAIN", "Binance 站内转账 / Off-chain"],
|
||||
["TRON", "TRON"],
|
||||
["BSC", "BSC"],
|
||||
];
|
||||
|
||||
@ -1897,6 +1897,24 @@ a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.finance-usdt-address-item__editor {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-usdt-address-item__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.finance-usdt-address-item__missing {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.finance-usdt-address-empty {
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-5) 0;
|
||||
|
||||
@ -390,6 +390,7 @@ export const API_OPERATIONS = {
|
||||
updateWeeklyStarCycle: "updateWeeklyStarCycle",
|
||||
uploadFile: "uploadFile",
|
||||
uploadImage: "uploadImage",
|
||||
upsertFinanceUSDTAddress: "upsertFinanceUSDTAddress",
|
||||
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
|
||||
upsertResourceShopItems: "upsertResourceShopItems",
|
||||
upsertRule: "upsertRule",
|
||||
@ -3023,6 +3024,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "upload:create",
|
||||
permissions: ["upload:create"]
|
||||
},
|
||||
upsertFinanceUSDTAddress: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertFinanceUSDTAddress,
|
||||
path: "/v1/admin/finance/usdt-addresses/{app_code}/{chain}",
|
||||
permission: "finance-order:usdt-address:update",
|
||||
permissions: ["finance-order:usdt-address:update"]
|
||||
},
|
||||
upsertLuckyGiftConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
|
||||
|
||||
67
src/shared/api/generated/schema.d.ts
vendored
67
src/shared/api/generated/schema.d.ts
vendored
@ -2604,6 +2604,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/finance/usdt-addresses/{app_code}/{chain}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["upsertFinanceUSDTAddress"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/finance/coin-seller-recharge-exchange-rates/{app_code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -5443,6 +5459,9 @@ export interface components {
|
||||
ApiResponseFinanceUSDTAddresses: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["FinanceUSDTAddresses"];
|
||||
};
|
||||
ApiResponseFinanceUSDTAddressUpsert: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["FinanceUSDTAddressUpsert"];
|
||||
};
|
||||
ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"];
|
||||
};
|
||||
@ -6113,7 +6132,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
providerCode: "mifapay" | "v5pay" | "usdt";
|
||||
/** @enum {string} */
|
||||
chain?: "TRON" | "BSC";
|
||||
chain?: "C2C" | "OFFCHAIN" | "TRON" | "BSC";
|
||||
externalOrderNo: string;
|
||||
providerCountryCode?: string;
|
||||
providerCurrencyCode?: string;
|
||||
@ -6183,7 +6202,7 @@ export interface components {
|
||||
providerCurrencyCode?: string;
|
||||
providerAmountMinor?: number;
|
||||
/** @enum {string} */
|
||||
chain?: "TRON" | "BSC";
|
||||
chain?: "C2C" | "OFFCHAIN" | "TRON" | "BSC";
|
||||
remark?: string;
|
||||
};
|
||||
FinanceCoinSellerRechargeQuoteInput: {
|
||||
@ -6230,6 +6249,17 @@ export interface components {
|
||||
appCode: string;
|
||||
items: components["schemas"]["FinanceUSDTAddress"][];
|
||||
};
|
||||
FinanceUSDTAddressInput: {
|
||||
address: string;
|
||||
};
|
||||
FinanceUSDTAddressUpsert: {
|
||||
appCode: string;
|
||||
/** @enum {string} */
|
||||
chain: "TRON" | "BSC";
|
||||
address: string;
|
||||
/** Format: int64 */
|
||||
updatedAtMs: number;
|
||||
};
|
||||
FinanceCoinSellerRechargeExchangeRateInput: {
|
||||
tiers: {
|
||||
minUsdAmount: number;
|
||||
@ -6250,7 +6280,7 @@ export interface components {
|
||||
currencyCode?: string;
|
||||
providerAmountMinor?: number;
|
||||
/** @enum {string} */
|
||||
chain?: "TRON" | "BSC";
|
||||
chain?: "C2C" | "OFFCHAIN" | "TRON" | "BSC";
|
||||
receiveAddress?: string;
|
||||
verifiedAtMs?: number;
|
||||
failureReason?: string;
|
||||
@ -6262,7 +6292,7 @@ export interface components {
|
||||
providerCode: "mifapay" | "v5pay" | "usdt";
|
||||
externalOrderNo: string;
|
||||
/** @enum {string} */
|
||||
chain?: "TRON" | "BSC";
|
||||
chain?: "C2C" | "OFFCHAIN" | "TRON" | "BSC";
|
||||
usdAmount?: number;
|
||||
usdMinorAmount?: number;
|
||||
/**
|
||||
@ -7095,6 +7125,15 @@ export interface components {
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
FinanceUSDTAddressResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseFinanceUSDTAddressUpsert"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
FinanceCoinSellerRechargeReceiptVerificationResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@ -7514,6 +7553,11 @@ export interface components {
|
||||
"application/json": components["schemas"]["FinanceCoinSellerRechargeReceiptVerificationInput"];
|
||||
};
|
||||
};
|
||||
FinanceUSDTAddressRequest: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["FinanceUSDTAddressInput"];
|
||||
};
|
||||
};
|
||||
FinanceWithdrawalAuditRequest: {
|
||||
content: {
|
||||
"application/json": {
|
||||
@ -10833,6 +10877,21 @@ export interface operations {
|
||||
200: components["responses"]["FinanceUSDTAddressesResponse"];
|
||||
};
|
||||
};
|
||||
upsertFinanceUSDTAddress: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
app_code: string;
|
||||
chain: "TRON" | "BSC";
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: components["requestBodies"]["FinanceUSDTAddressRequest"];
|
||||
responses: {
|
||||
200: components["responses"]["FinanceUSDTAddressResponse"];
|
||||
};
|
||||
};
|
||||
getFinanceCoinSellerRechargeExchangeRate: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user