Compare commits

...

2 Commits

Author SHA1 Message Date
zhx
7711f2a9ee 自动充值系统 2026-07-07 18:05:08 +08:00
zhx
ee023df614 vip赠送 2026-07-06 15:14:05 +08:00
28 changed files with 2761 additions and 260 deletions

View File

@ -3578,6 +3578,148 @@
"x-permissions": ["finance-withdrawal:audit", "finance-application:audit"]
}
},
"/admin/finance/orders/coin-seller-recharges": {
"get": {
"operationId": "listFinanceCoinSellerRechargeOrders",
"parameters": [
{
"in": "query",
"name": "app_code",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "provider_code",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "status",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "verify_status",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "grant_status",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "keyword",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "page_size",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/FinanceCoinSellerRechargeOrderPageResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:view",
"x-permissions": ["finance-order:coin-seller-recharge:view"]
},
"post": {
"operationId": "createFinanceCoinSellerRechargeOrder",
"requestBody": {
"$ref": "#/components/requestBodies/FinanceCoinSellerRechargeOrderRequest"
},
"responses": {
"201": {
"$ref": "#/components/responses/FinanceCoinSellerRechargeOrderResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:create",
"x-permissions": ["finance-order:coin-seller-recharge:create"]
}
},
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
"post": {
"operationId": "verifyFinanceCoinSellerRechargeReceipt",
"requestBody": {
"$ref": "#/components/requestBodies/FinanceCoinSellerRechargeReceiptVerificationRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/FinanceCoinSellerRechargeReceiptVerificationResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:verify",
"x-permissions": ["finance-order:coin-seller-recharge:verify"]
}
},
"/admin/finance/orders/coin-seller-recharges/{order_id}/verify": {
"post": {
"operationId": "verifyFinanceCoinSellerRechargeOrder",
"parameters": [
{
"in": "path",
"name": "order_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/FinanceCoinSellerRechargeOrderResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:verify",
"x-permissions": ["finance-order:coin-seller-recharge:verify"]
}
},
"/admin/finance/orders/coin-seller-recharges/{order_id}/grant": {
"post": {
"operationId": "grantFinanceCoinSellerRechargeOrder",
"parameters": [
{
"in": "path",
"name": "order_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/FinanceCoinSellerRechargeOrderResponse"
}
},
"x-permission": "finance-order:coin-seller-recharge:grant",
"x-permissions": ["finance-order:coin-seller-recharge:grant"]
}
},
"/admin/payment/temporary-links": {
"get": {
"operationId": "listTemporaryPaymentLinks",
@ -6073,6 +6215,24 @@
}
}
},
"FinanceCoinSellerRechargeOrderRequest": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FinanceCoinSellerRechargeOrderInput"
}
}
}
},
"FinanceCoinSellerRechargeReceiptVerificationRequest": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FinanceCoinSellerRechargeReceiptVerificationInput"
}
}
}
},
"FinanceApplicationAuditRequest": {
"content": {
"application/json": {
@ -6345,6 +6505,36 @@
}
}
},
"FinanceCoinSellerRechargeOrderPageResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeOrderPage"
}
}
}
},
"FinanceCoinSellerRechargeOrderResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeOrder"
}
}
}
},
"FinanceCoinSellerRechargeReceiptVerificationResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseFinanceCoinSellerRechargeReceiptVerification"
}
}
}
},
"FinanceWithdrawalApplicationPageResponse": {
"description": "OK",
"content": {
@ -6694,6 +6884,27 @@
}
}
},
"ApiPageFinanceCoinSellerRechargeOrder": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FinanceCoinSellerRechargeOrder"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"ApiPageFinanceWithdrawalApplication": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
@ -6896,6 +7107,51 @@
}
]
},
"ApiResponseFinanceCoinSellerRechargeOrder": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FinanceCoinSellerRechargeOrder"
}
}
}
]
},
"ApiResponseFinanceCoinSellerRechargeReceiptVerification": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FinanceCoinSellerRechargeReceiptVerification"
}
}
}
]
},
"ApiResponseFinanceCoinSellerRechargeOrderPage": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/ApiPageFinanceCoinSellerRechargeOrder"
}
}
}
]
},
"ApiResponseFinanceWithdrawalApplication": {
"allOf": [
{
@ -8076,6 +8332,133 @@
}
}
},
"FinanceCoinSellerRechargeOrder": {
"type": "object",
"required": [
"id",
"appCode",
"targetUserId",
"usdAmount",
"coinAmount",
"providerCode",
"externalOrderNo",
"status",
"verifyStatus",
"grantStatus",
"createdAtMs"
],
"properties": {
"id": {
"type": "string"
},
"appCode": {
"type": "string"
},
"targetUserId": {
"type": "string"
},
"targetDisplayUserId": {
"type": "string"
},
"usdAmount": {
"type": "string"
},
"usdMinorAmount": {
"type": "integer"
},
"coinAmount": {
"type": "integer"
},
"providerCode": {
"type": "string",
"enum": ["mifapay", "v5pay", "usdt"]
},
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
},
"externalOrderNo": {
"type": "string"
},
"providerCountryCode": {
"type": "string"
},
"providerCurrencyCode": {
"type": "string"
},
"providerAmountMinor": {
"type": "integer"
},
"providerOrderId": {
"type": "string"
},
"providerStatus": {
"type": "string"
},
"providerPayType": {
"type": "string"
},
"receiveAddress": {
"type": "string"
},
"orderDate": {
"type": "string"
},
"status": {
"type": "string"
},
"verifyStatus": {
"type": "string"
},
"grantStatus": {
"type": "string"
},
"remark": {
"type": "string"
},
"failureReason": {
"type": "string"
},
"providerPayloadJson": {
"type": "string"
},
"walletCommandId": {
"type": "string"
},
"walletTransactionId": {
"type": "string"
},
"walletAssetType": {
"type": "string"
},
"walletAmountDelta": {
"type": "integer"
},
"walletBalanceAfter": {
"type": "integer"
},
"operatorName": {
"type": "string"
},
"verifiedByName": {
"type": "string"
},
"grantedByName": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"verifiedAtMs": {
"nullable": true,
"type": "integer"
},
"grantedAtMs": {
"nullable": true,
"type": "integer"
}
}
},
"FinanceWithdrawalApplication": {
"type": "object",
"required": [
@ -8190,6 +8573,112 @@
}
}
},
"FinanceCoinSellerRechargeOrderInput": {
"type": "object",
"required": ["appCode", "targetUserId", "usdAmount", "coinAmount", "providerCode", "externalOrderNo"],
"properties": {
"appCode": {
"type": "string"
},
"targetUserId": {
"type": "string"
},
"usdAmount": {
"type": "number",
"minimum": 0
},
"coinAmount": {
"type": "integer",
"minimum": 0
},
"providerCode": {
"type": "string",
"enum": ["mifapay", "v5pay", "usdt"]
},
"externalOrderNo": {
"type": "string"
},
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
},
"remark": {
"type": "string",
"maxLength": 512
}
}
},
"FinanceCoinSellerRechargeReceiptVerification": {
"type": "object",
"required": ["verified"],
"properties": {
"verified": {
"type": "boolean"
},
"providerCode": {
"type": "string"
},
"externalOrderNo": {
"type": "string"
},
"providerOrderId": {
"type": "string"
},
"status": {
"type": "string"
},
"currencyCode": {
"type": "string"
},
"providerAmountMinor": {
"type": "integer"
},
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
},
"receiveAddress": {
"type": "string"
},
"verifiedAtMs": {
"type": "integer"
},
"failureReason": {
"type": "string"
},
"rawJson": {
"type": "string"
}
}
},
"FinanceCoinSellerRechargeReceiptVerificationInput": {
"type": "object",
"required": ["appCode", "providerCode", "externalOrderNo"],
"properties": {
"appCode": {
"type": "string"
},
"providerCode": {
"type": "string",
"enum": ["mifapay", "v5pay", "usdt"]
},
"externalOrderNo": {
"type": "string"
},
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
},
"usdAmount": {
"type": "number",
"minimum": 0
},
"usdMinorAmount": {
"type": "integer",
"minimum": 0
}
}
},
"UserFinanceScope": {
"type": "object",
"required": ["appCode", "regionId"],

View File

@ -6,6 +6,7 @@ import {
approveFinanceApplication,
approveFinanceWithdrawalApplication,
createFinanceApplication,
createFinanceCoinSellerRechargeOrder,
exportFinanceRechargeBills,
fetchFinanceApplicationOptions,
fetchFinanceRechargeApps,
@ -13,7 +14,9 @@ import {
fetchFinanceRechargeSummaries,
fetchFinanceSession,
filterOperationsByPermissions,
grantFinanceCoinSellerRechargeOrder,
listFinanceApplications,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listFinanceRechargeRegions,
listMyFinanceApplications,
@ -21,10 +24,13 @@ import {
refreshFinanceGoogleRechargePaid,
rejectFinanceApplication,
rejectFinanceWithdrawalApplication,
verifyFinanceCoinSellerRechargeReceipt,
verifyFinanceCoinSellerRechargeOrder,
} from "./api.js";
import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx";
import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx";
import { FinanceAuditDialog } from "./components/FinanceAuditDialog.jsx";
import { FinanceCoinSellerRechargeOrderList } from "./components/FinanceCoinSellerRechargeOrderList.jsx";
import { FinanceMyApplicationList } from "./components/FinanceMyApplicationList.jsx";
import { FinanceOverview } from "./components/FinanceOverview.jsx";
import { FinanceReconciliation } from "./components/FinanceReconciliation.jsx";
@ -124,6 +130,12 @@ export function FinanceApp() {
error: "",
loading: false,
});
const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({
data: { items: [], page: 1, pageSize, total: 0 },
error: "",
loading: false,
});
const [coinSellerRechargeOrderActionLoading, setCoinSellerRechargeOrderActionLoading] = useState("");
const [activeView, setActiveView] = useState("create");
const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({
appCode: "",
@ -139,6 +151,15 @@ export function FinanceApp() {
const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" });
const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" });
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
appCode: "",
grantStatus: "",
keyword: "",
page: 1,
providerCode: "",
status: "",
verifyStatus: "",
});
const [form, setForm] = useState(DEFAULT_APPLICATION_FORM);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
@ -158,11 +179,13 @@ export function FinanceApp() {
setActiveView(
session.canViewAppRechargeDetails
? "overview"
: session.canAuditApplication
? "applications"
: session.canCreateApplication
? "create"
: "withdrawals",
: session.canViewCoinSellerRechargeOrders
? "coinSellerRechargeOrders"
: session.canAuditApplication
? "applications"
: session.canCreateApplication
? "create"
: "withdrawals",
);
})
.catch((err) => {
@ -233,6 +256,19 @@ export function FinanceApp() {
}),
[withdrawalFilters],
);
const coinSellerRechargeOrderQuery = useMemo(
() => ({
app_code: coinSellerRechargeOrderFilters.appCode,
grant_status: coinSellerRechargeOrderFilters.grantStatus,
keyword: coinSellerRechargeOrderFilters.keyword,
page: coinSellerRechargeOrderFilters.page,
page_size: pageSize,
provider_code: coinSellerRechargeOrderFilters.providerCode,
status: coinSellerRechargeOrderFilters.status,
verify_status: coinSellerRechargeOrderFilters.verifyStatus,
}),
[coinSellerRechargeOrderFilters],
);
const loadOptions = useCallback(async () => {
if (!canLoadOptions) {
@ -464,6 +500,23 @@ export function FinanceApp() {
}
}, [session?.canViewWithdrawalApplications, withdrawalQuery]);
const loadCoinSellerRechargeOrders = useCallback(async () => {
if (!session?.canViewCoinSellerRechargeOrders) {
return;
}
setCoinSellerRechargeOrdersState((current) => ({ ...current, error: "", loading: true }));
try {
const data = await listFinanceCoinSellerRechargeOrders(coinSellerRechargeOrderQuery);
setCoinSellerRechargeOrdersState({ data, error: "", loading: false });
} catch (err) {
setCoinSellerRechargeOrdersState((current) => ({
...current,
error: err.message || "加载币商充值订单失败",
loading: false,
}));
}
}, [coinSellerRechargeOrderQuery, session?.canViewCoinSellerRechargeOrders]);
useEffect(() => {
loadOptions();
}, [loadOptions]);
@ -504,6 +557,10 @@ export function FinanceApp() {
loadWithdrawals();
}, [loadWithdrawals]);
useEffect(() => {
loadCoinSellerRechargeOrders();
}, [loadCoinSellerRechargeOrders]);
const operationOptions = useMemo(
() => filterOperationsByPermissions(optionsState.data.operations, session?.permissions || []),
[optionsState.data.operations, session?.permissions],
@ -580,6 +637,72 @@ export function FinanceApp() {
}
};
const submitCoinSellerRechargeOrder = async (payload) => {
if (!session?.canCreateCoinSellerRechargeOrder) {
showToast("无创建币商充值订单权限", "error");
return null;
}
setCoinSellerRechargeOrderActionLoading("create");
try {
const order = await createFinanceCoinSellerRechargeOrder(cleanPayload(payload));
showToast("币商充值订单已创建", "success");
await loadCoinSellerRechargeOrders();
return order;
} catch (err) {
showToast(err.message || "创建币商充值订单失败", "error");
return null;
} finally {
setCoinSellerRechargeOrderActionLoading("");
}
};
const verifyCoinSellerRechargeReceipt = async (payload) => {
if (!session?.canVerifyCoinSellerRechargeOrder) {
showToast("无校验币商充值订单权限", "error");
return null;
}
try {
const verification = await verifyFinanceCoinSellerRechargeReceipt(cleanPayload(payload));
showToast(verification?.verified ? "订单号校验通过" : verification?.failureReason || "订单号校验未通过", verification?.verified ? "success" : "error");
return verification;
} catch (err) {
showToast(err.message || "校验订单号失败", "error");
return null;
}
};
const verifyCoinSellerRechargeOrder = async (order) => {
if (!order?.id || !session?.canVerifyCoinSellerRechargeOrder) {
return;
}
setCoinSellerRechargeOrderActionLoading(`verify:${order.id}`);
try {
await verifyFinanceCoinSellerRechargeOrder(order.id);
showToast("订单校验已完成", "success");
await loadCoinSellerRechargeOrders();
} catch (err) {
showToast(err.message || "校验币商充值订单失败", "error");
} finally {
setCoinSellerRechargeOrderActionLoading("");
}
};
const grantCoinSellerRechargeOrder = async (order) => {
if (!order?.id || !session?.canGrantCoinSellerRechargeOrder) {
return;
}
setCoinSellerRechargeOrderActionLoading(`grant:${order.id}`);
try {
await grantFinanceCoinSellerRechargeOrder(order.id);
showToast("币商充值已发放", "success");
await loadCoinSellerRechargeOrders();
} catch (err) {
showToast(err.message || "发放币商充值失败", "error");
} finally {
setCoinSellerRechargeOrderActionLoading("");
}
};
const updateFilters = (nextFilters) => {
setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
};
@ -596,6 +719,10 @@ export function FinanceApp() {
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
};
const updateCoinSellerRechargeOrderFilters = (nextFilters) => {
setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
};
const closeCreateDialog = (_, reason) => {
if (submitting || reason === "backdropClick") {
return;
@ -695,6 +822,7 @@ export function FinanceApp() {
appBar={appBar}
canAudit={session.canAuditApplication}
canCreate={session.canCreateApplication}
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
canViewRechargeDetails={session.canViewAppRechargeDetails}
canViewWithdrawals={session.canViewWithdrawalApplications}
loading={
@ -702,7 +830,8 @@ export function FinanceApp() {
rechargeDetailsState.loading ||
myApplicationsState.loading ||
applicationsState.loading ||
withdrawalsState.loading
withdrawalsState.loading ||
coinSellerRechargeOrdersState.loading
}
session={session}
toolbar={financeToolbar}
@ -730,6 +859,24 @@ export function FinanceApp() {
}}
onOpenWithdrawals={() => setActiveView("withdrawals")}
/>
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
<FinanceCoinSellerRechargeOrderList
actionLoading={coinSellerRechargeOrderActionLoading}
apps={rechargeApps}
canCreate={session.canCreateCoinSellerRechargeOrder}
canGrant={session.canGrantCoinSellerRechargeOrder}
canVerify={session.canVerifyCoinSellerRechargeOrder}
error={coinSellerRechargeOrdersState.error}
filters={coinSellerRechargeOrderFilters}
loading={coinSellerRechargeOrdersState.loading}
orders={coinSellerRechargeOrdersState.data}
onCreate={submitCoinSellerRechargeOrder}
onFiltersChange={updateCoinSellerRechargeOrderFilters}
onGrant={grantCoinSellerRechargeOrder}
onReload={loadCoinSellerRechargeOrders}
onVerify={verifyCoinSellerRechargeOrder}
onVerifyReceipt={verifyCoinSellerRechargeReceipt}
/>
) : activeView === "recon" && session.canViewAppRechargeDetails ? (
<FinanceReconciliation
loading={reconState.loading}

View File

@ -7,6 +7,7 @@ vi.mock("./api.js", () => ({
approveFinanceApplication: vi.fn(),
approveFinanceWithdrawalApplication: vi.fn(),
createFinanceApplication: vi.fn(),
createFinanceCoinSellerRechargeOrder: vi.fn(),
fetchFinanceApplicationOptions: vi.fn(async () => ({
apps: [{ appCode: "lalu", appName: "lalu" }],
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
@ -16,18 +17,25 @@ vi.mock("./api.js", () => ({
canAccessFinance: true,
canAuditApplication: false,
canCreateApplication: true,
canCreateCoinSellerRechargeOrder: false,
canGrantCoinSellerRechargeOrder: false,
canVerifyCoinSellerRechargeOrder: false,
canViewAppRechargeDetails: false,
canViewCoinSellerRechargeOrders: false,
canViewWithdrawalApplications: false,
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
user: { account: "admin", name: "admin" }
})),
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
grantFinanceCoinSellerRechargeOrder: vi.fn(),
listFinanceApplications: vi.fn(),
listFinanceCoinSellerRechargeOrders: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
listFinanceRechargeDetails: vi.fn(),
listFinanceWithdrawalApplications: vi.fn(),
listMyFinanceApplications: vi.fn(async () => ({ items: [], page: 1, pageSize: 50, total: 0 })),
rejectFinanceApplication: vi.fn(),
rejectFinanceWithdrawalApplication: vi.fn()
rejectFinanceWithdrawalApplication: vi.fn(),
verifyFinanceCoinSellerRechargeOrder: vi.fn()
}));
afterEach(() => {

View File

@ -6,6 +6,8 @@ import {
mergeRechargeOverviews,
mergeRechargeSummaries,
normalizeApplicationPage,
normalizeCoinSellerRechargeOrder,
normalizeCoinSellerRechargeOrderPage,
normalizeRechargeBillPage,
normalizeRechargeOverview,
normalizeRechargeRegions,
@ -24,14 +26,27 @@ export async function fetchFinanceSession() {
permissions.includes(FINANCE_PERMISSIONS.auditWithdrawalApplications) || permissions.includes(FINANCE_PERMISSIONS.auditApplication);
const canViewWithdrawalApplications = permissions.includes(FINANCE_PERMISSIONS.viewWithdrawalApplications) || canAuditWithdrawalApplications;
const canViewAppRechargeDetails = permissions.includes(FINANCE_PERMISSIONS.viewAppRechargeDetails);
const canViewCoinSellerRechargeOrders = permissions.includes(FINANCE_PERMISSIONS.viewCoinSellerRechargeOrders);
const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder);
const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder);
const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder);
return {
...session,
canAccessFinance: canCreateApplication || canAuditApplication || canViewWithdrawalApplications || canViewAppRechargeDetails,
canAccessFinance:
canCreateApplication ||
canAuditApplication ||
canViewWithdrawalApplications ||
canViewAppRechargeDetails ||
canViewCoinSellerRechargeOrders,
canAuditApplication,
canAuditWithdrawalApplications,
canCreateApplication,
canCreateCoinSellerRechargeOrder,
canGrantCoinSellerRechargeOrder,
canVerifyCoinSellerRechargeOrder,
canViewAppRechargeDetails,
canViewCoinSellerRechargeOrders,
canViewWithdrawalApplications,
permissions
};
@ -81,6 +96,48 @@ export async function listFinanceWithdrawalApplications(query = {}) {
return normalizeWithdrawalApplicationPage(data);
}
export async function listFinanceCoinSellerRechargeOrders(query = {}) {
const endpoint = API_ENDPOINTS.listFinanceCoinSellerRechargeOrders;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceCoinSellerRechargeOrders), {
method: endpoint.method,
query
});
return normalizeCoinSellerRechargeOrderPage(data);
}
export async function createFinanceCoinSellerRechargeOrder(payload) {
const endpoint = API_ENDPOINTS.createFinanceCoinSellerRechargeOrder;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceCoinSellerRechargeOrder), {
body: payload,
method: endpoint.method
});
return normalizeCoinSellerRechargeOrder(data);
}
export async function verifyFinanceCoinSellerRechargeReceipt(payload) {
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeReceipt;
return apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt), {
body: cleanPayload(payload),
method: endpoint.method
});
}
export async function verifyFinanceCoinSellerRechargeOrder(orderId) {
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeOrder;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder, { order_id: orderId }), {
method: endpoint.method
});
return normalizeCoinSellerRechargeOrder(data);
}
export async function grantFinanceCoinSellerRechargeOrder(orderId) {
const endpoint = API_ENDPOINTS.grantFinanceCoinSellerRechargeOrder;
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.grantFinanceCoinSellerRechargeOrder, { order_id: orderId }), {
method: endpoint.method
});
return normalizeCoinSellerRechargeOrder(data);
}
export async function listFinanceRechargeDetails(query = {}, apps = []) {
const page = positiveInteger(query.page, 1);
const pageSize = positiveInteger(query.page_size ?? query.pageSize, 50);
@ -326,6 +383,12 @@ function cleanRechargeBillQuery(query = {}) {
return rest;
}
function cleanPayload(payload = {}) {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== "")
);
}
function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : fallback;

View File

@ -4,14 +4,19 @@ import {
approveFinanceApplication,
approveFinanceWithdrawalApplication,
createFinanceApplication,
createFinanceCoinSellerRechargeOrder,
fetchFinanceApplicationOptions,
filterOperationsByPermissions,
grantFinanceCoinSellerRechargeOrder,
listFinanceApplications,
listFinanceCoinSellerRechargeOrders,
listFinanceRechargeDetails,
listMyFinanceApplications,
listFinanceWithdrawalApplications,
rejectFinanceApplication,
rejectFinanceWithdrawalApplication
rejectFinanceWithdrawalApplication,
verifyFinanceCoinSellerRechargeReceipt,
verifyFinanceCoinSellerRechargeOrder
} from "./api.js";
import { FINANCE_OPERATIONS } from "./constants.js";
@ -92,6 +97,56 @@ test("finance application APIs use generated endpoint paths", async () => {
expect(JSON.parse(String(calls[8][1]?.body))).toEqual({ auditRemark: "bad" });
});
test("finance coin seller recharge order APIs use generated endpoint paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url) => {
if (String(url).includes("/coin-seller-recharges?")) {
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
}
return new Response(JSON.stringify({ code: 0, data: { id: "order-1", targetUserId: "10001" } }));
})
);
await listFinanceCoinSellerRechargeOrders({ page: 1, page_size: 50, provider_code: "mifapay" });
await createFinanceCoinSellerRechargeOrder({
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerCode: "mifapay",
targetUserId: "10001",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeReceipt({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerCode: "mifapay",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeOrder("order-1");
await grantFinanceCoinSellerRechargeOrder("order-1");
const calls = vi.mocked(fetch).mock.calls;
expect(String(calls[0][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges?");
expect(String(calls[0][0])).toContain("provider_code=mifapay");
expect(calls[0][1]?.method).toBe("GET");
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges");
expect(calls[1][1]?.method).toBe("POST");
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ externalOrderNo: "MIFA-001", providerCode: "mifapay" });
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications");
expect(calls[2][1]?.method).toBe("POST");
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerCode: "mifapay",
usdAmount: 10
});
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");
expect(calls[3][1]?.method).toBe("POST");
expect(String(calls[4][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/grant");
expect(calls[4][1]?.method).toBe("POST");
});
test("filters operation options by backend-configured permissions", () => {
const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [
"finance-operation:user-coin-credit",

View File

@ -0,0 +1,539 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
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 InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import {
COIN_SELLER_RECHARGE_CHAINS,
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
} from "../constants.js";
import {
buildCoinSellerRechargeReceiptPayload,
buildCoinSellerRechargeOrderPayload,
coinSellerRechargeChainLabel,
coinSellerRechargeGrantStatusLabel,
coinSellerRechargeProviderLabel,
coinSellerRechargeStatusLabel,
coinSellerRechargeStatusTone,
coinSellerRechargeVerifyStatusLabel,
formatAmount,
formatTime,
formatUsdMinor,
isCoinSellerRechargeGranted,
isCoinSellerRechargeVerified,
validateCoinSellerRechargeOrderPayload,
} from "../format.js";
export function FinanceCoinSellerRechargeOrderList({
actionLoading,
apps,
canCreate,
canGrant,
canVerify,
error,
filters,
loading,
onCreate,
onFiltersChange,
onGrant,
onReload,
onVerify,
onVerifyReceipt,
orders,
}) {
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM);
const [formError, setFormError] = useState("");
const [receiptVerification, setReceiptVerification] = useState(null);
const [receiptLoading, setReceiptLoading] = useState(false);
const items = orders.items || [];
const page = Number(orders.page || filters.page || 1);
const pageSize = Number(orders.pageSize || 50);
const total = Number(orders.total || 0);
const hasNextPage = page * pageSize < total;
const openCreate = () => {
setForm({
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
appCode: filters.appCode || apps[0]?.appCode || "",
});
setFormError("");
setReceiptVerification(null);
setCreateOpen(true);
};
const updateForm = (key, value) => {
if (key === "providerCode") {
setForm((current) => ({
...current,
chain: "",
providerCode: value,
}));
setFormError("");
setReceiptVerification(null);
return;
}
setForm((current) => ({ ...current, [key]: value }));
setFormError("");
// APP
setReceiptVerification(null);
};
const submitCreate = async (event) => {
event.preventDefault();
if (!receiptVerification?.verified) {
setFormError("请先校验订单号");
return;
}
const payload = buildCoinSellerRechargeOrderPayload(form);
const validationMessage = validateCoinSellerRechargeOrderPayload(payload);
if (validationMessage) {
setFormError(validationMessage);
return;
}
const created = await onCreate(payload);
if (created) {
setCreateOpen(false);
setReceiptVerification(null);
}
};
const verifyCreateReceipt = async () => {
const payload = buildCoinSellerRechargeReceiptPayload(form);
const validationMessage = validateCoinSellerRechargeOrderPayload({
...buildCoinSellerRechargeOrderPayload(form),
coinAmount: 1,
targetUserId: "verification-only",
});
if (validationMessage) {
setFormError(validationMessage);
return;
}
setFormError("");
setReceiptLoading(true);
try {
const verification = await onVerifyReceipt(payload);
setReceiptVerification(verification);
if (!verification?.verified) {
setFormError(verification?.failureReason || "订单号校验未通过");
}
} finally {
setReceiptLoading(false);
}
};
const confirmGrant = (order) => {
const userText = order.targetDisplayUserId || order.targetUserId || "-";
const ok = window.confirm(
isZeroCoinMakeupOrder(order)
? `确认完成用户 ${userText} 的补单?该订单计入充值,不发放金币。`
: `确认给用户 ${userText} 发放 ${formatAmount(order.coinAmount)} 金币?`,
);
if (ok) {
onGrant(order);
}
};
return (
<section className="finance-card finance-list">
<div className="finance-filterrow">
<TextField
className="finance-filterrow__region"
label="APP"
select
size="small"
value={filters.appCode}
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
>
<MenuItem value="">全部 APP</MenuItem>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))}
</TextField>
<TextField
label="渠道"
select
size="small"
value={filters.providerCode}
onChange={(event) => onFiltersChange({ providerCode: event.target.value })}
>
<MenuItem value="">全部渠道</MenuItem>
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="订单状态"
select
size="small"
value={filters.status}
onChange={(event) => onFiltersChange({ status: event.target.value })}
>
<MenuItem value="">全部状态</MenuItem>
{COIN_SELLER_RECHARGE_STATUS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="搜索"
size="small"
value={filters.keyword}
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
/>
<span className="finance-filterrow__spacer" />
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
<Button disabled={!canCreate} startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={openCreate}>
创建币商充值
</Button>
</div>
{error ? <div className="finance-error">{error}</div> : null}
{loading && !items.length ? <div className="finance-skeleton" /> : null}
{!loading || items.length ? (
<div className="finance-tablewrap">
<table className="finance-flat-table finance-flat-table--orders">
<thead>
<tr>
<th>订单</th>
<th>APP</th>
<th>目标用户</th>
<th className="r">金额</th>
<th>渠道</th>
<th>三方订单</th>
<th>备注</th>
<th>状态</th>
<th>钱包</th>
<th>操作人</th>
<th>时间</th>
<th className="r">操作</th>
</tr>
</thead>
<tbody>
{items.length ? (
items.map((item) => (
<tr className="finance-flat-table__row" key={item.id}>
<td className="finance-order-cell">{item.id || "-"}</td>
<td>{appName(item.appCode, apps)}</td>
<td>
<Stacked primary={item.targetDisplayUserId || item.targetUserId} secondary={item.targetUserId} />
</td>
<td className="r num">
<Stacked
primary={usdText(item)}
secondary={`${formatAmount(item.coinAmount)} 金币${isZeroCoinMakeupOrder(item) ? "(补单)" : ""}`}
/>
</td>
<td>
<Stacked primary={coinSellerRechargeProviderLabel(item.providerCode)} secondary={providerMeta(item)} />
</td>
<td>
<Stacked primary={item.externalOrderNo || "-"} secondary={providerAmountText(item)} />
</td>
<td className="finance-remark-cell">{item.remark || "-"}</td>
<td>
<Stacked
primary={<StatusBadge status={item.status} />}
secondary={`${coinSellerRechargeVerifyStatusLabel(item.verifyStatus)} / ${coinSellerRechargeGrantStatusLabel(item.grantStatus)}`}
/>
</td>
<td>
<Stacked
primary={item.walletTransactionId || item.walletCommandId || "-"}
secondary={walletMeta(item)}
/>
</td>
<td>
<Stacked
primary={item.operatorName || "-"}
secondary={[
item.verifiedByName && `校验:${item.verifiedByName}`,
item.grantedByName && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
]
.filter(Boolean)
.join(" / ")}
/>
</td>
<td>
<Stacked
primary={formatTime(item.createdAtMs)}
secondary={[
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
item.grantedAtMs && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
]
.filter(Boolean)
.join(" / ")}
/>
</td>
<td className="r">
<span className="finance-row-actions">
<Button
disabled={
!canVerify ||
actionLoading === `verify:${item.id}` ||
isCoinSellerRechargeVerified(item) ||
isCoinSellerRechargeGranted(item)
}
size="small"
startIcon={<FactCheckOutlined fontSize="small" />}
variant="outlined"
onClick={() => onVerify(item)}
>
校验
</Button>
<Button
disabled={
!canGrant ||
actionLoading === `grant:${item.id}` ||
!isCoinSellerRechargeVerified(item) ||
isCoinSellerRechargeGranted(item)
}
size="small"
startIcon={<PaidOutlined fontSize="small" />}
variant="contained"
onClick={() => confirmGrant(item)}
>
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
</Button>
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={12} style={{ textAlign: "center" }}>
当前无数据
</td>
</tr>
)}
</tbody>
</table>
</div>
) : null}
{total > pageSize ? (
<div className="finance-pagination">
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
上一页
</Button>
<span>
{page} / {total}
</span>
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
下一页
</Button>
</div>
) : null}
<CreateDialog
apps={apps}
form={form}
formError={formError}
loading={actionLoading === "create"}
open={createOpen}
receiptLoading={receiptLoading}
receiptVerification={receiptVerification}
onChange={updateForm}
onClose={() => {
setCreateOpen(false);
setReceiptVerification(null);
setFormError("");
}}
onSubmit={submitCreate}
onVerifyReceipt={verifyCreateReceipt}
/>
</section>
);
}
function CreateDialog({
apps,
form,
formError,
loading,
onChange,
onClose,
onSubmit,
onVerifyReceipt,
open,
receiptLoading,
receiptVerification,
}) {
const disabled = loading || receiptLoading;
const canCreate = receiptVerification?.verified === true && !disabled;
return (
<Dialog fullWidth maxWidth={false} open={open} slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }} onClose={disabled ? undefined : onClose}>
<DialogTitle>创建币商充值</DialogTitle>
<DialogContent className="finance-create-dialog">
<form className="finance-form finance-form--dialog" id="coin-seller-recharge-order-form" onSubmit={onSubmit}>
{formError ? <Alert severity="error">{formError}</Alert> : null}
<div className={form.providerCode === "usdt" ? "finance-form-grid finance-form-grid--coin-seller-create" : "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"}>
{apps.length ? (
<TextField disabled={disabled} label="APP" required select value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)}>
{apps.map((app) => (
<MenuItem key={app.appCode} value={app.appCode}>
{app.appName || app.appCode}
</MenuItem>
))}
</TextField>
) : (
<TextField disabled={disabled} label="APP" required value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)} />
)}
<TextField disabled={disabled} label="目标用户 ID" required value={form.targetUserId} onChange={(event) => onChange("targetUserId", event.target.value)} />
<TextField
disabled={disabled}
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
inputProps={{ min: 0, step: "0.01" }}
label="USD 金额"
required
type="number"
value={form.usdAmount}
onChange={(event) => onChange("usdAmount", event.target.value)}
/>
<TextField
disabled={disabled}
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
inputProps={{ min: 0, step: "1" }}
label="充值金币"
required
type="number"
value={form.coinAmount}
onChange={(event) => onChange("coinAmount", event.target.value)}
/>
<TextField disabled={disabled} label="支付渠道" required select value={form.providerCode} onChange={(event) => onChange("providerCode", event.target.value)}>
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.providerCode === "usdt" ? (
<TextField disabled={disabled} label="USDT 链" required select value={form.chain} onChange={(event) => onChange("chain", event.target.value)}>
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : null}
<div className="finance-receipt-field">
<TextField disabled={disabled} label="三方订单号" required value={form.externalOrderNo} onChange={(event) => onChange("externalOrderNo", event.target.value)} />
<Button disabled={disabled || !onVerifyReceipt} startIcon={<FactCheckOutlined fontSize="small" />} variant="outlined" onClick={onVerifyReceipt}>
校验
</Button>
</div>
<TextField
disabled={disabled}
inputProps={{ maxLength: 512 }}
label="备注"
value={form.remark}
onChange={(event) => onChange("remark", event.target.value)}
/>
</div>
{receiptVerification ? <ReceiptVerificationBanner verification={receiptVerification} /> : null}
</form>
</DialogContent>
<DialogActions>
<Button disabled={disabled} variant="outlined" onClick={onClose}>
取消
</Button>
<Button disabled={!canCreate} form="coin-seller-recharge-order-form" type="submit" variant="contained">
创建
</Button>
</DialogActions>
</Dialog>
);
}
function isZeroCoinMakeupOrder(order) {
return Number(order?.coinAmount || 0) === 0;
}
function ReceiptVerificationBanner({ verification }) {
const detail = [
verification.status && `状态 ${verification.status}`,
verification.providerOrderId && `三方单 ${verification.providerOrderId}`,
verification.currencyCode && verification.providerAmountMinor !== undefined
? formatUsdMinor(verification.providerAmountMinor, verification.currencyCode)
: "",
verification.failureReason,
]
.filter(Boolean)
.join(" / ");
return (
<Alert className="finance-receipt-result" severity={verification.verified ? "success" : "error"}>
{verification.verified ? "校验通过" : "校验未通过"}
{detail ? `${detail}` : ""}
</Alert>
);
}
function StatusBadge({ status }) {
return <span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>{coinSellerRechargeStatusLabel(status)}</span>;
}
function Stacked({ primary, secondary }) {
return (
<span className="finance-stack">
<span>{primary || "-"}</span>
<small>{secondary || ""}</small>
</span>
);
}
function appName(appCode, apps) {
return apps.find((app) => app.appCode === appCode)?.appName || appCode || "-";
}
function usdText(item) {
if (item.usdAmount !== undefined && item.usdAmount !== null && item.usdAmount !== "") {
return `USD ${item.usdAmount}`;
}
return formatUsdMinor(item.usdMinorAmount, "USD");
}
function providerAmountText(item) {
return item.providerAmountMinor === null || item.providerAmountMinor === undefined
? "-"
: formatUsdMinor(item.providerAmountMinor, item.providerCurrencyCode || "USD");
}
function providerMeta(item) {
if (item.providerCode === "usdt") {
return [coinSellerRechargeChainLabel(item.chain), item.providerStatus].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "mifapay") {
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "v5pay") {
return [item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-";
}
return "-";
}
function walletMeta(item) {
return [
item.walletAssetType,
item.walletAmountDelta === null || item.walletAmountDelta === undefined ? "" : `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined ? "" : `余额 ${formatAmount(item.walletBalanceAfter)}`,
]
.filter(Boolean)
.join(" / ");
}

View File

@ -0,0 +1,170 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
afterEach(() => {
vi.unstubAllGlobals();
});
test("coin seller recharge order create dialog verifies receipt before create", async () => {
const onCreate = vi.fn(async () => ({ id: "order-2" }));
const onVerifyReceipt = vi.fn(async () => ({
currencyCode: "USD",
providerAmountMinor: 1000,
providerOrderId: "V5-001",
status: "paid",
verified: true,
}));
render(<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />);
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
fireEvent.click(screen.getByRole("option", { name: "V5Pay" }));
expect(screen.queryByRole("textbox", { name: "三方国家码" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "三方币种" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "支付类型" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "订单日期" })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } });
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "校验" }));
await screen.findByText(/校验通过/);
await waitFor(() => expect(screen.getByRole("button", { name: "创建" })).not.toBeDisabled());
expect(onVerifyReceipt).toHaveBeenCalledWith({
appCode: "lalu",
externalOrderNo: "V5-001",
providerCode: "v5pay",
usdAmount: 10,
});
fireEvent.click(screen.getByRole("button", { name: "创建" }));
await waitFor(() =>
expect(onCreate).toHaveBeenCalledWith({
appCode: "lalu",
coinAmount: 0,
externalOrderNo: "V5-001",
providerCode: "v5pay",
remark: "补单,不发金币",
targetUserId: "10001",
usdAmount: 10,
}),
);
});
test("coin seller recharge order actions follow verify and grant status", () => {
const onVerify = vi.fn();
const onGrant = vi.fn();
vi.stubGlobal("confirm", vi.fn(() => true));
render(
<FinanceCoinSellerRechargeOrderList
{...baseProps({
orders: {
items: [
orderFixture({ id: "pending", verifyStatus: "pending", grantStatus: "pending" }),
orderFixture({ id: "verified", verifyStatus: "verified", grantStatus: "pending" }),
],
page: 1,
pageSize: 50,
total: 2,
},
})}
onGrant={onGrant}
onVerify={onVerify}
/>,
);
const verifyButtons = screen.getAllByRole("button", { name: "校验" });
const grantButtons = screen.getAllByRole("button", { name: "发放" });
fireEvent.click(verifyButtons[0]);
fireEvent.click(grantButtons[1]);
expect(onVerify).toHaveBeenCalledWith(expect.objectContaining({ id: "pending" }));
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "verified" }));
expect(screen.getAllByText("人工补单")[0]).toBeInTheDocument();
expect(grantButtons[0]).toBeDisabled();
expect(verifyButtons[1]).toBeDisabled();
});
test("zero coin seller recharge order uses makeup completion action", () => {
const onGrant = vi.fn();
const confirm = vi.fn(() => true);
vi.stubGlobal("confirm", confirm);
render(
<FinanceCoinSellerRechargeOrderList
{...baseProps({
orders: {
items: [orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" })],
page: 1,
pageSize: 50,
total: 1,
},
})}
onGrant={onGrant}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "完成补单" }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining("不发放金币"));
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "makeup", coinAmount: 0 }));
});
function baseProps(overrides = {}) {
return {
actionLoading: "",
apps: [{ appCode: "lalu", appName: "lalu" }],
canCreate: true,
canGrant: true,
canVerify: true,
error: "",
filters: {
appCode: "",
grantStatus: "",
keyword: "",
page: 1,
providerCode: "",
status: "",
verifyStatus: "",
},
loading: false,
onCreate: vi.fn(),
onFiltersChange: vi.fn(),
onGrant: vi.fn(),
onReload: vi.fn(),
onVerify: vi.fn(),
onVerifyReceipt: vi.fn(),
orders: {
items: [orderFixture()],
page: 1,
pageSize: 50,
total: 1,
},
...overrides,
};
}
function orderFixture(overrides = {}) {
return {
appCode: "lalu",
coinAmount: 100000,
createdAtMs: 1760000000000,
externalOrderNo: "MIFA-001",
grantStatus: "pending",
id: "order-1",
providerCode: "mifapay",
remark: "人工补单",
status: "pending",
targetDisplayUserId: "165075",
targetUserId: "10001",
usdAmount: "10.00",
verifyStatus: "pending",
...overrides,
};
}

View File

@ -1,8 +1,12 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
const baseProps = {
apps: [
{ appCode: "lalu", appName: "lalu" },
{ appCode: "aslan", appName: "Aslan" },
],
bills: {
items: [],
page: 1,
@ -20,12 +24,6 @@ const baseProps = {
loading: false,
onFiltersChange: vi.fn(),
onReload: vi.fn(),
options: {
apps: [
{ appCode: "lalu", appName: "lalu" },
{ appCode: "aslan", appName: "Aslan" },
],
},
summary: {
googlePlay: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
thirdParty: { billCount: 0, coinAmount: 0, usdMinorAmount: 0 },
@ -46,18 +44,14 @@ test("hides coin recharge columns by default", () => {
/>,
);
expect(screen.getByRole("switch", { name: "金币显示" })).not.toBeChecked();
expect(screen.queryByRole("columnheader", { name: "充值金币" })).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "金币换算" })).not.toBeInTheDocument();
expect(screen.queryByText("90,000 金币")).not.toBeInTheDocument();
expect(screen.queryByText("90,000 金币 = USD 9.99")).not.toBeInTheDocument();
expect(screen.queryByText("1 笔 · 90,000 金币")).not.toBeInTheDocument();
expect(screen.getAllByText("1 笔")).toHaveLength(2);
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
});
test("renders app recharge coin columns and values when switch is enabled", () => {
test("renders app recharge coin columns and values when coin currency is selected", () => {
render(
<FinanceRechargeDetailList
{...baseProps}
@ -66,39 +60,34 @@ test("renders app recharge coin columns and values when switch is enabled", () =
items: [rechargeBillFixture()],
total: 1,
}}
currency="coin"
/>,
);
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值金币" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "金币换算" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
expect(screen.getByText("Aslan")).toBeInTheDocument();
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
expect(screen.getAllByText("1 笔 · 90,000 金币")).toHaveLength(2);
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("tx-google")).toBeInTheDocument();
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
expect(screen.getByText("90,000 金币 = USD 9.99")).toBeInTheDocument();
expect(screen.getAllByText("SAR 12.99")).toHaveLength(2);
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /充值时间 · 中国时区/ })).toBeInTheDocument();
});
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
expect(container.querySelector("tbody td")?.colSpan).toBe(7);
expect(screen.getByText("当前无数据")).toBeInTheDocument();
expect(container.querySelector("tbody td")?.colSpan).toBe(8);
expect(screen.getByText("当前筛选条件下没有账单")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "金币显示" }));
expect(container.querySelector("tbody td")?.colSpan).toBe(9);
const { container: coinContainer } = render(<FinanceRechargeDetailList {...baseProps} currency="coin" />);
expect(coinContainer.querySelector("tbody td")?.colSpan).toBe(9);
});
function rechargeBillFixture() {

View File

@ -7,6 +7,10 @@ import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined";
import LinearProgress from "@mui/material/LinearProgress";
const NAV_GROUPS = [
{
label: "订单",
views: [{ icon: ReceiptLongOutlined, id: "coinSellerRechargeOrders", label: "币商充值" }],
},
{
label: "经营",
views: [
@ -27,6 +31,7 @@ const NAV_GROUPS = [
const VIEW_TITLES = {
applications: "申请审核",
coinSellerRechargeOrders: "币商充值",
create: "发起申请",
overview: "财务概览",
recon: "渠道对账",
@ -39,6 +44,7 @@ export function FinanceShell({
appBar,
canAudit,
canCreate,
canViewCoinSellerRechargeOrders,
canViewRechargeDetails,
canViewWithdrawals,
children,
@ -52,6 +58,9 @@ export function FinanceShell({
if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") {
return canViewRechargeDetails;
}
if (viewId === "coinSellerRechargeOrders") {
return canViewCoinSellerRechargeOrders;
}
if (viewId === "applications") {
return canAudit;
}

View File

@ -2,6 +2,10 @@ export const FINANCE_PERMISSIONS = {
auditApplication: "finance-application:audit",
auditWithdrawalApplications: "finance-withdrawal:audit",
createApplication: "finance-application:create",
createCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:create",
grantCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:grant",
verifyCoinSellerRechargeOrder: "finance-order:coin-seller-recharge:verify",
viewCoinSellerRechargeOrders: "finance-order:coin-seller-recharge:view",
viewAppRechargeDetails: "payment-bill:view",
viewWithdrawalApplications: "finance-withdrawal:view",
};
@ -55,6 +59,37 @@ export const APPLICATION_STATUS = [
["rejected", "已拒绝"],
];
export const COIN_SELLER_RECHARGE_PROVIDERS = [
["mifapay", "MiFaPay"],
["v5pay", "V5Pay"],
["usdt", "USDT"],
];
export const COIN_SELLER_RECHARGE_CHAINS = [
["TRON", "TRON"],
["BSC", "BSC"],
];
export const COIN_SELLER_RECHARGE_STATUS = [
["pending", "待处理"],
["verified", "已校验"],
["granted", "已发放"],
["failed", "失败"],
["canceled", "已取消"],
];
export const COIN_SELLER_RECHARGE_VERIFY_STATUS = [
["pending", "待校验"],
["verified", "校验通过"],
["failed", "校验失败"],
];
export const COIN_SELLER_RECHARGE_GRANT_STATUS = [
["pending", "待发放"],
["granted", "已发放"],
["failed", "发放失败"],
];
export const DEFAULT_APPLICATION_FORM = {
appCode: "",
coinAmount: "",
@ -66,6 +101,17 @@ export const DEFAULT_APPLICATION_FORM = {
walletIdentity: "",
};
export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
appCode: "",
chain: "",
coinAmount: "",
externalOrderNo: "",
providerCode: "mifapay",
remark: "",
targetUserId: "",
usdAmount: "",
};
export function operationRequiresWalletIdentity(operation) {
return FINANCE_OPERATIONS.some((item) => item.value === operation && item.requiresWalletIdentity);
}

View File

@ -1,5 +1,10 @@
import {
APPLICATION_STATUS,
COIN_SELLER_RECHARGE_CHAINS,
COIN_SELLER_RECHARGE_GRANT_STATUS,
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
COIN_SELLER_RECHARGE_VERIFY_STATUS,
FINANCE_OPERATIONS,
WALLET_IDENTITIES,
operationAllowsZeroRechargeAmount,
@ -122,6 +127,110 @@ export function normalizeWithdrawalApplicationPage(data = {}) {
};
}
export function buildCoinSellerRechargeOrderPayload(form) {
const providerCode = stringValue(form.providerCode);
const payload = {
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
coinAmount: numberValue(form.coinAmount),
externalOrderNo: stringValue(form.externalOrderNo),
providerCode,
remark: stringValue(form.remark),
targetUserId: stringValue(form.targetUserId),
usdAmount: numberValue(form.usdAmount),
};
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== ""));
}
export function buildCoinSellerRechargeReceiptPayload(form) {
const providerCode = stringValue(form.providerCode);
return Object.fromEntries(
Object.entries({
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
externalOrderNo: stringValue(form.externalOrderNo),
providerCode,
usdAmount: numberValue(form.usdAmount),
}).filter(([, value]) => value !== ""),
);
}
export function validateCoinSellerRechargeOrderPayload(payload) {
if (!payload.appCode) {
return "请选择 APP";
}
if (!payload.targetUserId) {
return "请填写目标用户 ID";
}
if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) {
return "请填写大于 0 的 USD 金额";
}
if (!Number.isInteger(payload.coinAmount) || payload.coinAmount < 0) {
return "请填写不小于 0 的整数金币";
}
if (!payload.providerCode) {
return "请选择支付渠道";
}
if (!payload.externalOrderNo) {
return "请填写三方订单号";
}
if (payload.providerCode === "usdt" && !payload.chain) {
return "请选择 USDT 链";
}
if (payload.remark && payload.remark.length > 512) {
return "备注不能超过 512 字";
}
return "";
}
export function normalizeCoinSellerRechargeOrder(item = {}) {
return {
appCode: stringValue(item.appCode ?? item.app_code),
chain: stringValue(item.chain),
coinAmount: numberValue(item.coinAmount ?? item.coin_amount),
createdAtMs: numberOrNull(item.createdAtMs ?? item.created_at_ms),
externalOrderNo: stringValue(item.externalOrderNo ?? item.external_order_no),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
grantStatus: stringValue(item.grantStatus ?? item.grant_status) || "pending",
grantedAtMs: numberOrNull(item.grantedAtMs ?? item.granted_at_ms),
grantedByName: stringValue(item.grantedByName ?? item.granted_by_name),
id: stringValue(item.id ?? item.orderId ?? item.order_id),
operatorName: stringValue(item.operatorName ?? item.operator_name),
providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor),
providerCode: stringValue(item.providerCode ?? item.provider_code),
providerCurrencyCode: stringValue(item.providerCurrencyCode ?? item.provider_currency_code),
providerOrderId: stringValue(item.providerOrderId ?? item.provider_order_id),
providerPayloadJson: stringValue(item.providerPayloadJson ?? item.provider_payload_json),
providerStatus: stringValue(item.providerStatus ?? item.provider_status),
receiveAddress: stringValue(item.receiveAddress ?? item.receive_address),
remark: stringValue(item.remark),
status: stringValue(item.status) || "pending",
targetDisplayUserId: stringValue(item.targetDisplayUserId ?? item.target_display_user_id),
targetUserId: stringValue(item.targetUserId ?? item.target_user_id),
usdAmount: item.usdAmount ?? item.usd_amount ?? "",
usdMinorAmount: numberOrNull(item.usdMinorAmount ?? item.usd_minor_amount),
verifiedAtMs: numberOrNull(item.verifiedAtMs ?? item.verified_at_ms),
verifiedByName: stringValue(item.verifiedByName ?? item.verified_by_name),
verifyStatus: stringValue(item.verifyStatus ?? item.verify_status) || "pending",
walletAmountDelta: numberOrNull(item.walletAmountDelta ?? item.wallet_amount_delta),
walletAssetType: stringValue(item.walletAssetType ?? item.wallet_asset_type),
walletBalanceAfter: numberOrNull(item.walletBalanceAfter ?? item.wallet_balance_after),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
};
}
export function normalizeCoinSellerRechargeOrderPage(data = {}) {
const items = Array.isArray(data.items) ? data.items : [];
return {
items: items.map(normalizeCoinSellerRechargeOrder),
page: Number(data.page || 1),
pageSize: Number(data.pageSize ?? data.page_size ?? items.length),
total: Number(data.total ?? items.length),
};
}
export function normalizeRechargeBill(item = {}) {
return {
appCode: stringValue(item.appCode ?? item.app_code),
@ -414,6 +523,50 @@ export function statusLabel(value) {
return APPLICATION_STATUS.find(([status]) => status === value)?.[1] || value || "-";
}
export function coinSellerRechargeProviderLabel(value) {
return COIN_SELLER_RECHARGE_PROVIDERS.find(([code]) => code === value)?.[1] || value || "-";
}
export function coinSellerRechargeChainLabel(value) {
return COIN_SELLER_RECHARGE_CHAINS.find(([code]) => code === value)?.[1] || value || "-";
}
export function coinSellerRechargeStatusLabel(value) {
return COIN_SELLER_RECHARGE_STATUS.find(([status]) => status === value)?.[1] || value || "-";
}
export function coinSellerRechargeVerifyStatusLabel(value) {
return COIN_SELLER_RECHARGE_VERIFY_STATUS.find(([status]) => status === value)?.[1] || value || "-";
}
export function coinSellerRechargeGrantStatusLabel(value) {
return COIN_SELLER_RECHARGE_GRANT_STATUS.find(([status]) => status === value)?.[1] || value || "-";
}
export function coinSellerRechargeStatusTone(value) {
if (["granted", "verified", "success", "succeeded", "completed", "passed"].includes(stringValue(value))) {
return "success";
}
if (["failed", "rejected", "canceled", "cancelled"].includes(stringValue(value))) {
return "danger";
}
return "warning";
}
export function isCoinSellerRechargeVerified(order) {
return (
["verified", "success", "succeeded", "completed", "passed"].includes(stringValue(order?.verifyStatus)) ||
stringValue(order?.status) === "verified"
);
}
export function isCoinSellerRechargeGranted(order) {
return (
["granted", "success", "succeeded", "completed"].includes(stringValue(order?.grantStatus)) ||
stringValue(order?.status) === "granted"
);
}
export function walletIdentityLabel(value) {
return WALLET_IDENTITIES.find(([identity]) => identity === value)?.[1] || value || "-";
}

View File

@ -1,13 +1,18 @@
import { expect, test } from "vitest";
import {
buildApplicationPayload,
buildCoinSellerRechargeReceiptPayload,
buildCoinSellerRechargeOrderPayload,
coinSellerRechargeProviderLabel,
normalizeApplicationPage,
normalizeCoinSellerRechargeOrderPage,
normalizeRechargeBillPage,
normalizeWithdrawalApplicationPage,
operationLabel,
rechargeTypeLabel,
statusLabel,
validateApplicationPayload,
validateCoinSellerRechargeOrderPayload,
walletIdentityLabel,
} from "./format.js";
@ -160,6 +165,84 @@ test("normalizes finance withdrawal application list rows", () => {
});
});
test("builds validates and normalizes coin seller recharge orders", () => {
const payload = buildCoinSellerRechargeOrderPayload({
appCode: " lalu ",
coinAmount: "100000",
externalOrderNo: " MIFA-001 ",
providerCode: "mifapay",
remark: " 人工补单 ",
targetUserId: " 10001 ",
usdAmount: "10.5",
});
expect(payload).toEqual({
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerCode: "mifapay",
remark: "人工补单",
targetUserId: "10001",
usdAmount: 10.5,
});
expect(
buildCoinSellerRechargeReceiptPayload({
...payload,
coinAmount: "",
targetUserId: "",
}),
).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerCode: "mifapay",
usdAmount: 10.5,
});
expect(validateCoinSellerRechargeOrderPayload(payload)).toBe("");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: 0 })).toBe("");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, coinAmount: -1 })).toBe("请填写不小于 0 的整数金币");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, remark: "x".repeat(513) })).toBe("备注不能超过 512 字");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCode: "usdt", chain: "" })).toBe(
"请选择 USDT 链",
);
const page = normalizeCoinSellerRechargeOrderPage({
items: [
{
app_code: "lalu",
coin_amount: 100000,
created_at_ms: 1760000000000,
external_order_no: "MIFA-001",
grant_status: "pending",
id: "order-1",
provider_code: "mifapay",
remark: "人工补单",
target_display_user_id: "165075",
target_user_id: "10001",
usd_amount: "10.50",
verify_status: "verified",
wallet_transaction_id: "wallet-tx",
},
],
page: 1,
page_size: 50,
total: 1,
});
expect(page.items[0]).toMatchObject({
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
id: "order-1",
providerCode: "mifapay",
remark: "人工补单",
targetDisplayUserId: "165075",
targetUserId: "10001",
verifyStatus: "verified",
walletTransactionId: "wallet-tx",
});
expect(coinSellerRechargeProviderLabel("mifapay")).toBe("MiFaPay");
});
test("normalizes app recharge detail list rows with optional payment fields", () => {
const page = normalizeRechargeBillPage({
items: [

View File

@ -386,6 +386,13 @@ a {
font-size: 12px;
}
.finance-remark-cell {
max-width: 180px;
color: var(--text-secondary);
white-space: normal;
word-break: break-word;
}
.finance-pagination__meta {
color: var(--text-secondary);
}
@ -528,6 +535,11 @@ a {
border-radius: var(--radius-card) !important;
}
.finance-create-dialog-paper--coin-seller {
width: min(760px, calc(100vw - 64px));
max-width: min(760px, calc(100vw - 64px)) !important;
}
.finance-create-dialog-paper .MuiDialogTitle-root {
padding: var(--space-5) var(--space-5) var(--space-2);
font-size: 18px;
@ -544,6 +556,25 @@ a {
gap: var(--space-3);
}
.finance-form--dialog .finance-form-grid--coin-seller-create {
grid-template-columns: minmax(0, 1fr);
}
.finance-form--dialog .finance-form-grid--coin-seller-no-chain {
grid-template-columns: minmax(0, 1fr);
}
.finance-receipt-field {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
}
.finance-receipt-result {
align-items: center;
}
.finance-form--dialog .finance-evidence-grid {
grid-template-columns: 320px minmax(0, 1fr);
gap: var(--space-4);
@ -648,6 +679,8 @@ a {
}
.finance-form--dialog .finance-form-grid,
.finance-form--dialog .finance-form-grid--coin-seller-create,
.finance-form--dialog .finance-form-grid--coin-seller-no-chain,
.finance-form--dialog .finance-evidence-grid {
grid-template-columns: 1fr;
}
@ -1072,6 +1105,10 @@ a {
min-width: 1180px;
}
.finance-flat-table--orders {
min-width: 1740px;
}
.finance-views {
display: flex;
gap: 2px;

View File

@ -47,6 +47,10 @@ export const PERMISSIONS = {
financeApplicationAudit: "finance-application:audit",
financeWithdrawalView: "finance-withdrawal:view",
financeWithdrawalAudit: "finance-withdrawal:audit",
financeOrderCoinSellerRechargeView: "finance-order:coin-seller-recharge:view",
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
financeOperationUserCoinCredit: "finance-operation:user-coin-credit",
financeOperationUserCoinDebit: "finance-operation:user-coin-debit",
financeOperationUserWalletCredit: "finance-operation:user-wallet-credit",

View File

@ -44,6 +44,8 @@ export const resourceGrantStatusLabels = {
export const resourceGrantSubjectLabels = {
resource: "资源",
resource_group: "资源组",
vip: "VIP",
vip_level: "VIP等级",
};
export const emojiPackPricingLabels = {

View File

@ -0,0 +1,206 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useResourceGrantListPage } from "./useResourcePages.js";
const mocks = vi.hoisted(() => ({
can: vi.fn(),
getVipConfig: vi.fn(),
grantResource: vi.fn(),
grantResourceGroup: vi.fn(),
grantVip: vi.fn(),
listGiftTypes: vi.fn(),
listGifts: vi.fn(),
listResourceGroups: vi.fn(),
listResources: vi.fn(),
lookupResourceGrantTarget: vi.fn(),
reload: vi.fn(),
showToast: vi.fn(),
usePaginatedQuery: vi.fn(),
}));
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
useAuth: () => ({ can: mocks.can }),
}));
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
usePaginatedQuery: mocks.usePaginatedQuery,
}));
vi.mock("@/shared/ui/ConfirmProvider.jsx", () => ({
useConfirm: () => vi.fn(),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: () => ({ showToast: mocks.showToast }),
}));
vi.mock("@/features/resources/api", async (importOriginal) => ({
...(await importOriginal()),
grantResource: mocks.grantResource,
grantResourceGroup: mocks.grantResourceGroup,
listGiftTypes: mocks.listGiftTypes,
listGifts: mocks.listGifts,
listResourceGroups: mocks.listResourceGroups,
listResources: mocks.listResources,
lookupResourceGrantTarget: mocks.lookupResourceGrantTarget,
}));
vi.mock("@/features/vip-config/api", () => ({
getVipConfig: mocks.getVipConfig,
grantVip: mocks.grantVip,
}));
beforeEach(() => {
mocks.can.mockReturnValue(true);
mocks.getVipConfig.mockResolvedValue({ levels: [{ level: 5, name: "VIP5", status: "active" }] });
mocks.grantResource.mockResolvedValue({});
mocks.grantResourceGroup.mockResolvedValue({});
mocks.grantVip.mockResolvedValue({ vip: { level: 5, name: "VIP5" } });
mocks.listGiftTypes.mockResolvedValue([]);
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
mocks.listResources.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
mocks.lookupResourceGrantTarget.mockResolvedValue({ userId: "318705991371722752" });
mocks.reload.mockResolvedValue(undefined);
mocks.usePaginatedQuery.mockReturnValue({
data: { items: [], page: 1, pageSize: 50, total: 0 },
error: null,
loading: false,
reload: mocks.reload,
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("vip grant resolves target user before calling vip grant api", async () => {
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.setForm(grantFormFixture({ subjectType: "vip", vipLevel: "5" }));
});
await act(async () => {
await result.current.submitGrant({ preventDefault: vi.fn() });
});
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
expect(mocks.grantVip).toHaveBeenCalledWith({
level: 5,
reason: "manual",
targetUserId: "318705991371722752",
});
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
mocks.grantVip.mock.invocationCallOrder[0],
);
expect(mocks.grantResource).not.toHaveBeenCalled();
expect(mocks.grantResourceGroup).not.toHaveBeenCalled();
});
test("resource grant branch still calls resource grant api", async () => {
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.setForm(
grantFormFixture({
durationDays: "7",
quantity: "2",
resourceIds: ["11"],
subjectType: "resource",
}),
);
});
await act(async () => {
await result.current.submitGrant({ preventDefault: vi.fn() });
});
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
expect(mocks.grantResource).toHaveBeenCalledWith(
expect.objectContaining({
durationMs: 7 * 24 * 60 * 60 * 1000,
quantity: 2,
reason: "manual",
resourceId: 11,
targetUserId: "318705991371722752",
}),
);
expect(mocks.grantResource.mock.calls[0][0].commandId).toMatch(/^resource-grant-11-0-/);
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
mocks.grantResource.mock.invocationCallOrder[0],
);
expect(mocks.grantVip).not.toHaveBeenCalled();
expect(mocks.grantResourceGroup).not.toHaveBeenCalled();
});
test("resource group grant branch still calls resource group grant api", async () => {
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.setForm(grantFormFixture({ groupId: "22", subjectType: "resource_group" }));
});
await act(async () => {
await result.current.submitGrant({ preventDefault: vi.fn() });
});
expect(mocks.lookupResourceGrantTarget).toHaveBeenCalledWith("163000");
expect(mocks.grantResourceGroup).toHaveBeenCalledWith(
expect.objectContaining({
groupId: 22,
reason: "manual",
targetUserId: "318705991371722752",
}),
);
expect(mocks.grantResourceGroup.mock.calls[0][0].commandId).toMatch(/^resource-group-grant-/);
expect(mocks.lookupResourceGrantTarget.mock.invocationCallOrder[0]).toBeLessThan(
mocks.grantResourceGroup.mock.invocationCallOrder[0],
);
expect(mocks.grantVip).not.toHaveBeenCalled();
expect(mocks.grantResource).not.toHaveBeenCalled();
});
test("create dialog loads only vip levels when user only has vip grant permission", async () => {
mocks.can.mockImplementation((permission) => permission === "vip-config:grant");
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.openCreateGrant();
});
await waitFor(() => expect(mocks.getVipConfig).toHaveBeenCalledTimes(1));
expect(result.current.form.subjectType).toBe("vip");
expect(mocks.listResources).not.toHaveBeenCalled();
expect(mocks.listGifts).not.toHaveBeenCalled();
expect(mocks.listGiftTypes).not.toHaveBeenCalled();
expect(mocks.listResourceGroups).not.toHaveBeenCalled();
});
test("create dialog skips vip config when user lacks vip grant permission", async () => {
mocks.can.mockImplementation((permission) => permission !== "vip-config:grant");
const { result } = renderHook(() => useResourceGrantListPage());
await act(async () => {
result.current.openCreateGrant();
});
await waitFor(() => expect(mocks.listResources).toHaveBeenCalled());
expect(result.current.form.subjectType).toBe("resource");
expect(mocks.getVipConfig).not.toHaveBeenCalled();
});
function grantFormFixture(patch = {}) {
return {
durationDays: "7",
groupId: "",
quantity: "1",
reason: "manual",
resourceId: "",
resourceIds: [],
subjectType: "resource",
targetUserId: "163000",
vipLevel: "",
...patch,
};
}

View File

@ -43,6 +43,7 @@ import {
updateResourceGroup,
upsertResourceShopItems,
} from "@/features/resources/api";
import { getVipConfig, grantVip } from "@/features/vip-config/api";
import {
cpRelationTypeLabels,
cpRelationTypeOptions,
@ -313,6 +314,15 @@ export async function fetchAllOptionPages(fetcher, query = {}, pageSize = option
throw new Error("资源选项分页过多,请收窄筛选条件");
}
function loadResourceGrantOptions() {
return Promise.all([
fetchAllOptionPages(listResources, { status: "active" }),
fetchAllOptionPages(listGifts, { status: "active" }),
listGiftTypes(),
fetchAllOptionPages(listResourceGroups, { status: "active" }),
]).then(([resources, gifts, giftTypes, groups]) => ({ gifts, giftTypes, groups, resources }));
}
function parseGiftPresentationObject(presentationJson) {
const rawValue = String(presentationJson || "").trim();
if (!rawValue) {
@ -366,6 +376,7 @@ const emptyGrantForm = () => ({
resourceIds: [],
subjectType: "resource",
targetUserId: "",
vipLevel: "",
});
function groupItemToForm(item) {
@ -1840,6 +1851,7 @@ export function useResourceGrantListPage() {
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
const [groupOptions, setGroupOptions] = useState([]);
const [resourceOptions, setResourceOptions] = useState([]);
const [vipLevelOptions, setVipLevelOptions] = useState([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
@ -1868,28 +1880,53 @@ export function useResourceGrantListPage() {
}
let ignore = false;
setOptionsLoading(true);
Promise.all([
fetchAllOptionPages(listResources, { status: "active" }),
fetchAllOptionPages(listGifts, { status: "active" }),
listGiftTypes(),
fetchAllOptionPages(listResourceGroups, { status: "active" }),
])
.then(([resources, gifts, giftTypes, groups]) => {
if (!ignore) {
setResourceOptions(
(resources.items || []).filter(
(resource) => resource.status !== "disabled" && resource.grantable !== false,
),
);
setGiftOptions((gifts.items || []).filter((gift) => gift.resourceId && gift.status !== "disabled"));
setGiftTypeOptions(mergeGiftTypeOptions(giftTypes));
setGroupOptions(groups.items || []);
}
})
.catch((err) => {
if (!ignore) {
showToast(err.message || "加载资源选项失败", "error");
const optionRequests = [];
if (abilities.canCreateGrant) {
optionRequests.push(loadResourceGrantOptions().then((data) => ({ data, type: "resource" })));
} else {
setResourceOptions([]);
setGiftOptions([]);
setGiftTypeOptions(defaultGiftTypeOptions);
setGroupOptions([]);
}
if (abilities.canGrantVip) {
optionRequests.push(getVipConfig().then((data) => ({ data, type: "vip" })));
} else {
setVipLevelOptions([]);
}
Promise.allSettled(optionRequests)
.then((results) => {
if (ignore) {
return;
}
results.forEach((result) => {
if (result.status === "rejected") {
showToast(result.reason?.message || "加载资源选项失败", "error");
return;
}
if (result.value.type === "resource") {
const { gifts, giftTypes, groups, resources } = result.value.data;
setResourceOptions(
(resources.items || []).filter(
(resource) => resource.status !== "disabled" && resource.grantable !== false,
),
);
setGiftOptions(
(gifts.items || []).filter((gift) => gift.resourceId && gift.status !== "disabled"),
);
setGiftTypeOptions(mergeGiftTypeOptions(giftTypes));
setGroupOptions(groups.items || []);
return;
}
const activeVipLevels = (result.value.data.levels || []).filter((level) => level.status === "active");
setVipLevelOptions(activeVipLevels);
setForm((current) => {
if (current.subjectType !== "vip" || current.vipLevel || !activeVipLevels.length) {
return current;
}
return { ...current, vipLevel: String(activeVipLevels[0].level) };
});
});
})
.finally(() => {
if (!ignore) {
@ -1899,10 +1936,10 @@ export function useResourceGrantListPage() {
return () => {
ignore = true;
};
}, [activeAction, showToast]);
}, [abilities.canCreateGrant, abilities.canGrantVip, activeAction, showToast]);
const openCreateGrant = () => {
setForm(emptyGrantForm());
setForm({ ...emptyGrantForm(), subjectType: defaultGrantSubjectType(abilities) });
setActiveAction("create");
};
@ -1912,12 +1949,15 @@ export function useResourceGrantListPage() {
const submitGrant = async (event) => {
event.preventDefault();
if (!abilities.canCreateGrant) {
if (!abilities.canCreateGrant && !abilities.canGrantVip) {
return;
}
setLoadingAction("grant-create");
try {
const parsedForm = parseForm(resourceGrantFormSchema, form);
if (!canSubmitGrantSubject(parsedForm.subjectType, abilities)) {
return;
}
const targetUser = await lookupResourceGrantTarget(parsedForm.targetUserId);
const targetUserId = normalizedResolvedUserId(targetUser);
const payload = buildGrantPayload(parsedForm, targetUserId);
@ -1925,10 +1965,12 @@ export function useResourceGrantListPage() {
for (const resourcePayload of payload.resources) {
await grantResource(resourcePayload);
}
} else {
} else if (payload.subjectType === "resource_group") {
await grantResourceGroup(payload.group);
} else {
await grantVip(payload.vip);
}
showToast("资源已赠送", "success");
showToast(payload.subjectType === "vip" ? "VIP已赠送" : "资源已赠送", "success");
closeAction();
setForm(emptyGrantForm());
await result.reload();
@ -1944,7 +1986,7 @@ export function useResourceGrantListPage() {
!abilities.canRevokeGrant ||
!grant?.grantId ||
grant.status !== "succeeded" ||
!isRevocableGrantSubject(grant.grantSubjectType)
!isRevocableGrant(grant)
) {
return;
}
@ -2002,6 +2044,7 @@ export function useResourceGrantListPage() {
status,
submitGrant,
targetFilter,
vipLevelOptions,
};
}
@ -2348,6 +2391,16 @@ function buildGrantPayload(form, resolvedTargetUserId) {
subjectType: "resource",
};
}
if (form.subjectType === "vip") {
return {
subjectType: "vip",
vip: {
level: Number(form.vipLevel),
reason,
targetUserId,
},
};
}
return {
group: {
commandId: makeCommandId("resource-group-grant"),
@ -2366,8 +2419,31 @@ function grantResourceIds(form) {
return form.resourceId ? [form.resourceId] : [];
}
function isRevocableGrantSubject(subjectType) {
return subjectType === "resource" || subjectType === "resource_group";
function isRevocableGrant(grant) {
if (isVipGrantRecord(grant)) {
return false;
}
return grant?.grantSubjectType === "resource" || grant?.grantSubjectType === "resource_group";
}
function isVipGrantRecord(grant) {
const source = String(grant?.grantSource || "").trim();
const commandId = String(grant?.commandId || "").trim();
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:");
}
function canSubmitGrantSubject(subjectType, abilities) {
if (subjectType === "vip") {
return Boolean(abilities.canGrantVip);
}
return Boolean(abilities.canCreateGrant);
}
function defaultGrantSubjectType(abilities) {
if (abilities.canCreateGrant) {
return "resource";
}
return abilities.canGrantVip ? "vip" : "resource";
}
function makeCommandId(prefix) {

View File

@ -34,7 +34,7 @@ const grantColumns = [
width: "minmax(220px, 1fr)",
render: (grant) => (
<div className={styles.stack}>
<span className={styles.name}>{grantSourceLabel(grant.grantSource)}</span>
<span className={styles.name}>{grantSourceLabel(grant.grantSource, grant)}</span>
</div>
),
},
@ -127,7 +127,7 @@ export function ResourceGrantListPage() {
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canCreateGrant ? (
page.abilities.canCreateGrant || page.abilities.canGrantVip ? (
<AdminActionIconButton label="资源赠送" primary onClick={page.openCreateGrant}>
<Add fontSize="small" />
</AdminActionIconButton>
@ -155,7 +155,8 @@ export function ResourceGrantListPage() {
</AdminListBody>
</DataState>
<ResourceGrantDialog
disabled={!page.abilities.canCreateGrant}
abilities={page.abilities}
disabled={!page.abilities.canCreateGrant && !page.abilities.canGrantVip}
form={page.form}
giftOptions={page.giftOptions}
giftTypeOptions={page.giftTypeOptions}
@ -165,6 +166,7 @@ export function ResourceGrantListPage() {
optionsLoading={page.optionsLoading}
resourceOptions={page.resourceOptions}
setForm={page.setForm}
vipLevelOptions={page.vipLevelOptions}
onClose={page.closeAction}
onSubmit={page.submitGrant}
/>
@ -176,7 +178,7 @@ function ResourceGrantActions({ grant, page }) {
const canRevoke =
page.abilities.canRevokeGrant &&
grant?.status === "succeeded" &&
isRevocableGrantSubject(grant?.grantSubjectType);
isRevocableGrant(grant);
if (!canRevoke) {
return <span className={styles.meta}>-</span>;
}
@ -197,6 +199,7 @@ function ResourceGrantActions({ grant, page }) {
}
function ResourceGrantDialog({
abilities,
disabled,
form,
giftOptions,
@ -209,8 +212,30 @@ function ResourceGrantDialog({
optionsLoading,
resourceOptions,
setForm,
vipLevelOptions,
}) {
const submitDisabled = disabled || loading || optionsLoading;
const resourceGrantAllowed = Boolean(abilities?.canCreateGrant);
const vipGrantAllowed = Boolean(abilities?.canGrantVip);
const subjectAllowed = canEditGrantSubject(form.subjectType, abilities);
const hasVipLevels = (vipLevelOptions || []).length > 0;
const currentVipLevel = String(form.vipLevel || "");
const vipLevelValue = (vipLevelOptions || []).some((level) => String(level.level) === currentVipLevel)
? currentVipLevel
: "";
const vipUnavailable = form.subjectType === "vip" && (!vipGrantAllowed || !hasVipLevels);
const submitDisabled = disabled || loading || optionsLoading || !subjectAllowed || vipUnavailable;
const handleSubjectChange = (event) => {
const subjectType = event.target.value;
setForm({
...form,
groupId: "",
resourceId: "",
resourceIds: [],
subjectType,
vipLevel: subjectType === "vip" ? String((vipLevelOptions || [])[0]?.level || "") : "",
});
};
return (
<AdminFormDialog
loading={loading}
@ -222,7 +247,7 @@ function ResourceGrantDialog({
>
<AdminFormSection title="赠送对象">
<TextField
disabled={disabled}
disabled={disabled || loading || !subjectAllowed}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="用户 ID / 短号"
required
@ -238,24 +263,17 @@ function ResourceGrantDialog({
required
select
value={form.subjectType}
onChange={(event) =>
setForm({
...form,
groupId: "",
resourceId: "",
resourceIds: [],
subjectType: event.target.value,
})
}
onChange={handleSubjectChange}
>
<MenuItem value="resource">资源</MenuItem>
<MenuItem value="resource_group">资源组</MenuItem>
{resourceGrantAllowed ? <MenuItem value="resource">资源</MenuItem> : null}
{resourceGrantAllowed ? <MenuItem value="resource_group">资源组</MenuItem> : null}
{vipGrantAllowed ? <MenuItem value="vip">VIP</MenuItem> : null}
</TextField>
{form.subjectType === "resource" ? (
<>
<GiftResourceSelectField
drawerTitle="选择资源"
disabled={disabled || optionsLoading}
disabled={disabled || optionsLoading || !resourceGrantAllowed}
giftTypes={giftTypeOptions}
gifts={giftOptions}
label="资源"
@ -268,7 +286,7 @@ function ResourceGrantDialog({
}
/>
<TextField
disabled={disabled}
disabled={disabled || !resourceGrantAllowed}
label="数量"
required
type="number"
@ -276,16 +294,17 @@ function ResourceGrantDialog({
onChange={(event) => setForm({ ...form, quantity: event.target.value })}
/>
<TextField
disabled={disabled}
disabled={disabled || !resourceGrantAllowed}
label="有效天数"
type="number"
value={form.durationDays}
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
/>
</>
) : (
) : null}
{form.subjectType === "resource_group" ? (
<TextField
disabled={disabled || optionsLoading}
disabled={disabled || optionsLoading || !resourceGrantAllowed}
label="资源组"
required
select
@ -298,12 +317,35 @@ function ResourceGrantDialog({
</MenuItem>
))}
</TextField>
)}
) : null}
{form.subjectType === "vip" ? (
<TextField
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
helperText={!hasVipLevels && !optionsLoading ? "暂无启用VIP等级" : undefined}
label="VIP等级"
required
select
value={vipLevelValue}
onChange={(event) => setForm({ ...form, vipLevel: event.target.value })}
>
{hasVipLevels ? (
(vipLevelOptions || []).map((level) => (
<MenuItem key={level.level} value={String(level.level)}>
VIP{level.level} · {level.name || `VIP${level.level}`}
</MenuItem>
))
) : (
<MenuItem disabled value="">
暂无启用VIP等级
</MenuItem>
)}
</TextField>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="备注">
<TextField
disabled={disabled}
disabled={disabled || loading || !subjectAllowed}
label="原因"
multiline
minRows={2}
@ -375,8 +417,10 @@ function GrantItemMedia({ item }) {
}
function GrantSubject({ grant }) {
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const sourceLabel = grantSourceLabel(grant.grantSource);
const subjectLabel = isVipGrantRecord(grant)
? "VIP奖励资源组"
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
const sourceLabel = grantSourceLabel(grant.grantSource, grant);
return (
<div className={styles.stack}>
<span className={styles.name}>{sourceLabel === "-" ? subjectLabel : `${subjectLabel} · ${sourceLabel}`}</span>
@ -388,10 +432,11 @@ function GrantSubject({ grant }) {
function GrantReason({ grant }) {
const label = reasonLabel(grant.reason);
const raw = String(grant.reason || "").trim();
const showRawReason = raw && raw !== label && !grantReasonLabels[raw];
return (
<div className={styles.stack}>
<span className={styles.name}>{label}</span>
{raw && raw !== label ? <span className={styles.meta}>{raw}</span> : null}
{showRawReason ? <span className={styles.meta}>{raw}</span> : null}
</div>
);
}
@ -427,7 +472,9 @@ function GrantOperator({ grant }) {
const grantSourceLabels = {
achievement: "成就奖励",
admin: "后台发放",
admin_grant: "后台VIP赠送",
agency_opening: "Agency 开通",
activity_grant: "活动VIP赠送",
cp_weekly_rank: "CP 周榜奖励",
cumulative_recharge_reward: "累充奖励",
first_recharge_reward: "首充奖励",
@ -438,6 +485,7 @@ const grantSourceLabels = {
resource_shop: "道具商店",
room_rocket: "房间火箭",
seven_day_checkin: "七日签到",
vip_purchase: "VIP购买",
weekly_star: "周星奖励",
wheel_reward: "转盘奖励",
};
@ -448,9 +496,12 @@ const grantReasonLabels = {
"resource shop purchase": "道具商店购买",
};
function grantSourceLabel(source) {
function grantSourceLabel(source, grant) {
const key = String(source || "").trim();
return grantSourceLabels[key] || key || "-";
if (grantSourceLabels[key]) {
return grantSourceLabels[key];
}
return isVipRewardCommand(grant) ? "VIP奖励" : key || "-";
}
function reasonLabel(reason) {
@ -459,12 +510,44 @@ function reasonLabel(reason) {
}
function subjectMeta(grant) {
if (isVipGrantRecord(grant)) {
return grant.grantSubjectId ? `奖励资源组 ID ${grant.grantSubjectId}` : "-";
}
if (grant.grantSubjectType === "vip_level") {
return grant.grantSubjectId ? `VIP等级 ${grant.grantSubjectId}` : "-";
}
const subjectLabel = resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "对象";
return grant.grantSubjectId ? `${subjectLabel} ID ${grant.grantSubjectId}` : "-";
}
function isRevocableGrantSubject(subjectType) {
return subjectType === "resource" || subjectType === "resource_group";
function isRevocableGrant(grant) {
if (isVipGrantRecord(grant)) {
return false;
}
return grant?.grantSubjectType === "resource" || grant?.grantSubjectType === "resource_group";
}
function isVipGrantRecord(grant) {
const source = String(grant?.grantSource || "").trim();
return (
source === "admin_grant" ||
source === "activity_grant" ||
source === "vip_purchase" ||
isVipRewardCommand(grant)
);
}
function isVipRewardCommand(grant) {
return String(grant?.commandId || "")
.trim()
.startsWith("vip_reward:");
}
function canEditGrantSubject(subjectType, abilities) {
if (subjectType === "vip") {
return Boolean(abilities?.canGrantVip);
}
return Boolean(abilities?.canCreateGrant);
}
function grantItemLabel(item, snapshot = parseGrantItemSnapshot(item.resourceSnapshotJson)) {

View File

@ -161,11 +161,78 @@ test("resource grant list keeps operator only for user initiated manager grants"
expect(screen.getByText("Manager A")).toBeInTheDocument();
});
test("resource grant dialog shows vip subject and level selector", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
activeAction: "create",
form: grantFormFixture({ subjectType: "vip", vipLevel: "5" }),
vipLevelOptions: [{ level: 5, name: "黄金VIP", status: "active" }],
}),
);
render(<ResourceGrantListPage />);
expect(screen.getByText("VIP等级")).toBeInTheDocument();
expect(screen.getByText("VIP5 · 黄金VIP")).toBeInTheDocument();
});
test("resource grant dialog hides vip subject without vip grant permission", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
abilities: { canCreateGrant: true, canGrantVip: false },
activeAction: "create",
form: grantFormFixture({ subjectType: "resource" }),
}),
);
render(<ResourceGrantListPage />);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "对象类型" }));
expect(screen.queryByRole("option", { name: "VIP" })).not.toBeInTheDocument();
expect(screen.queryByText("VIP等级")).not.toBeInTheDocument();
});
test("resource grant list renders vip source records and hides revoke", () => {
vi.mocked(useResourceGrantListPage).mockReturnValue(
pageFixture({
data: {
items: [
grantFixture({
commandId: "vip_reward:admin_vip_grant:rgr_vip",
grantId: "rgr_vip",
grantSource: "admin_grant",
grantSubjectId: "22",
grantSubjectType: "resource_group",
reason: "admin_grant",
status: "succeeded",
}),
],
page: 1,
pageSize: 50,
total: 1,
},
}),
);
render(<ResourceGrantListPage />);
expect(screen.getAllByText("后台VIP赠送").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("VIP奖励资源组 · 后台VIP赠送")).toBeInTheDocument();
expect(screen.getByText("奖励资源组 ID 22")).toBeInTheDocument();
expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument();
expect(screen.queryByText("admin_grant")).not.toBeInTheDocument();
expect(screen.queryByText("vip_reward:admin_vip_grant:rgr_vip")).not.toBeInTheDocument();
});
function pageFixture(patch = {}) {
const { abilities: abilityPatch, form: formPatch, ...restPatch } = patch;
return {
abilities: {
canCreateGrant: true,
canGrantVip: true,
canRevokeGrant: true,
...abilityPatch,
},
activeAction: "",
changeQuery: vi.fn(),
@ -173,15 +240,7 @@ function pageFixture(patch = {}) {
closeAction: vi.fn(),
data: { items: [], page: 1, pageSize: 50, total: 0 },
error: null,
form: {
durationDays: "0",
giftIds: [],
groupId: "",
reason: "",
resourceIds: [],
subjectType: "resource",
targetUserId: "",
},
form: grantFormFixture(formPatch),
giftOptions: [],
giftTypeOptions: [],
groupOptions: [],
@ -199,6 +258,23 @@ function pageFixture(patch = {}) {
setPage: vi.fn(),
status: "",
submitGrant: vi.fn(),
vipLevelOptions: [],
...restPatch,
};
}
function grantFormFixture(patch = {}) {
return {
durationDays: "7",
giftIds: [],
groupId: "",
quantity: "1",
reason: "",
resourceId: "",
resourceIds: [],
subjectType: "resource",
targetUserId: "",
vipLevel: "",
...patch,
};
}

View File

@ -9,6 +9,7 @@ export function useResourceAbilities() {
canCreateGift: can(PERMISSIONS.giftCreate),
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
canGrantVip: can(PERMISSIONS.vipConfigGrant),
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
canDelete: can(PERMISSIONS.resourceDelete),

View File

@ -446,8 +446,9 @@ export const resourceGrantFormSchema = z
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
resourceId: z.union([z.string(), z.number()]).optional(),
resourceIds: z.array(z.union([z.string(), z.number()])).optional(),
subjectType: z.enum(["resource", "resource_group"]),
subjectType: z.enum(["resource", "resource_group", "vip"]),
targetUserId: z.union([z.string(), z.number()]),
vipLevel: z.union([z.string(), z.number()]).optional(),
})
.superRefine((value, context) => {
const targetUserId = String(value.targetUserId || "").trim();
@ -455,6 +456,7 @@ export const resourceGrantFormSchema = z
const groupId = Number(value.groupId || 0);
const quantity = Number(value.quantity || 1);
const durationDays = Number(value.durationDays || 0);
const vipLevel = Number(value.vipLevel || 0);
if (!/^[1-9]\d*$/.test(targetUserId)) {
context.addIssue({
@ -481,6 +483,20 @@ export const resourceGrantFormSchema = z
});
}
});
if (!Number.isInteger(quantity) || quantity <= 0) {
context.addIssue({
code: "custom",
message: "请输入数量",
path: ["quantity"],
});
}
if (!Number.isFinite(durationDays) || durationDays < 0) {
context.addIssue({
code: "custom",
message: "请输入有效天数",
path: ["durationDays"],
});
}
}
if (value.subjectType === "resource_group" && (!Number.isInteger(groupId) || groupId <= 0)) {
context.addIssue({
@ -489,18 +505,11 @@ export const resourceGrantFormSchema = z
path: ["groupId"],
});
}
if (!Number.isInteger(quantity) || quantity <= 0) {
if (value.subjectType === "vip" && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
context.addIssue({
code: "custom",
message: "请输入数量",
path: ["quantity"],
});
}
if (!Number.isFinite(durationDays) || durationDays < 0) {
context.addIssue({
code: "custom",
message: "请输入有效天数",
path: ["durationDays"],
message: "请选择VIP等级",
path: ["vipLevel"],
});
}
});

View File

@ -302,6 +302,31 @@ describe("resource form schema", () => {
expect(payload.resourceIds).toEqual(["11", "12"]);
});
test("validates vip grant form fields", () => {
const payload = parseForm(resourceGrantFormSchema, {
reason: "manual",
subjectType: "vip",
targetUserId: "1001",
vipLevel: "5",
});
expect(payload.subjectType).toBe("vip");
expect(payload.vipLevel).toBe("5");
});
test("rejects invalid vip grant form fields", () => {
const basePayload = {
reason: "manual",
subjectType: "vip",
targetUserId: "1001",
vipLevel: "5",
};
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, vipLevel: "" })).toThrow(FormValidationError);
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, vipLevel: "bad" })).toThrow(FormValidationError);
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, reason: "" })).toThrow(FormValidationError);
});
test("validates resource shop durations", () => {
const payload = parseForm(resourceShopItemsFormSchema, {
items: [

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getVipConfig, grantVip, updateVipConfig } from "@/features/vip-config/api";
import { getVipConfig, updateVipConfig } from "@/features/vip-config/api";
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
import { listResourceGroups } from "@/features/resources/api";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -13,11 +13,8 @@ export function useVipConfigPage() {
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
const [resourceGroups, setResourceGroups] = useState([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [grantDrawerOpen, setGrantDrawerOpen] = useState(false);
const [grantForm, setGrantForm] = useState(defaultGrantForm());
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [grantSaving, setGrantSaving] = useState(false);
const activeCount = useMemo(
() => (config.levels || []).filter((level) => level.status === "active").length,
@ -59,22 +56,6 @@ export function useVipConfigPage() {
setDrawerOpen(false);
};
const openGrantDrawer = () => {
setGrantForm((current) => ({
...defaultGrantForm(),
level: firstActiveLevel(config.levels || []) || current.level || "1",
}));
setGrantDrawerOpen(true);
};
const closeGrantDrawer = () => {
if (grantSaving) {
return;
}
setGrantDrawerOpen(false);
setGrantForm(defaultGrantForm());
};
const updateLevel = (levelNumber, patch) => {
setForm((current) => ({
...current,
@ -82,10 +63,6 @@ export function useVipConfigPage() {
}));
};
const updateGrantForm = (patch) => {
setGrantForm((current) => ({ ...current, ...patch }));
};
const submit = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
@ -113,53 +90,20 @@ export function useVipConfigPage() {
}
};
const submitGrant = async (event) => {
event.preventDefault();
if (!abilities.canGrant) {
return;
}
const targetUserId = String(grantForm.targetUserId || "").trim();
const reason = String(grantForm.reason || "").trim();
const level = Number(grantForm.level || 0);
if (!targetUserId || !Number.isInteger(level) || level <= 0 || !reason) {
showToast("VIP 赠送参数不正确", "error");
return;
}
setGrantSaving(true);
try {
const result = await grantVip({ targetUserId, level, reason });
setGrantDrawerOpen(false);
setGrantForm(defaultGrantForm());
showToast(`已赠送 ${result.vip?.name || `VIP${level}`}`, "success");
await reload();
} catch (err) {
showToast(err.message || "赠送 VIP 失败", "error");
} finally {
setGrantSaving(false);
}
};
return {
abilities,
activeCount,
closeDrawer,
closeGrantDrawer,
config,
drawerOpen,
form,
grantDrawerOpen,
grantForm,
grantSaving,
loading,
openDrawer,
openGrantDrawer,
reload,
resourceGroups,
saving,
submit,
submitGrant,
updateLevel,
updateGrantForm,
};
}
@ -185,19 +129,6 @@ function completeLevels(levels) {
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
}
function defaultGrantForm() {
return {
targetUserId: "",
level: "1",
reason: "",
};
}
function firstActiveLevel(levels) {
const active = (levels || []).find((level) => level.status === "active");
return active ? String(active.level) : "";
}
function levelToForm(level) {
return {
level: Number(level.level || 0),

View File

@ -1,9 +1,7 @@
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { useVipConfigPage } from "@/features/vip-config/hooks/useVipConfigPage.js";
@ -48,15 +46,6 @@ export function VipConfigPage() {
编辑配置
</Button>
) : null}
{page.abilities.canGrant ? (
<Button
disabled={page.loading}
startIcon={<CardGiftcardOutlined fontSize="small" />}
onClick={page.openGrantDrawer}
>
赠送VIP
</Button>
) : null}
</div>
</div>
<DataState loading={page.loading} onRetry={page.reload}>
@ -65,76 +54,10 @@ export function VipConfigPage() {
</AdminListBody>
</DataState>
<VipConfigDrawer page={page} />
<VipGrantDrawer page={page} />
</AdminListPage>
);
}
function VipGrantDrawer({ page }) {
const disabled = !page.abilities.canGrant || page.grantSaving || page.loading;
const activeLevels = (page.config.levels || []).filter((level) => level.status === "active");
return (
<Drawer
anchor="right"
open={page.grantDrawerOpen}
onClose={page.grantSaving ? undefined : page.closeGrantDrawer}
>
<form className="form-drawer" onSubmit={page.submitGrant}>
<h2>赠送VIP</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">赠送信息</div>
<TextField
disabled={disabled}
inputProps={{ inputMode: "numeric" }}
label="用户ID"
value={page.grantForm.targetUserId}
onChange={(event) => page.updateGrantForm({ targetUserId: event.target.value })}
/>
<TextField
select
disabled={disabled || activeLevels.length === 0}
label="VIP等级"
value={page.grantForm.level}
onChange={(event) => page.updateGrantForm({ level: event.target.value })}
>
{activeLevels.length === 0 ? (
<MenuItem value={page.grantForm.level || ""}>暂无启用等级</MenuItem>
) : (
activeLevels.map((level) => (
<MenuItem key={level.level} value={String(level.level)}>
VIP{level.level} · {level.name}
</MenuItem>
))
)}
</TextField>
<TextField
multiline
disabled={disabled}
inputProps={{ maxLength: 512 }}
label="原因"
minRows={3}
value={page.grantForm.reason}
onChange={(event) => page.updateGrantForm({ reason: event.target.value })}
/>
</section>
<div className="form-drawer__actions">
<Button disabled={page.grantSaving} type="button" onClick={page.closeGrantDrawer}>
取消
</Button>
<Button
disabled={disabled || activeLevels.length === 0}
startIcon={<CardGiftcardOutlined fontSize="small" />}
type="submit"
variant="primary"
>
赠送
</Button>
</div>
</form>
</Drawer>
);
}
function VipConfigDrawer({ page }) {
const disabled = !page.abilities.canUpdate || page.saving || page.loading;
return (

View File

@ -6,6 +6,5 @@ export function useVipConfigAbilities() {
return {
canView: can(PERMISSIONS.vipConfigView),
canUpdate: can(PERMISSIONS.vipConfigUpdate),
canGrant: can(PERMISSIONS.vipConfigGrant),
};
}

View File

@ -45,6 +45,7 @@ export const API_OPERATIONS = {
createEmojiPack: "createEmojiPack",
createExploreTab: "createExploreTab",
createFinanceApplication: "createFinanceApplication",
createFinanceCoinSellerRechargeOrder: "createFinanceCoinSellerRechargeOrder",
createFullServerNoticeFanout: "createFullServerNoticeFanout",
createGift: "createGift",
createH5Link: "createH5Link",
@ -151,6 +152,7 @@ export const API_OPERATIONS = {
getWeeklyStarCycle: "getWeeklyStarCycle",
getWheelConfig: "getWheelConfig",
getWheelDrawSummary: "getWheelDrawSummary",
grantFinanceCoinSellerRechargeOrder: "grantFinanceCoinSellerRechargeOrder",
grantPrettyId: "grantPrettyId",
grantResource: "grantResource",
grantResourceGroup: "grantResourceGroup",
@ -177,6 +179,7 @@ export const API_OPERATIONS = {
listEmojiPacks: "listEmojiPacks",
listExploreTabs: "listExploreTabs",
listFinanceApplications: "listFinanceApplications",
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
listFinanceWithdrawalApplications: "listFinanceWithdrawalApplications",
listFirstRechargeRewardClaims: "listFirstRechargeRewardClaims",
listGifts: "listGifts",
@ -339,7 +342,9 @@ export const API_OPERATIONS = {
upsertLuckyGiftConfig: "upsertLuckyGiftConfig",
upsertResourceShopItems: "upsertResourceShopItems",
upsertRule: "upsertRule",
upsertWheelConfig: "upsertWheelConfig"
upsertWheelConfig: "upsertWheelConfig",
verifyFinanceCoinSellerRechargeOrder: "verifyFinanceCoinSellerRechargeOrder",
verifyFinanceCoinSellerRechargeReceipt: "verifyFinanceCoinSellerRechargeReceipt"
} as const;
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
@ -567,6 +572,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "finance-application:create",
permissions: ["finance-application:create"]
},
createFinanceCoinSellerRechargeOrder: {
method: "POST",
operationId: API_OPERATIONS.createFinanceCoinSellerRechargeOrder,
path: "/v1/admin/finance/orders/coin-seller-recharges",
permission: "finance-order:coin-seller-recharge:create",
permissions: ["finance-order:coin-seller-recharge:create"]
},
createFullServerNoticeFanout: {
method: "POST",
operationId: API_OPERATIONS.createFullServerNoticeFanout,
@ -1309,6 +1321,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "wheel:view",
permissions: ["wheel:view"]
},
grantFinanceCoinSellerRechargeOrder: {
method: "POST",
operationId: API_OPERATIONS.grantFinanceCoinSellerRechargeOrder,
path: "/v1/admin/finance/orders/coin-seller-recharges/{order_id}/grant",
permission: "finance-order:coin-seller-recharge:grant",
permissions: ["finance-order:coin-seller-recharge:grant"]
},
grantPrettyId: {
method: "POST",
operationId: API_OPERATIONS.grantPrettyId,
@ -1489,6 +1508,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "finance-application:audit",
permissions: ["finance-application:audit"]
},
listFinanceCoinSellerRechargeOrders: {
method: "GET",
operationId: API_OPERATIONS.listFinanceCoinSellerRechargeOrders,
path: "/v1/admin/finance/orders/coin-seller-recharges",
permission: "finance-order:coin-seller-recharge:view",
permissions: ["finance-order:coin-seller-recharge:view"]
},
listFinanceWithdrawalApplications: {
method: "GET",
operationId: API_OPERATIONS.listFinanceWithdrawalApplications,
@ -2617,6 +2643,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
path: "/v1/admin/activity/wheel/config",
permission: "wheel:update",
permissions: ["wheel:update"]
},
verifyFinanceCoinSellerRechargeOrder: {
method: "POST",
operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder,
path: "/v1/admin/finance/orders/coin-seller-recharges/{order_id}/verify",
permission: "finance-order:coin-seller-recharge:verify",
permissions: ["finance-order:coin-seller-recharge:verify"]
},
verifyFinanceCoinSellerRechargeReceipt: {
method: "POST",
operationId: API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt,
path: "/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications",
permission: "finance-order:coin-seller-recharge:verify",
permissions: ["finance-order:coin-seller-recharge:verify"]
}
};

View File

@ -2228,6 +2228,70 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/finance/orders/coin-seller-recharges": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listFinanceCoinSellerRechargeOrders"];
put?: never;
post: operations["createFinanceCoinSellerRechargeOrder"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/finance/orders/coin-seller-recharges/receipt-verifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["verifyFinanceCoinSellerRechargeReceipt"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/finance/orders/coin-seller-recharges/{order_id}/verify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["verifyFinanceCoinSellerRechargeOrder"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/finance/orders/coin-seller-recharges/{order_id}/grant": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["grantFinanceCoinSellerRechargeOrder"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/temporary-links": {
parameters: {
query?: never;
@ -3890,6 +3954,12 @@ export interface components {
pageSize: number;
total: number;
};
ApiPageFinanceCoinSellerRechargeOrder: {
items: components["schemas"]["FinanceCoinSellerRechargeOrder"][];
page: number;
pageSize: number;
total: number;
};
ApiPageFinanceWithdrawalApplication: {
items: components["schemas"]["FinanceWithdrawalApplication"][];
page: number;
@ -3938,6 +4008,15 @@ export interface components {
ApiResponseFinanceApplicationPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageFinanceApplication"];
};
ApiResponseFinanceCoinSellerRechargeOrder: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceCoinSellerRechargeOrder"];
};
ApiResponseFinanceCoinSellerRechargeReceiptVerification: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceCoinSellerRechargeReceiptVerification"];
};
ApiResponseFinanceCoinSellerRechargeOrderPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageFinanceCoinSellerRechargeOrder"];
};
ApiResponseFinanceWithdrawalApplication: components["schemas"]["Envelope"] & {
data?: components["schemas"]["FinanceWithdrawalApplication"];
};
@ -4295,6 +4374,45 @@ export interface components {
updatedAtMs: number;
dingTalkNotified?: boolean;
};
FinanceCoinSellerRechargeOrder: {
id: string;
appCode: string;
targetUserId: string;
targetDisplayUserId?: string;
usdAmount: string;
usdMinorAmount?: number;
coinAmount: number;
/** @enum {string} */
providerCode: "mifapay" | "v5pay" | "usdt";
/** @enum {string} */
chain?: "TRON" | "BSC";
externalOrderNo: string;
providerCountryCode?: string;
providerCurrencyCode?: string;
providerAmountMinor?: number;
providerOrderId?: string;
providerStatus?: string;
providerPayType?: string;
receiveAddress?: string;
orderDate?: string;
status: string;
verifyStatus: string;
grantStatus: string;
remark?: string;
failureReason?: string;
providerPayloadJson?: string;
walletCommandId?: string;
walletTransactionId?: string;
walletAssetType?: string;
walletAmountDelta?: number;
walletBalanceAfter?: number;
operatorName?: string;
verifiedByName?: string;
grantedByName?: string;
createdAtMs: number;
verifiedAtMs?: number | null;
grantedAtMs?: number | null;
};
FinanceWithdrawalApplication: {
id: number;
appCode: string;
@ -4328,6 +4446,43 @@ export interface components {
credentialImageUrl?: string;
credentialText?: string;
};
FinanceCoinSellerRechargeOrderInput: {
appCode: string;
targetUserId: string;
usdAmount: number;
coinAmount: number;
/** @enum {string} */
providerCode: "mifapay" | "v5pay" | "usdt";
externalOrderNo: string;
/** @enum {string} */
chain?: "TRON" | "BSC";
remark?: string;
};
FinanceCoinSellerRechargeReceiptVerification: {
verified: boolean;
providerCode?: string;
externalOrderNo?: string;
providerOrderId?: string;
status?: string;
currencyCode?: string;
providerAmountMinor?: number;
/** @enum {string} */
chain?: "TRON" | "BSC";
receiveAddress?: string;
verifiedAtMs?: number;
failureReason?: string;
rawJson?: string;
};
FinanceCoinSellerRechargeReceiptVerificationInput: {
appCode: string;
/** @enum {string} */
providerCode: "mifapay" | "v5pay" | "usdt";
externalOrderNo: string;
/** @enum {string} */
chain?: "TRON" | "BSC";
usdAmount?: number;
usdMinorAmount?: number;
};
UserFinanceScope: {
appCode: string;
regionId: number;
@ -4425,6 +4580,33 @@ export interface components {
};
};
/** @description OK */
FinanceCoinSellerRechargeOrderPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeOrderPage"];
};
};
/** @description OK */
FinanceCoinSellerRechargeOrderResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeOrder"];
};
};
/** @description OK */
FinanceCoinSellerRechargeReceiptVerificationResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseFinanceCoinSellerRechargeReceiptVerification"];
};
};
/** @description OK */
FinanceWithdrawalApplicationPageResponse: {
headers: {
[name: string]: unknown;
@ -4667,6 +4849,16 @@ export interface components {
"application/json": components["schemas"]["FinanceApplicationInput"];
};
};
FinanceCoinSellerRechargeOrderRequest: {
content: {
"application/json": components["schemas"]["FinanceCoinSellerRechargeOrderInput"];
};
};
FinanceCoinSellerRechargeReceiptVerificationRequest: {
content: {
"application/json": components["schemas"]["FinanceCoinSellerRechargeReceiptVerificationInput"];
};
};
FinanceApplicationAuditRequest: {
content: {
"application/json": {
@ -7403,6 +7595,79 @@ export interface operations {
200: components["responses"]["FinanceWithdrawalApplicationResponse"];
};
};
listFinanceCoinSellerRechargeOrders: {
parameters: {
query?: {
app_code?: string;
provider_code?: string;
status?: string;
verify_status?: string;
grant_status?: string;
keyword?: string;
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["FinanceCoinSellerRechargeOrderPageResponse"];
};
};
createFinanceCoinSellerRechargeOrder: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeOrderRequest"];
responses: {
201: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
};
};
verifyFinanceCoinSellerRechargeReceipt: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: components["requestBodies"]["FinanceCoinSellerRechargeReceiptVerificationRequest"];
responses: {
200: components["responses"]["FinanceCoinSellerRechargeReceiptVerificationResponse"];
};
};
verifyFinanceCoinSellerRechargeOrder: {
parameters: {
query?: never;
header?: never;
path: {
order_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
};
};
grantFinanceCoinSellerRechargeOrder: {
parameters: {
query?: never;
header?: never;
path: {
order_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["FinanceCoinSellerRechargeOrderResponse"];
};
};
listTemporaryPaymentLinks: {
parameters: {
query?: never;

View File

@ -585,6 +585,79 @@ export interface TemporaryPaymentLinkDto {
usdMinorAmount: number;
}
export interface FinanceCoinSellerRechargeOrderDto {
appCode?: string;
chain?: string;
coinAmount: number;
createdAtMs?: number;
externalOrderNo?: string;
failureReason?: string;
grantStatus?: string;
grantedAtMs?: number;
grantedByName?: string;
id: EntityId;
operatorName?: string;
orderDate?: string;
providerAmountMinor?: number;
providerCode?: string;
providerCountryCode?: string;
providerCurrencyCode?: string;
providerOrderId?: string;
providerPayloadJson?: string;
providerPayType?: string;
providerStatus?: string;
receiveAddress?: string;
remark?: string;
status?: string;
targetDisplayUserId?: string;
targetUserId: string;
usdAmount?: number | string;
usdMinorAmount?: number;
verifiedAtMs?: number;
verifiedByName?: string;
verifyStatus?: string;
walletAmountDelta?: number;
walletAssetType?: string;
walletBalanceAfter?: number;
walletCommandId?: string;
walletTransactionId?: string;
}
export interface FinanceCoinSellerRechargeOrderPayload {
appCode: string;
chain?: string;
coinAmount: number;
externalOrderNo: string;
providerCode: "mifapay" | "v5pay" | "usdt";
remark?: string;
targetUserId: EntityId;
usdAmount: number;
}
export interface FinanceCoinSellerRechargeReceiptVerificationPayload {
appCode: string;
chain?: string;
externalOrderNo: string;
providerCode: "mifapay" | "v5pay" | "usdt";
usdAmount?: number;
usdMinorAmount?: number;
}
export interface FinanceCoinSellerRechargeReceiptVerificationDto {
chain?: string;
currencyCode?: string;
externalOrderNo?: string;
failureReason?: string;
providerAmountMinor?: number;
providerCode?: string;
providerOrderId?: string;
rawJson?: string;
receiveAddress?: string;
status?: string;
verifiedAtMs?: number;
verified: boolean;
}
export interface ThirdPartyPaymentRateSyncDto {
missingCurrencies?: string[];
rates?: Record<string, string>;