子币商以及 充值流水相关

This commit is contained in:
zhx 2026-07-09 18:15:25 +08:00
parent c7ca4f23ec
commit cf98d5bd9a
26 changed files with 4755 additions and 2832 deletions

View File

@ -1622,6 +1622,129 @@
"x-permissions": ["coin-seller:update"]
}
},
"/admin/coin-sellers/{user_id}/sub-sellers": {
"get": {
"operationId": "listCoinSellerSubSellers",
"parameters": [
{
"in": "path",
"name": "user_id",
"required": true,
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/CoinSellerPageResponse"
}
},
"x-permission": "coin-seller:view",
"x-permissions": ["coin-seller:view"]
}
},
"/admin/coin-seller-sub-applications": {
"get": {
"operationId": "listCoinSellerSubApplications",
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"$ref": "#/components/parameters/Keyword"
},
{
"in": "query",
"name": "status",
"schema": {
"type": "string",
"enum": ["pending", "approved", "rejected"]
}
},
{
"in": "query",
"name": "parent_user_id",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "target_user_id",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/CoinSellerSubApplicationPageResponse"
}
},
"x-permission": "coin-seller-sub-application:view",
"x-permissions": ["coin-seller-sub-application:view"]
}
},
"/admin/coin-seller-sub-applications/{application_id}/approve": {
"post": {
"operationId": "approveCoinSellerSubApplication",
"requestBody": {
"$ref": "#/components/requestBodies/CoinSellerSubApplicationReviewRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/CoinSellerSubApplicationReviewResponse"
}
},
"parameters": [
{
"in": "path",
"name": "application_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "coin-seller-sub-application:audit",
"x-permissions": ["coin-seller-sub-application:audit"]
}
},
"/admin/coin-seller-sub-applications/{application_id}/reject": {
"post": {
"operationId": "rejectCoinSellerSubApplication",
"requestBody": {
"$ref": "#/components/requestBodies/CoinSellerSubApplicationReviewRequest"
},
"responses": {
"200": {
"$ref": "#/components/responses/CoinSellerSubApplicationReviewResponse"
}
},
"parameters": [
{
"in": "path",
"name": "application_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "coin-seller-sub-application:audit",
"x-permissions": ["coin-seller-sub-application:audit"]
}
},
"/admin/coin-sellers/{user_id}/stock-credits": {
"post": {
"operationId": "creditCoinSellerStock",
@ -2887,7 +3010,12 @@
"in": "query",
"schema": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
"enum": [
"admin_stock_credit",
"seller_transfer",
"sub_seller_transfer",
"salary_transfer_received"
]
}
},
{
@ -2975,7 +3103,12 @@
"in": "query",
"schema": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
"enum": [
"admin_stock_credit",
"seller_transfer",
"sub_seller_transfer",
"salary_transfer_received"
]
}
},
{
@ -3083,7 +3216,11 @@
{ "in": "query", "name": "keyword", "schema": { "type": "string" } },
{ "in": "query", "name": "status", "schema": { "type": "string", "enum": ["active", "disabled"] } },
{ "in": "query", "name": "page", "schema": { "type": "integer", "minimum": 1 } },
{ "in": "query", "name": "page_size", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }
{
"in": "query",
"name": "page_size",
"schema": { "type": "integer", "minimum": 1, "maximum": 100 }
}
],
"responses": {
"200": {
@ -3161,7 +3298,11 @@
{ "in": "query", "name": "keyword", "schema": { "type": "string" } },
{ "in": "query", "name": "status", "schema": { "type": "string", "enum": ["active", "disabled"] } },
{ "in": "query", "name": "page", "schema": { "type": "integer", "minimum": 1 } },
{ "in": "query", "name": "page_size", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }
{
"in": "query",
"name": "page_size",
"schema": { "type": "integer", "minimum": 1, "maximum": 100 }
}
],
"responses": {
"200": {
@ -3718,7 +3859,12 @@
{ "in": "query", "name": "end_ms", "schema": { "type": "integer", "format": "int64" } },
{ "in": "query", "name": "app_codes", "schema": { "type": "string" } },
{ "in": "query", "name": "region_id", "schema": { "type": "integer", "format": "int64" } },
{ "in": "query", "name": "region_ids", "description": "逗号分隔的大区 ID用于 Social BI 顶栏多选区域。", "schema": { "type": "string" } },
{
"in": "query",
"name": "region_ids",
"description": "逗号分隔的大区 ID用于 Social BI 顶栏多选区域。",
"schema": { "type": "string" }
},
{
"in": "query",
"name": "section",
@ -6728,6 +6874,16 @@
}
}
},
"CoinSellerSubApplicationReviewRequest": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CoinSellerSubApplicationReviewPayload"
}
}
}
},
"CountryCreateRequest": {
"required": true,
"content": {
@ -7061,6 +7217,26 @@
}
}
},
"CoinSellerSubApplicationPageResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCoinSellerSubApplicationPage"
}
}
}
},
"CoinSellerSubApplicationReviewResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCoinSellerSubApplicationReview"
}
}
}
},
"CoinSellerLedgerPageResponse": {
"description": "OK",
"content": {
@ -7517,6 +7693,27 @@
}
}
},
"ApiPageCoinSellerSubApplication": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CoinSellerSubApplication"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"ApiPageCoinSellerLedger": {
"type": "object",
"required": ["items", "page", "pageSize", "total"],
@ -7894,6 +8091,36 @@
}
]
},
"ApiResponseCoinSellerSubApplicationPage": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/ApiPageCoinSellerSubApplication"
}
}
}
]
},
"ApiResponseCoinSellerSubApplicationReview": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/CoinSellerSubApplicationReviewResult"
}
}
}
]
},
"ApiResponseCoinSellerLedgerPage": {
"allOf": [
{
@ -8780,6 +9007,9 @@
"type": "string",
"enum": ["active", "disabled"]
},
"subCoinSellerCount": {
"type": "integer"
},
"totalRechargeUsdtMicro": {
"type": "integer"
},
@ -8794,6 +9024,204 @@
}
}
},
"CoinSellerSubApplicationUser": {
"type": "object",
"required": ["userId"],
"properties": {
"avatar": {
"type": "string"
},
"displayUserId": {
"type": "string"
},
"regionId": {
"type": "integer"
},
"regionName": {
"type": "string"
},
"userId": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"CoinSellerSubApplication": {
"type": "object",
"required": ["applicationId", "parent", "target", "status"],
"properties": {
"applicationId": {
"type": "string"
},
"commandId": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"parent": {
"$ref": "#/components/schemas/CoinSellerSubApplicationUser"
},
"relationId": {
"type": "string"
},
"reviewedAtMs": {
"type": "integer"
},
"reviewedByAdminId": {
"type": "string"
},
"reviewReason": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["pending", "approved", "rejected"]
},
"target": {
"$ref": "#/components/schemas/CoinSellerSubApplicationUser"
},
"updatedAtMs": {
"type": "integer"
}
}
},
"CoinSellerSubApplicationReviewApplication": {
"type": "object",
"required": ["applicationId", "parentUserId", "targetUserId", "status"],
"properties": {
"applicationId": {
"type": "string"
},
"commandId": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"parentUserId": {
"type": "string"
},
"relationId": {
"type": "string"
},
"reviewedAtMs": {
"type": "integer"
},
"reviewedByAdminId": {
"type": "string"
},
"reviewReason": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["pending", "approved", "rejected"]
},
"targetUserId": {
"type": "string"
},
"updatedAtMs": {
"type": "integer"
}
}
},
"CoinSellerSubRelation": {
"type": "object",
"required": ["relationId", "parentUserId", "childUserId", "status"],
"properties": {
"childUserId": {
"type": "string"
},
"createdAtMs": {
"type": "integer"
},
"createdByUserId": {
"type": "string"
},
"parentUserId": {
"type": "string"
},
"relationId": {
"type": "string"
},
"status": {
"type": "string"
},
"updatedAtMs": {
"type": "integer"
}
}
},
"CoinSellerSubApplicationReviewChild": {
"type": "object",
"required": ["userId"],
"properties": {
"avatar": {
"type": "string"
},
"canManageSubCoinSellers": {
"type": "boolean"
},
"contactInfo": {
"type": "string"
},
"displayUserId": {
"type": "string"
},
"merchantAssetType": {
"type": "string"
},
"regionId": {
"type": "integer"
},
"regionName": {
"type": "string"
},
"status": {
"type": "string"
},
"updatedAtMs": {
"type": "integer"
},
"userId": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"CoinSellerSubApplicationReviewPayload": {
"type": "object",
"required": ["commandId"],
"properties": {
"commandId": {
"type": "string",
"maxLength": 120
},
"reason": {
"type": "string",
"maxLength": 200
}
}
},
"CoinSellerSubApplicationReviewResult": {
"type": "object",
"properties": {
"application": {
"$ref": "#/components/schemas/CoinSellerSubApplicationReviewApplication"
},
"child": {
"$ref": "#/components/schemas/CoinSellerSubApplicationReviewChild"
},
"relation": {
"$ref": "#/components/schemas/CoinSellerSubRelation"
}
}
},
"CoinLedgerUser": {
"type": "object",
"required": ["userId"],
@ -8814,7 +9242,19 @@
},
"CoinSellerLedger": {
"type": "object",
"required": ["entryId", "transactionId", "ledgerType", "bizType", "seller", "receiver", "amount", "direction", "availableDelta", "sellerBalanceAfter", "createdAtMs"],
"required": [
"entryId",
"transactionId",
"ledgerType",
"bizType",
"seller",
"receiver",
"amount",
"direction",
"availableDelta",
"sellerBalanceAfter",
"createdAtMs"
],
"properties": {
"amount": {
"type": "integer",
@ -8857,7 +9297,12 @@
},
"ledgerType": {
"type": "string",
"enum": ["admin_stock_credit", "seller_transfer", "sub_seller_transfer", "salary_transfer_received"]
"enum": [
"admin_stock_credit",
"seller_transfer",
"sub_seller_transfer",
"salary_transfer_received"
]
},
"metadata": {
"type": "object",
@ -10132,6 +10577,13 @@
"externalOrderNo": {
"type": "string"
},
"providerCurrencyCode": {
"type": "string"
},
"providerAmountMinor": {
"type": "integer",
"minimum": 1
},
"chain": {
"type": "string",
"enum": ["TRON", "BSC"]
@ -10210,6 +10662,13 @@
"usdMinorAmount": {
"type": "integer",
"minimum": 0
},
"providerCurrencyCode": {
"type": "string"
},
"providerAmountMinor": {
"type": "integer",
"minimum": 1
}
}
},

View File

@ -113,14 +113,18 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
targetUserId: "10001",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeReceipt({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10
});
await verifyFinanceCoinSellerRechargeOrder("order-1");
@ -132,13 +136,24 @@ test("finance coin seller recharge order APIs use generated endpoint paths", asy
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(JSON.parse(String(calls[1][1]?.body))).toEqual({
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
targetUserId: "10001",
usdAmount: 10
});
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",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10
});
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");

View File

@ -3,6 +3,7 @@ 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 Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
@ -14,6 +15,7 @@ import TextField from "@mui/material/TextField";
import { useState } from "react";
import {
COIN_SELLER_RECHARGE_CHAINS,
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
COIN_SELLER_RECHARGE_PROVIDERS,
COIN_SELLER_RECHARGE_STATUS,
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
@ -78,7 +80,9 @@ export function FinanceCoinSellerRechargeOrderList({
setForm((current) => ({
...current,
chain: "",
providerAmount: "",
providerCode: value,
providerCurrencyCode: "",
}));
setFormError("");
setReceiptVerification(null);
@ -201,7 +205,12 @@ export function FinanceCoinSellerRechargeOrderList({
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
刷新
</Button>
<Button disabled={!canCreate} startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={openCreate}>
<Button
disabled={!canCreate}
startIcon={<AddOutlined fontSize="small" />}
variant="contained"
onClick={openCreate}
>
创建币商充值
</Button>
</div>
@ -234,7 +243,10 @@ export function FinanceCoinSellerRechargeOrderList({
<td className="finance-order-cell">{item.id || "-"}</td>
<td>{appName(item.appCode, apps)}</td>
<td>
<Stacked primary={item.targetDisplayUserId || item.targetUserId} secondary={item.targetUserId} />
<Stacked
primary={item.targetDisplayUserId || item.targetUserId}
secondary={item.targetUserId}
/>
</td>
<td className="r num">
<Stacked
@ -243,10 +255,16 @@ export function FinanceCoinSellerRechargeOrderList({
/>
</td>
<td>
<Stacked primary={coinSellerRechargeProviderLabel(item.providerCode)} secondary={providerMeta(item)} />
<Stacked
primary={coinSellerRechargeProviderLabel(item.providerCode)}
secondary={providerMeta(item)}
/>
</td>
<td>
<Stacked primary={item.externalOrderNo || "-"} secondary={providerAmountText(item)} />
<Stacked
primary={item.externalOrderNo || "-"}
secondary={providerAmountText(item)}
/>
</td>
<td className="finance-remark-cell">{item.remark || "-"}</td>
<td>
@ -266,7 +284,8 @@ export function FinanceCoinSellerRechargeOrderList({
primary={item.operatorName || "-"}
secondary={[
item.verifiedByName && `校验:${item.verifiedByName}`,
item.grantedByName && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
item.grantedByName &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
]
.filter(Boolean)
.join(" / ")}
@ -277,7 +296,8 @@ export function FinanceCoinSellerRechargeOrderList({
primary={formatTime(item.createdAtMs)}
secondary={[
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
item.grantedAtMs && `${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
item.grantedAtMs &&
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
]
.filter(Boolean)
.join(" / ")}
@ -330,13 +350,21 @@ export function FinanceCoinSellerRechargeOrderList({
) : null}
{total > pageSize ? (
<div className="finance-pagination">
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
<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
disabled={!hasNextPage || loading}
variant="outlined"
onClick={() => onFiltersChange({ page: page + 1 })}
>
下一页
</Button>
</div>
@ -379,14 +407,37 @@ function CreateDialog({
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}>
<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}>
<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"}>
<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)}>
<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}
@ -394,9 +445,21 @@ function CreateDialog({
))}
</TextField>
) : (
<TextField disabled={disabled} label="APP" required value={form.appCode} onChange={(event) => onChange("appCode", event.target.value)} />
<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}
label="目标用户 ID"
required
value={form.targetUserId}
onChange={(event) => onChange("targetUserId", event.target.value)}
/>
<TextField
disabled={disabled}
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
@ -417,7 +480,14 @@ function CreateDialog({
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)}>
<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}
@ -425,7 +495,14 @@ function CreateDialog({
))}
</TextField>
{form.providerCode === "usdt" ? (
<TextField disabled={disabled} label="USDT 链" required select value={form.chain} onChange={(event) => onChange("chain", event.target.value)}>
<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}
@ -433,9 +510,45 @@ function CreateDialog({
))}
</TextField>
) : null}
{form.providerCode !== "usdt" ? (
<>
<Autocomplete
disabled={disabled}
getOptionLabel={currencyOptionLabel}
isOptionEqualToValue={(option, value) => option === value}
noOptionsText="无匹配币种"
options={providerCurrencyOptions(form.providerCode)}
renderInput={(params) => (
<TextField {...params} label="币种" required />
)}
value={form.providerCurrencyCode || null}
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0, step: "0.01" }}
label="币种金额"
required
type="number"
value={form.providerAmount}
onChange={(event) => onChange("providerAmount", event.target.value)}
/>
</>
) : 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}>
<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>
@ -466,6 +579,14 @@ function isZeroCoinMakeupOrder(order) {
return Number(order?.coinAmount || 0) === 0;
}
function providerCurrencyOptions(providerCode) {
return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || [];
}
function currencyOptionLabel(option) {
return option || "";
}
function ReceiptVerificationBanner({ verification }) {
const detail = [
verification.status && `状态 ${verification.status}`,
@ -486,7 +607,11 @@ function ReceiptVerificationBanner({ verification }) {
}
function StatusBadge({ status }) {
return <span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>{coinSellerRechargeStatusLabel(status)}</span>;
return (
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
{coinSellerRechargeStatusLabel(status)}
</span>
);
}
function Stacked({ primary, secondary }) {
@ -523,7 +648,9 @@ function providerMeta(item) {
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
}
if (item.providerCode === "v5pay") {
return [item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-";
return (
[item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-"
);
}
return "-";
}
@ -531,8 +658,12 @@ function providerMeta(item) {
function walletMeta(item) {
return [
item.walletAssetType,
item.walletAmountDelta === null || item.walletAmountDelta === undefined ? "" : `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined ? "" : `余额 ${formatAmount(item.walletBalanceAfter)}`,
item.walletAmountDelta === null || item.walletAmountDelta === undefined
? ""
: `变动 ${formatAmount(item.walletAmountDelta)}`,
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined
? ""
: `余额 ${formatAmount(item.walletBalanceAfter)}`,
]
.filter(Boolean)
.join(" / ");

View File

@ -9,26 +9,31 @@ afterEach(() => {
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,
currencyCode: "PHP",
providerAmountMinor: 4800,
providerOrderId: "V5-001",
status: "paid",
verified: true,
}));
render(<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />);
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.getByRole("combobox", { name: "币种" })).toBeInTheDocument();
expect(screen.getByRole("spinbutton", { name: "币种金额" })).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.mouseDown(screen.getByRole("combobox", { name: "币种" }));
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } });
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
@ -39,7 +44,9 @@ test("coin seller recharge order create dialog verifies receipt before create",
expect(onVerifyReceipt).toHaveBeenCalledWith({
appCode: "lalu",
externalOrderNo: "V5-001",
providerAmountMinor: 4800,
providerCode: "v5pay",
providerCurrencyCode: "PHP",
usdAmount: 10,
});
fireEvent.click(screen.getByRole("button", { name: "创建" }));
@ -48,7 +55,9 @@ test("coin seller recharge order create dialog verifies receipt before create",
appCode: "lalu",
coinAmount: 0,
externalOrderNo: "V5-001",
providerAmountMinor: 4800,
providerCode: "v5pay",
providerCurrencyCode: "PHP",
remark: "补单,不发金币",
targetUserId: "10001",
usdAmount: 10,
@ -59,7 +68,10 @@ test("coin seller recharge order create dialog verifies receipt before create",
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));
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
render(
<FinanceCoinSellerRechargeOrderList
@ -100,7 +112,9 @@ test("zero coin seller recharge order uses makeup completion action", () => {
<FinanceCoinSellerRechargeOrderList
{...baseProps({
orders: {
items: [orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" })],
items: [
orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" }),
],
page: 1,
pageSize: 50,
total: 1,

View File

@ -70,6 +70,11 @@ export const COIN_SELLER_RECHARGE_CHAINS = [
["BSC", "BSC"],
];
export const COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES = {
mifapay: ["PHP", "INR", "IDR", "VND", "SAR", "AED", "BHD", "QAR", "OMR", "KWD", "BDT", "PKR", "EGP"],
v5pay: ["PHP", "SAR", "EGP", "AED", "IDR", "INR", "PKR", "TRY", "MXN"],
};
export const COIN_SELLER_RECHARGE_STATUS = [
["pending", "待处理"],
["verified", "已校验"],
@ -106,7 +111,9 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = {
chain: "",
coinAmount: "",
externalOrderNo: "",
providerAmount: "",
providerCode: "mifapay",
providerCurrencyCode: "",
remark: "",
targetUserId: "",
usdAmount: "",

View File

@ -129,12 +129,16 @@ export function normalizeWithdrawalApplicationPage(data = {}) {
export function buildCoinSellerRechargeOrderPayload(form) {
const providerCode = stringValue(form.providerCode);
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
const providerAmountMinor = providerAmountMinorFromForm(form);
const payload = {
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
coinAmount: numberValue(form.coinAmount),
externalOrderNo: stringValue(form.externalOrderNo),
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
providerCode,
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
remark: stringValue(form.remark),
targetUserId: stringValue(form.targetUserId),
usdAmount: numberValue(form.usdAmount),
@ -145,12 +149,16 @@ export function buildCoinSellerRechargeOrderPayload(form) {
export function buildCoinSellerRechargeReceiptPayload(form) {
const providerCode = stringValue(form.providerCode);
const providerCurrencyCode = stringValue(form.providerCurrencyCode).toUpperCase();
const providerAmountMinor = providerAmountMinorFromForm(form);
return Object.fromEntries(
Object.entries({
appCode: stringValue(form.appCode),
chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "",
externalOrderNo: stringValue(form.externalOrderNo),
providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "",
providerCode,
providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "",
usdAmount: numberValue(form.usdAmount),
}).filter(([, value]) => value !== ""),
);
@ -178,6 +186,14 @@ export function validateCoinSellerRechargeOrderPayload(payload) {
if (payload.providerCode === "usdt" && !payload.chain) {
return "请选择 USDT 链";
}
if (requiresThirdPartyProviderAmount(payload.providerCode)) {
if (!payload.providerCurrencyCode) {
return "请填写币种";
}
if (!Number.isInteger(payload.providerAmountMinor) || payload.providerAmountMinor <= 0) {
return "请填写大于 0 的币种金额";
}
}
if (payload.remark && payload.remark.length > 512) {
return "备注不能超过 512 字";
}
@ -642,6 +658,26 @@ function numberOrNull(value) {
return Number.isFinite(nextValue) ? nextValue : null;
}
function providerMoneyToMinor(value) {
const number = numberValue(value);
if (!Number.isFinite(number)) {
return Number.NaN;
}
return Math.round(number * 100);
}
function providerAmountMinorFromForm(form) {
// 表单输入是常规金额,已构建 payload 重用时则直接沿用 minor避免校验请求二次放大金额。
if (stringValue(form.providerAmount) !== "") {
return providerMoneyToMinor(form.providerAmount);
}
return numberValue(form.providerAmountMinor);
}
function requiresThirdPartyProviderAmount(providerCode) {
return ["mifapay", "v5pay"].includes(stringValue(providerCode).toLowerCase());
}
function stringValue(value) {
return String(value ?? "").trim();
}

View File

@ -170,7 +170,9 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: " lalu ",
coinAmount: "100000",
externalOrderNo: " MIFA-001 ",
providerAmount: "1250.5",
providerCode: "mifapay",
providerCurrencyCode: " php ",
remark: " 人工补单 ",
targetUserId: " 10001 ",
usdAmount: "10.5",
@ -180,7 +182,9 @@ test("builds validates and normalizes coin seller recharge orders", () => {
appCode: "lalu",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
remark: "人工补单",
targetUserId: "10001",
usdAmount: 10.5,
@ -194,16 +198,90 @@ test("builds validates and normalizes coin seller recharge orders", () => {
).toEqual({
appCode: "lalu",
externalOrderNo: "MIFA-001",
providerAmountMinor: 125050,
providerCode: "mifapay",
providerCurrencyCode: "PHP",
usdAmount: 10.5,
});
expect(validateCoinSellerRechargeOrderPayload(payload)).toBe("");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerCurrencyCode: "" })).toBe("请填写币种");
expect(validateCoinSellerRechargeOrderPayload({ ...payload, providerAmountMinor: 0 })).toBe(
"请填写大于 0 的币种金额",
);
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 boundaryPayload = buildCoinSellerRechargeOrderPayload({
...payload,
externalOrderNo: "V5-001",
providerAmount: "0.01",
providerCode: "v5pay",
providerCurrencyCode: "try",
});
expect(boundaryPayload).toMatchObject({
externalOrderNo: "V5-001",
providerAmountMinor: 1,
providerCode: "v5pay",
providerCurrencyCode: "TRY",
});
expect(validateCoinSellerRechargeOrderPayload(boundaryPayload)).toBe("");
expect(
validateCoinSellerRechargeOrderPayload(
buildCoinSellerRechargeOrderPayload({
...payload,
providerAmount: "abc",
providerCode: "v5pay",
providerCurrencyCode: "TRY",
}),
),
).toBe("请填写大于 0 的币种金额");
expect(
validateCoinSellerRechargeOrderPayload(
buildCoinSellerRechargeOrderPayload({
...payload,
providerAmount: "",
providerAmountMinor: undefined,
providerCode: "v5pay",
providerCurrencyCode: "TRY",
}),
),
).toBe("请填写大于 0 的币种金额");
expect(
buildCoinSellerRechargeOrderPayload({
...payload,
chain: "tron",
providerAmount: "48",
providerCode: "usdt",
providerCurrencyCode: "PHP",
}),
).toEqual({
appCode: "lalu",
chain: "TRON",
coinAmount: 100000,
externalOrderNo: "MIFA-001",
providerCode: "usdt",
remark: "人工补单",
targetUserId: "10001",
usdAmount: 10.5,
});
expect(
buildCoinSellerRechargeReceiptPayload({
...payload,
chain: "bsc",
providerAmount: "48",
providerCode: "usdt",
providerCurrencyCode: "PHP",
}),
).toEqual({
appCode: "lalu",
chain: "BSC",
externalOrderNo: "MIFA-001",
providerCode: "usdt",
usdAmount: 10.5,
});
const page = normalizeCoinSellerRechargeOrderPage({
items: [

View File

@ -134,6 +134,7 @@ export const fallbackNavigation = [
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
routeNavItem("host-org-coin-seller-sub-applications", { icon: ReceiptLongOutlined }),
routeNavItem("host-withdrawals", { icon: ReceiptLongOutlined }),
],
},

View File

@ -43,6 +43,8 @@ export const PERMISSIONS = {
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
coinSellerSubApplicationView: "coin-seller-sub-application:view",
coinSellerSubApplicationAudit: "coin-seller-sub-application:audit",
hostWithdrawalView: "host-withdrawal:view",
financeView: "finance:view",
financeApplicationCreate: "finance-application:create",
@ -286,6 +288,7 @@ export const MENU_CODES = {
hostAgencyPolicy: "host-agency-policy",
hostSalarySettlement: "host-salary-settlement",
hostOrgCoinSellers: "host-org-coin-sellers",
hostOrgCoinSellerSubApplications: "host-org-coin-seller-sub-applications",
hostWithdrawals: "host-withdrawals",
payment: "payment",
paymentBillList: "payment-bill-list",

View File

@ -13,6 +13,8 @@ import {
disableRegion,
listAgencies,
listBDs,
listCoinSellerSubApplications,
listCoinSellerSubSellers,
listCoinSellers,
listCountries,
listHosts,
@ -21,6 +23,8 @@ import {
getJob,
renameCountryCode,
replaceRegionCountries,
approveCoinSellerSubApplication,
rejectCoinSellerSubApplication,
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
@ -206,6 +210,54 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
});
test("coin seller sub application APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })),
),
);
await listCoinSellerSubSellers("3001", { page: 1, page_size: 10 });
await listCoinSellerSubApplications({
keyword: "163001",
page: 2,
page_size: 10,
parent_user_id: "3001",
status: "pending",
target_user_id: "3002",
});
await approveCoinSellerSubApplication("sub-app-1", {
commandId: "approve-sub-app-test",
reason: "ok",
});
await rejectCoinSellerSubApplication("sub-app-2", {
commandId: "reject-sub-app-test",
reason: "bad",
});
const [subSellerUrl, subSellerInit] = vi.mocked(fetch).mock.calls[0];
const [applicationUrl, applicationInit] = vi.mocked(fetch).mock.calls[1];
const [approveUrl, approveInit] = vi.mocked(fetch).mock.calls[2];
const [rejectUrl, rejectInit] = vi.mocked(fetch).mock.calls[3];
expect(String(subSellerUrl)).toContain("/api/v1/admin/coin-sellers/3001/sub-sellers?");
expect(String(subSellerUrl)).toContain("page=1");
expect(subSellerInit?.method).toBe("GET");
expect(String(applicationUrl)).toContain("/api/v1/admin/coin-seller-sub-applications?");
expect(String(applicationUrl)).toContain("keyword=163001");
expect(String(applicationUrl)).toContain("parent_user_id=3001");
expect(String(applicationUrl)).toContain("target_user_id=3002");
expect(String(applicationUrl)).toContain("status=pending");
expect(applicationInit?.method).toBe("GET");
expect(String(approveUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-1/approve");
expect(approveInit?.method).toBe("POST");
expect(JSON.parse(String(approveInit?.body))).toMatchObject({ commandId: "approve-sub-app-test" });
expect(String(rejectUrl)).toContain("/api/v1/admin/coin-seller-sub-applications/sub-app-2/reject");
expect(rejectInit?.method).toBe("POST");
expect(JSON.parse(String(rejectInit?.body))).toMatchObject({ commandId: "reject-sub-app-test" });
});
test("country and region APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",

View File

@ -15,6 +15,9 @@ import type {
CoinSellerDto,
CoinSellerSalaryRatesDto,
CoinSellerSalaryRatesPayload,
CoinSellerSubApplicationDto,
CoinSellerSubApplicationReviewPayload,
CoinSellerSubApplicationReviewResultDto,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStockDebitDto,
@ -241,6 +244,28 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
});
}
export function listCoinSellerSubSellers(userId: EntityId, query: PageQuery = {}): Promise<ApiPage<CoinSellerDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerSubSellers;
return apiRequest<ApiPage<CoinSellerDto>>(
apiEndpointPath(API_OPERATIONS.listCoinSellerSubSellers, { user_id: userId }),
{
method: endpoint.method,
query,
},
);
}
export function listCoinSellerSubApplications(query: PageQuery = {}): Promise<ApiPage<CoinSellerSubApplicationDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerSubApplications;
return apiRequest<ApiPage<CoinSellerSubApplicationDto>>(
apiEndpointPath(API_OPERATIONS.listCoinSellerSubApplications),
{
method: endpoint.method,
query,
},
);
}
export function listHostWithdrawals(query: PageQuery = {}): Promise<ApiPage<HostWithdrawalDto>> {
const endpoint = API_ENDPOINTS.listHostWithdrawals;
return apiRequest<ApiPage<HostWithdrawalDto>>(apiEndpointPath(API_OPERATIONS.listHostWithdrawals), {
@ -339,6 +364,34 @@ export function setCoinSellerStatus(userId: EntityId, payload: CoinSellerStatusP
);
}
export function approveCoinSellerSubApplication(
applicationId: EntityId,
payload: CoinSellerSubApplicationReviewPayload,
): Promise<CoinSellerSubApplicationReviewResultDto> {
const endpoint = API_ENDPOINTS.approveCoinSellerSubApplication;
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
apiEndpointPath(API_OPERATIONS.approveCoinSellerSubApplication, { application_id: applicationId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function rejectCoinSellerSubApplication(
applicationId: EntityId,
payload: CoinSellerSubApplicationReviewPayload,
): Promise<CoinSellerSubApplicationReviewResultDto> {
const endpoint = API_ENDPOINTS.rejectCoinSellerSubApplication;
return apiRequest<CoinSellerSubApplicationReviewResultDto, CoinSellerSubApplicationReviewPayload>(
apiEndpointPath(API_OPERATIONS.rejectCoinSellerSubApplication, { application_id: applicationId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function creditCoinSellerStock(
userId: EntityId,
payload: CoinSellerStockCreditPayload,

View File

@ -13,10 +13,13 @@ export function HostOrgStatus({ value }) {
}
function getStatusTone(status) {
if (status === "active") {
if (status === "active" || status === "approved") {
return "running";
}
if (status === "closed" || status === "disabled") {
if (status === "pending") {
return "warning";
}
if (status === "closed" || status === "disabled" || status === "rejected") {
return "danger";
}
return "stopped";

View File

@ -1,53 +1,69 @@
export const agencyStatusFilters = [
["", "全部"],
["active", "启用"],
["closed", "已关闭"]
["closed", "已关闭"],
];
export const countryEnabledFilters = [
["", "全部"],
["enabled", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const regionStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const bdStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const managerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const hostStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const coinSellerStatusFilters = [
["", "全部"],
["active", "启用"],
["disabled", "停用"]
["disabled", "停用"],
];
export const coinSellerSubApplicationStatusFilters = [
["", "全部"],
["pending", "待审核"],
["approved", "已通过"],
["rejected", "已拒绝"],
];
export const coinSellerSubApplicationStatusLabels = {
approved: "已通过",
pending: "待审核",
rejected: "已拒绝",
};
export const hostSourceLabels = {
agency_application: "Agency 申请",
agency_invitation: "Agency 邀请",
admin_create_agency: "后台创建 Agency"
admin_create_agency: "后台创建 Agency",
};
export const statusLabels = {
active: "启用",
approved: "已通过",
closed: "已关闭",
disabled: "停用"
disabled: "停用",
pending: "待审核",
rejected: "已拒绝",
};

View File

@ -10,6 +10,7 @@ import {
debitCoinSellerStock,
getCoinSellerSalaryRates,
listCoinSellers,
listCoinSellerSubSellers,
replaceCoinSellerSalaryRates,
setCoinSellerStatus,
} from "@/features/host-org/api";
@ -79,6 +80,9 @@ export function useHostCoinSellersPage() {
const [loadingRates, setLoadingRates] = useState(false);
const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [loadingSubSellers, setLoadingSubSellers] = useState(false);
const [subSellerData, setSubSellerData] = useState(emptyData);
const [subSellerPage, setSubSellerPageState] = useState(1);
const [contactSavingIds, setContactSavingIds] = useState({});
const [sellerRowPatches, setSellerRowPatches] = useState({});
const filters = useMemo(
@ -193,6 +197,15 @@ export function useHostCoinSellersPage() {
setActiveAction("ledger");
};
const openSubSellerList = async (seller) => {
if (!seller?.userId || !abilities.canView) {
return;
}
setSelectedSeller(seller);
setActiveAction("sub-sellers");
await loadSubSellers(seller.userId, 1);
};
const openRateSettings = async () => {
if (!abilities.canExchangeRate) {
return;
@ -212,6 +225,31 @@ export function useHostCoinSellersPage() {
setSelectedSeller(null);
};
const loadSubSellers = async (sellerUserId = selectedSeller?.userId, nextPage = subSellerPage) => {
if (!sellerUserId) {
setSubSellerData(emptyData);
return;
}
setLoadingSubSellers(true);
try {
const nextData = await listCoinSellerSubSellers(sellerUserId, {
page: nextPage,
page_size: pageSize,
});
setSubSellerData(nextData || emptyData);
setSubSellerPageState(nextPage);
} catch (err) {
setSubSellerData(emptyData);
showToast(err.message || "加载子币商列表失败", "error");
} finally {
setLoadingSubSellers(false);
}
};
const setSubSellerPage = async (nextPage) => {
await loadSubSellers(selectedSeller?.userId, nextPage);
};
const changeRateRegionId = async (value) => {
setRateRegionId(value);
if (value) {
@ -453,6 +491,7 @@ export function useHostCoinSellersPage() {
error,
loading,
loadingAction,
loadingSubSellers,
loadingRates,
loadingRegions,
openRateSettings,
@ -461,6 +500,7 @@ export function useHostCoinSellersPage() {
openSellerLedger,
openStockCredit,
openStockDebit,
openSubSellerList,
page,
regionId,
regionOptions,
@ -478,9 +518,12 @@ export function useHostCoinSellersPage() {
setPage,
setStockForm,
setStockDebitForm,
setSubSellerPage,
status,
stockDebitForm,
stockForm,
subSellerData,
subSellerPage,
sortBy,
sortDirection,
submitRateSettings,

View File

@ -0,0 +1,152 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
approveCoinSellerSubApplication,
listCoinSellerSubApplications,
rejectCoinSellerSubApplication,
} from "@/features/host-org/api";
import { useCoinSellerSubApplicationAbilities } from "@/features/host-org/permissions.js";
import { coinSellerSubApplicationReviewSchema } from "@/features/host-org/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyReviewForm = (decision = "approved") => ({
commandId: makeCommandId(`coin-seller-sub-${decision}`),
reason: "",
});
export function useHostSubCoinSellerApplicationsPage() {
const abilities = useCoinSellerSubApplicationAbilities();
const { showToast } = useToast();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("pending");
const [parentUserId, setParentUserId] = useState("");
const [targetUserId, setTargetUserId] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedApplication, setSelectedApplication] = useState(null);
const [reviewDecision, setReviewDecision] = useState("approved");
const [reviewForm, setReviewForm] = useState(emptyReviewForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword,
parent_user_id: parentUserId,
status,
target_user_id: targetUserId,
}),
[keyword, parentUserId, status, targetUserId],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载子币商申请列表失败",
fetcher: listCoinSellerSubApplications,
filters,
page,
pageSize,
queryKey: ["host-org", "coin-seller-sub-applications", filters, page],
});
const changeKeyword = (value) => {
setKeyword(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const changeParentUserId = (value) => {
setParentUserId(String(value || "").trim());
setPage(1);
};
const changeTargetUserId = (value) => {
setTargetUserId(String(value || "").trim());
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setParentUserId("");
setTargetUserId("");
setPage(1);
};
const openReview = (application, decision) => {
if (!application?.applicationId || !abilities.canAudit || application.status !== "pending") {
return;
}
// 审核 commandId 与列表刷新分离,避免弹窗重复提交时把另一次审核误认为同一请求。
setSelectedApplication(application);
setReviewDecision(decision);
setReviewForm(emptyReviewForm(decision));
setActiveAction("review");
};
const closeAction = () => {
setActiveAction("");
setSelectedApplication(null);
};
const submitReview = async (event) => {
event.preventDefault();
if (!selectedApplication?.applicationId || !abilities.canAudit) {
return;
}
const payload = parseForm(coinSellerSubApplicationReviewSchema, reviewForm);
const submitter =
reviewDecision === "approved" ? approveCoinSellerSubApplication : rejectCoinSellerSubApplication;
setLoadingAction(`review-${selectedApplication.applicationId}`);
try {
await submitter(selectedApplication.applicationId, payload);
showToast(reviewDecision === "approved" ? "子币商申请已通过" : "子币商申请已拒绝", "success");
closeAction();
await reload();
} catch (err) {
showToast(err.message || "审核子币商申请失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
changeKeyword,
changeParentUserId,
changeStatus,
changeTargetUserId,
closeAction,
data,
error,
keyword,
loading,
loadingAction,
openReview,
page,
parentUserId,
resetFilters,
reviewDecision,
reviewForm,
selectedApplication,
setPage,
setReviewForm,
status,
submitReview,
targetUserId,
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}

View File

@ -78,6 +78,39 @@ const baseColumns = [
},
];
const subSellerColumns = [
{
key: "seller",
label: "子币商",
render: (item) => <SellerIdentity item={item} />,
width: "minmax(220px, 1.3fr)",
},
{
key: "regionId",
label: "区域",
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(140px, 0.8fr)",
},
{
key: "status",
label: "状态",
render: (item) => item.status || "-",
width: "minmax(90px, 0.5fr)",
},
{
key: "merchantBalance",
label: "币商余额",
render: (item) => formatNumber(item.merchantBalance),
width: "minmax(120px, 0.8fr)",
},
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
];
export function HostCoinSellersPage() {
const page = useHostCoinSellersPage();
const items = page.data.items || [];
@ -99,6 +132,12 @@ export function HostCoinSellersPage() {
render: (item) => <SubCoinSellerPermissionSwitch item={item} page={page} />,
width: "minmax(120px, 0.7fr)",
},
{
key: "subCoinSellerCount",
label: "子币商数量",
render: (item) => <SubCoinSellerCountButton item={item} page={page} />,
width: "minmax(120px, 0.7fr)",
},
...baseColumns.slice(2),
page.abilities.canUpdate || page.abilities.canStockCredit || page.abilities.canLedger
? {
@ -220,9 +259,7 @@ export function HostCoinSellersPage() {
checked={page.createForm.canManageSubCoinSellers !== false}
disabled={createDisabled}
label="子币商权限"
onChange={(checked) =>
page.setCreateForm({ ...page.createForm, canManageSubCoinSellers: checked })
}
onChange={(checked) => page.setCreateForm({ ...page.createForm, canManageSubCoinSellers: checked })}
/>
</HostOrgActionModal>
@ -380,7 +417,9 @@ export function HostCoinSellersPage() {
required
minRows={2}
value={stockDebitForm.reason}
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
onChange={(event) =>
page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })
}
/>
</AdminFormSection>
</HostOrgActionModal>
@ -401,6 +440,35 @@ export function HostCoinSellersPage() {
) : null}
</SideDrawer>
<SideDrawer
className={styles.ledgerDrawer}
contentClassName={styles.ledgerDrawerBody}
open={page.activeAction === "sub-sellers"}
title={`子币商列表 - ${sellerDrawerTitle(page.selectedSeller)}`}
width="wide"
onClose={page.closeAction}
>
<DataState loading={page.loadingSubSellers}>
<HostOrgTable
columns={subSellerColumns}
context={{ regionOptions: page.regionOptions }}
items={page.subSellerData.items || []}
minWidth="860px"
pagination={
Number(page.subSellerData.total || 0) > 0
? {
page: page.subSellerPage,
pageSize: page.subSellerData.pageSize || 50,
total: page.subSellerData.total,
onPageChange: page.setSubSellerPage,
}
: undefined
}
rowKey={(item) => item.userId}
/>
</DataState>
</SideDrawer>
<SideDrawer
actions={
<>
@ -509,9 +577,7 @@ function SubCoinSellerPermissionSwitch({ item, page }) {
<AdminSwitch
checked={item.canManageSubCoinSellers === true}
checkedLabel="开"
disabled={
!page.abilities.canUpdate || page.loadingAction === `coin-seller-sub-permission-${item.userId}`
}
disabled={!page.abilities.canUpdate || page.loadingAction === `coin-seller-sub-permission-${item.userId}`}
inputProps={{ "aria-label": item.canManageSubCoinSellers === true ? "关闭子币商权限" : "开启子币商权限" }}
uncheckedLabel="关"
onChange={(event) => page.toggleSubCoinSellerPermission(item, event.target.checked)}
@ -519,6 +585,20 @@ function SubCoinSellerPermissionSwitch({ item, page }) {
);
}
function SubCoinSellerCountButton({ item, page }) {
const count = Number(item.subCoinSellerCount || 0);
return (
<Button
disabled={!page.abilities.canView || page.loadingSubSellers}
size="small"
variant="text"
onClick={() => page.openSubSellerList(item)}
>
{formatNumber(count)}
</Button>
);
}
function SellerContact({ item, page }) {
const contact = String(item.contact || "").trim();
return (

View File

@ -6,6 +6,7 @@ import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSel
const mocks = vi.hoisted(() => ({
ledgerTable: vi.fn(),
openSellerLedger: vi.fn(),
openSubSellerList: vi.fn(),
toggleSubCoinSellerPermission: vi.fn(),
}));
@ -43,9 +44,25 @@ test("coin seller table shows cumulative USDT and hides creator id column", () =
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
expect(screen.getByText("累充USDT")).toBeInTheDocument();
expect(screen.getByText("子币商权限")).toBeInTheDocument();
expect(screen.getByText("子币商数量")).toBeInTheDocument();
expect(screen.getByText("12.5")).toBeInTheDocument();
});
test("coin seller sub seller count opens direct child seller drawer", () => {
const seller = sellerFixture({ subCoinSellerCount: 2 });
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
fireEvent.click(screen.getByRole("button", { name: "2" }));
expect(mocks.openSubSellerList).toHaveBeenCalledWith(seller);
});
test("coin seller sub permission switch toggles through the page hook", () => {
const seller = sellerFixture();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
@ -104,7 +121,34 @@ test("coin seller ledger drawer passes locked seller id to shared table", () =>
render(<HostCoinSellersPage />);
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
expect(mocks.ledgerTable).toHaveBeenCalledWith(
expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }),
);
});
test("coin seller sub seller drawer renders approved direct children", () => {
const seller = sellerFixture({ subCoinSellerCount: 1 });
const child = sellerFixture({
displayUserId: "168933",
merchantBalance: 1200,
subCoinSellerCount: 0,
userId: "3002",
username: "Child seller",
});
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
activeAction: "sub-sellers",
data: { items: [seller], page: 1, pageSize: 50, total: 1 },
selectedSeller: seller,
subSellerData: { items: [child], page: 1, pageSize: 50, total: 1 },
}),
);
render(<HostCoinSellersPage />);
expect(screen.getByText("子币商列表 - Flower92 / 164425")).toBeInTheDocument();
expect(screen.getByText("Child seller")).toBeInTheDocument();
expect(screen.getByText("168933")).toBeInTheDocument();
});
test("coin seller create form includes sub permission switch", () => {
@ -189,6 +233,7 @@ function pageFixture(patch = {}) {
openRateSettings: vi.fn(),
openSellerLedger: mocks.openSellerLedger,
openStockCredit: vi.fn(),
openSubSellerList: mocks.openSubSellerList,
page: 1,
query: "",
rateRegionId: "",
@ -203,8 +248,11 @@ function pageFixture(patch = {}) {
setCreateForm: vi.fn(),
setEditForm: vi.fn(),
setPage: vi.fn(),
setSubSellerPage: vi.fn(),
setStockForm: vi.fn(),
status: "",
subSellerData: { items: [], page: 1, pageSize: 50, total: 0 },
subSellerPage: 1,
stockDebitForm: { coinAmount: "", reason: "", rechargeAmount: "" },
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
sortBy: "",
@ -220,7 +268,7 @@ function pageFixture(patch = {}) {
};
}
function sellerFixture() {
function sellerFixture(patch = {}) {
return {
avatar: "",
canManageSubCoinSellers: true,
@ -230,9 +278,11 @@ function sellerFixture() {
merchantBalance: 9400000,
regionId: 1,
status: "active",
subCoinSellerCount: 2,
totalRechargeUsdtMicro: 12500000,
updatedAtMs: 1760000000000,
userId: "3001",
username: "Flower92",
...patch,
};
}

View File

@ -0,0 +1,205 @@
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
import HighlightOffOutlined from "@mui/icons-material/HighlightOffOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
coinSellerSubApplicationStatusFilters,
coinSellerSubApplicationStatusLabels,
} from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostSubCoinSellerApplicationsPage } from "@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js";
import styles from "@/features/host-org/host-org.module.css";
const columns = [
{
key: "parent",
label: "父币商",
render: (item) => <ApplicationUserIdentity user={item.parent} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "target",
label: "申请子币商",
render: (item) => <ApplicationUserIdentity user={item.target} />,
width: "minmax(220px, 1.2fr)",
},
{
key: "status",
label: "状态",
render: (item) => <HostOrgStatus value={item.status} />,
width: "minmax(120px, 0.6fr)",
},
{
key: "createdAtMs",
label: "申请时间",
render: (item) => formatMillis(item.createdAtMs),
width: "minmax(170px, 0.9fr)",
},
{
key: "reviewedAtMs",
label: "审核时间",
render: (item) => (Number(item.reviewedAtMs || 0) > 0 ? formatMillis(item.reviewedAtMs) : "-"),
width: "minmax(170px, 0.9fr)",
},
{
key: "reviewedByAdminId",
label: "审核人",
render: (item) => item.reviewedByAdminId || "-",
width: "minmax(120px, 0.6fr)",
},
{
key: "reviewReason",
label: "审核备注",
render: (item) => item.reviewReason || "-",
width: "minmax(180px, 1fr)",
},
{
key: "actions",
label: "操作",
render: (item, _index, context) => <ApplicationActions item={item} page={context?.page} />,
width: "minmax(120px, 0.7fr)",
},
];
export function HostSubCoinSellerApplicationsPage() {
const page = useHostSubCoinSellerApplicationsPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const reviewTitle = page.reviewDecision === "approved" ? "通过子币商申请" : "拒绝子币商申请";
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<section className={styles.filters}>
<TextField
label="搜索"
size="small"
value={page.keyword}
onChange={(event) => page.changeKeyword(event.target.value)}
/>
<TextField
label="状态"
select
size="small"
value={page.status}
onChange={(event) => page.changeStatus(event.target.value)}
>
{coinSellerSubApplicationStatusFilters.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="父币商ID"
size="small"
type="number"
value={page.parentUserId}
onChange={(event) => page.changeParentUserId(event.target.value)}
/>
<TextField
label="目标用户ID"
size="small"
type="number"
value={page.targetUserId}
onChange={(event) => page.changeTargetUserId(event.target.value)}
/>
<Button variant="text" onClick={page.resetFilters}>
重置
</Button>
</section>
</div>
<DataState error={page.error} loading={page.loading}>
<div className={styles.listBlock}>
<HostOrgTable
columns={columns}
context={{ page }}
items={items}
minWidth="1280px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.applicationId}
/>
</div>
</DataState>
</div>
<HostOrgActionModal
disabled={!page.abilities.canAudit}
loading={String(page.loadingAction).startsWith("review-")}
open={page.activeAction === "review"}
sectionTitle="审核信息"
submitLabel={page.reviewDecision === "approved" ? "确认通过" : "确认拒绝"}
onClose={page.closeAction}
onSubmit={page.submitReview}
title={reviewTitle}
>
<TextField disabled label="申请状态" value={statusText(page.selectedApplication?.status)} />
<TextField disabled label="Command ID" value={page.reviewForm.commandId} />
<TextField
disabled={!page.abilities.canAudit}
label="审核备注"
multiline
minRows={3}
value={page.reviewForm.reason}
onChange={(event) => page.setReviewForm({ ...page.reviewForm, reason: event.target.value })}
/>
</HostOrgActionModal>
</section>
);
}
function ApplicationUserIdentity({ user }) {
return (
<AdminUserIdentity
avatar={user?.avatar}
displayUserId={user?.displayUserId}
name={user?.username}
rows={[user?.userId, user?.regionName].filter(Boolean)}
userId={user?.userId}
/>
);
}
function ApplicationActions({ item, page }) {
if (!page?.abilities.canAudit || item.status !== "pending") {
return <span>-</span>;
}
const loading = page.loadingAction === `review-${item.applicationId}`;
return (
<AdminRowActions>
<AdminActionIconButton
disabled={loading}
label="通过"
primary
onClick={() => page.openReview(item, "approved")}
>
<CheckCircleOutlineOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton disabled={loading} label="拒绝" onClick={() => page.openReview(item, "rejected")}>
<HighlightOffOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function statusText(status) {
return coinSellerSubApplicationStatusLabels[status] || status || "-";
}

View File

@ -0,0 +1,110 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { HostSubCoinSellerApplicationsPage } from "./HostSubCoinSellerApplicationsPage.jsx";
import { useHostSubCoinSellerApplicationsPage } from "@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js";
const mocks = vi.hoisted(() => ({
openReview: vi.fn(),
resetFilters: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostSubCoinSellerApplicationsPage.js", () => ({
useHostSubCoinSellerApplicationsPage: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("sub coin seller application page renders pending applications with audit actions", () => {
const application = applicationFixture();
vi.mocked(useHostSubCoinSellerApplicationsPage).mockReturnValue(pageFixture({ data: pageData([application]) }));
render(<HostSubCoinSellerApplicationsPage />);
expect(screen.getByText("父币商")).toBeInTheDocument();
expect(screen.getByText("申请子币商")).toBeInTheDocument();
expect(screen.getByText("Parent seller")).toBeInTheDocument();
expect(screen.getByText("Target user")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("通过").find((node) => node.tagName.toLowerCase() === "button"));
fireEvent.click(screen.getAllByLabelText("拒绝").find((node) => node.tagName.toLowerCase() === "button"));
expect(mocks.openReview).toHaveBeenCalledWith(application, "approved");
expect(mocks.openReview).toHaveBeenCalledWith(application, "rejected");
});
test("sub coin seller application page hides audit actions for reviewed rows", () => {
vi.mocked(useHostSubCoinSellerApplicationsPage).mockReturnValue(
pageFixture({ data: pageData([applicationFixture({ status: "approved" })]) }),
);
render(<HostSubCoinSellerApplicationsPage />);
expect(screen.queryByLabelText("通过")).not.toBeInTheDocument();
expect(screen.queryByLabelText("拒绝")).not.toBeInTheDocument();
});
function pageFixture(patch = {}) {
return {
abilities: { canAudit: true, canView: true },
activeAction: "",
changeKeyword: vi.fn(),
changeParentUserId: vi.fn(),
changeStatus: vi.fn(),
changeTargetUserId: vi.fn(),
closeAction: vi.fn(),
data: pageData([applicationFixture()]),
error: null,
keyword: "",
loading: false,
loadingAction: "",
openReview: mocks.openReview,
page: 1,
parentUserId: "",
resetFilters: mocks.resetFilters,
reviewDecision: "approved",
reviewForm: { commandId: "coin-seller-sub-approved-test", reason: "" },
selectedApplication: null,
setPage: vi.fn(),
setReviewForm: vi.fn(),
status: "pending",
submitReview: vi.fn((event) => event.preventDefault()),
targetUserId: "",
...patch,
};
}
function pageData(items) {
return { items, page: 1, pageSize: 50, total: items.length };
}
function applicationFixture(patch = {}) {
return {
applicationId: "sub-app-1",
createdAtMs: 1760000000000,
parent: {
avatar: "",
displayUserId: "163001",
regionName: "中东区",
userId: "3001",
username: "Parent seller",
},
parentUserId: "3001",
relationId: "",
reviewReason: "",
reviewedAtMs: 0,
reviewedByAdminId: "",
status: "pending",
target: {
avatar: "",
displayUserId: "168933",
regionName: "中东区",
userId: "3002",
username: "Target user",
},
targetUserId: "3002",
updatedAtMs: 1760000000000,
...patch,
};
}

View File

@ -68,6 +68,15 @@ export function useCoinSellerAbilities() {
};
}
export function useCoinSellerSubApplicationAbilities() {
const { can } = useAuth();
return {
canAudit: can(PERMISSIONS.coinSellerSubApplicationAudit),
canView: can(PERMISSIONS.coinSellerSubApplicationView),
};
}
export function useHostWithdrawalAbilities() {
const { can } = useAuth();

View File

@ -65,6 +65,17 @@ export const hostOrgRoutes = [
path: "/host/coin-sellers",
permission: PERMISSIONS.coinSellerView,
},
{
label: "Sub Coin Seller 申请列表",
loader: () =>
import("./pages/HostSubCoinSellerApplicationsPage.jsx").then(
(module) => module.HostSubCoinSellerApplicationsPage,
),
menuCode: MENU_CODES.hostOrgCoinSellerSubApplications,
pageKey: "host-org-coin-seller-sub-applications",
path: "/host/coin-seller-sub-applications",
permission: PERMISSIONS.coinSellerSubApplicationView,
},
{
label: "主播提现",
loader: () => import("./pages/HostWithdrawalsPage.jsx").then((module) => module.HostWithdrawalsPage),

View File

@ -3,6 +3,7 @@ import {
agencyCloseSchema,
agencyJoinEnabledSchema,
coinSellerStatusSchema,
coinSellerSubApplicationReviewSchema,
createCoinSellerSchema,
createManagerSchema,
countryRenameCodeSchema,
@ -56,6 +57,19 @@ test("coin seller schemas preserve sub seller permission defaults and explicit f
}
});
test("coin seller sub application review schema keeps command id and reason", () => {
const result = coinSellerSubApplicationReviewSchema.safeParse({
commandId: "coin-seller-sub-application-review-test",
reason: "same region and profile is active",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.commandId).toBe("coin-seller-sub-application-review-test");
expect(result.data.reason).toBe("same region and profile is active");
}
});
test("agency action schemas preserve large agency ids as strings", () => {
const agencyId = "321170072154411009";
const closeResult = agencyCloseSchema.safeParse({

View File

@ -130,6 +130,8 @@ export const coinSellerStatusSchema = commandContactSchema.extend({
targetUserId: entityIdSchema,
});
export const coinSellerSubApplicationReviewSchema = commandBaseSchema;
export const coinSellerStockCreditSchema = z
.object({
coinAmount: z.coerce.number().int().positive("请输入充值金币"),
@ -219,6 +221,7 @@ export type CreateBDForm = z.infer<typeof createBDSchema>;
export type BDStatusForm = z.infer<typeof bdStatusSchema>;
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
export type CoinSellerSubApplicationReviewForm = z.infer<typeof coinSellerSubApplicationReviewSchema>;
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;

File diff suppressed because it is too large Load Diff

View File

@ -1108,6 +1108,70 @@ export interface paths {
patch: operations["setCoinSellerStatus"];
trace?: never;
};
"/admin/coin-sellers/{user_id}/sub-sellers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCoinSellerSubSellers"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/coin-seller-sub-applications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCoinSellerSubApplications"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/coin-seller-sub-applications/{application_id}/approve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["approveCoinSellerSubApplication"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/coin-seller-sub-applications/{application_id}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["rejectCoinSellerSubApplication"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/coin-sellers/{user_id}/stock-credits": {
parameters: {
query?: never;
@ -4096,6 +4160,12 @@ export interface components {
pageSize: number;
total: number;
};
ApiPageCoinSellerSubApplication: {
items: components["schemas"]["CoinSellerSubApplication"][];
page: number;
pageSize: number;
total: number;
};
ApiPageCoinSellerLedger: {
items: components["schemas"]["CoinSellerLedger"][];
page: number;
@ -4255,6 +4325,12 @@ export interface components {
ApiResponseCoinSeller: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CoinSeller"];
};
ApiResponseCoinSellerSubApplicationPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageCoinSellerSubApplication"];
};
ApiResponseCoinSellerSubApplicationReview: components["schemas"]["Envelope"] & {
data?: components["schemas"]["CoinSellerSubApplicationReviewResult"];
};
ApiResponseCoinSellerLedgerPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageCoinSellerLedger"];
};
@ -4492,11 +4568,79 @@ export interface components {
regionName?: string;
/** @enum {string} */
status?: "active" | "disabled";
subCoinSellerCount?: number;
totalRechargeUsdtMicro?: number;
updatedAtMs?: number;
userId: string;
username?: string;
};
CoinSellerSubApplicationUser: {
avatar?: string;
displayUserId?: string;
regionId?: number;
regionName?: string;
userId: string;
username?: string;
};
CoinSellerSubApplication: {
applicationId: string;
commandId?: string;
createdAtMs?: number;
parent: components["schemas"]["CoinSellerSubApplicationUser"];
relationId?: string;
reviewedAtMs?: number;
reviewedByAdminId?: string;
reviewReason?: string;
/** @enum {string} */
status: "pending" | "approved" | "rejected";
target: components["schemas"]["CoinSellerSubApplicationUser"];
updatedAtMs?: number;
};
CoinSellerSubApplicationReviewApplication: {
applicationId: string;
commandId?: string;
createdAtMs?: number;
parentUserId: string;
relationId?: string;
reviewedAtMs?: number;
reviewedByAdminId?: string;
reviewReason?: string;
/** @enum {string} */
status: "pending" | "approved" | "rejected";
targetUserId: string;
updatedAtMs?: number;
};
CoinSellerSubRelation: {
childUserId: string;
createdAtMs?: number;
createdByUserId?: string;
parentUserId: string;
relationId: string;
status: string;
updatedAtMs?: number;
};
CoinSellerSubApplicationReviewChild: {
avatar?: string;
canManageSubCoinSellers?: boolean;
contactInfo?: string;
displayUserId?: string;
merchantAssetType?: string;
regionId?: number;
regionName?: string;
status?: string;
updatedAtMs?: number;
userId: string;
username?: string;
};
CoinSellerSubApplicationReviewPayload: {
commandId: string;
reason?: string;
};
CoinSellerSubApplicationReviewResult: {
application?: components["schemas"]["CoinSellerSubApplicationReviewApplication"];
child?: components["schemas"]["CoinSellerSubApplicationReviewChild"];
relation?: components["schemas"]["CoinSellerSubRelation"];
};
CoinLedgerUser: {
avatar?: string;
displayUserId?: string;
@ -4509,7 +4653,15 @@ export interface components {
/** Format: int64 */
availableDelta: number;
/** @enum {string} */
bizType: "coin_seller_stock_purchase" | "coin_seller_stock_deduction" | "coin_seller_recharge" | "coin_seller_coin_compensation" | "coin_seller_transfer" | "coin_seller_sub_transfer" | "salary_transfer_to_coin_seller" | "manual_credit";
bizType:
| "coin_seller_stock_purchase"
| "coin_seller_stock_deduction"
| "coin_seller_recharge"
| "coin_seller_coin_compensation"
| "coin_seller_transfer"
| "coin_seller_sub_transfer"
| "salary_transfer_to_coin_seller"
| "manual_credit";
commandId?: string;
counterpartyUserId?: string;
/** Format: int64 */
@ -4841,7 +4993,13 @@ export interface components {
appCode: string;
appName?: string;
/** @enum {string} */
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
operation:
| "user_coin_credit"
| "user_coin_debit"
| "user_wallet_debit"
| "user_wallet_credit"
| "coin_seller_coin_credit"
| "coin_seller_coin_debit";
walletIdentity?: string;
targetUserId: string;
coinAmount: number;
@ -4923,7 +5081,13 @@ export interface components {
FinanceApplicationInput: {
appCode: string;
/** @enum {string} */
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
operation:
| "user_coin_credit"
| "user_coin_debit"
| "user_wallet_debit"
| "user_wallet_credit"
| "coin_seller_coin_credit"
| "coin_seller_coin_debit";
/** @enum {string} */
walletIdentity?: "host" | "agency" | "bd";
targetUserId: string;
@ -4940,6 +5104,8 @@ export interface components {
/** @enum {string} */
providerCode: "mifapay" | "v5pay" | "usdt";
externalOrderNo: string;
providerCurrencyCode?: string;
providerAmountMinor?: number;
/** @enum {string} */
chain?: "TRON" | "BSC";
remark?: string;
@ -4968,6 +5134,8 @@ export interface components {
chain?: "TRON" | "BSC";
usdAmount?: number;
usdMinorAmount?: number;
providerCurrencyCode?: string;
providerAmountMinor?: number;
};
UserFinanceScope: {
appCode: string;
@ -5021,6 +5189,24 @@ export interface components {
};
};
/** @description OK */
CoinSellerSubApplicationPageResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCoinSellerSubApplicationPage"];
};
};
/** @description OK */
CoinSellerSubApplicationReviewResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseCoinSellerSubApplicationReview"];
};
};
/** @description OK */
CoinSellerLedgerPageResponse: {
headers: {
[name: string]: unknown;
@ -5398,6 +5584,11 @@ export interface components {
"application/json": components["schemas"]["CoinSellerStatusPayload"];
};
};
CoinSellerSubApplicationReviewRequest: {
content: {
"application/json": components["schemas"]["CoinSellerSubApplicationReviewPayload"];
};
};
CountryCreateRequest: {
content: {
"application/json": components["schemas"]["CountryCreateInput"];
@ -6893,6 +7084,70 @@ export interface operations {
200: components["responses"]["CoinSellerResponse"];
};
};
listCoinSellerSubSellers: {
parameters: {
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
};
header?: never;
path: {
user_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["CoinSellerPageResponse"];
};
};
listCoinSellerSubApplications: {
parameters: {
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
keyword?: components["parameters"]["Keyword"];
status?: "pending" | "approved" | "rejected";
parent_user_id?: string;
target_user_id?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["CoinSellerSubApplicationPageResponse"];
};
};
approveCoinSellerSubApplication: {
parameters: {
query?: never;
header?: never;
path: {
application_id: string;
};
cookie?: never;
};
requestBody: components["requestBodies"]["CoinSellerSubApplicationReviewRequest"];
responses: {
200: components["responses"]["CoinSellerSubApplicationReviewResponse"];
};
};
rejectCoinSellerSubApplication: {
parameters: {
query?: never;
header?: never;
path: {
application_id: string;
};
cookie?: never;
};
requestBody: components["requestBodies"]["CoinSellerSubApplicationReviewRequest"];
responses: {
200: components["responses"]["CoinSellerSubApplicationReviewResponse"];
};
};
creditCoinSellerStock: {
parameters: {
query?: never;
@ -7770,7 +8025,11 @@ export interface operations {
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
ledger_type?:
| "admin_stock_credit"
| "seller_transfer"
| "sub_seller_transfer"
| "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
@ -7793,7 +8052,11 @@ export interface operations {
seller_filter_user_id?: string;
seller_display_user_id?: string;
seller_username?: string;
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
ledger_type?:
| "admin_stock_credit"
| "seller_transfer"
| "sub_seller_transfer"
| "salary_transfer_received";
start_at_ms?: number;
end_at_ms?: number;
};
@ -7847,7 +8110,13 @@ export interface operations {
sent_at_ms?: number;
summary: string;
/** @enum {string} */
target_scope: "all_registered_users" | "all_active_users" | "single_user" | "user_ids" | "region" | "country";
target_scope:
| "all_registered_users"
| "all_active_users"
| "single_user"
| "user_ids"
| "region"
| "country";
/** Format: int64 */
target_user_id?: number;
title: string;

View File

@ -628,7 +628,9 @@ export interface FinanceCoinSellerRechargeOrderPayload {
chain?: string;
coinAmount: number;
externalOrderNo: string;
providerAmountMinor?: number;
providerCode: "mifapay" | "v5pay" | "usdt";
providerCurrencyCode?: string;
remark?: string;
targetUserId: EntityId;
usdAmount: number;
@ -638,7 +640,9 @@ export interface FinanceCoinSellerRechargeReceiptVerificationPayload {
appCode: string;
chain?: string;
externalOrderNo: string;
providerAmountMinor?: number;
providerCode: "mifapay" | "v5pay" | "usdt";
providerCurrencyCode?: string;
usdAmount?: number;
usdMinorAmount?: number;
}
@ -842,12 +846,85 @@ export interface CoinSellerDto {
regionId?: number;
regionName?: string;
status?: string;
subCoinSellerCount?: number;
totalRechargeUsdtMicro?: number;
updatedAtMs?: number;
userId: string;
username?: string;
}
export interface CoinSellerSubApplicationUserDto {
avatar?: string;
displayUserId?: string;
regionId?: number;
regionName?: string;
userId: string;
username?: string;
}
export interface CoinSellerSubApplicationDto {
applicationId: string;
commandId?: string;
createdAtMs?: number;
parent: CoinSellerSubApplicationUserDto;
relationId?: string;
reviewedAtMs?: number;
reviewedByAdminId?: string;
reviewReason?: string;
status: "pending" | "approved" | "rejected";
target: CoinSellerSubApplicationUserDto;
updatedAtMs?: number;
}
export interface CoinSellerSubApplicationReviewPayload {
commandId: string;
reason?: string;
}
export interface CoinSellerSubApplicationReviewApplicationDto {
applicationId: string;
commandId?: string;
createdAtMs?: number;
parentUserId: string;
relationId?: string;
reviewedAtMs?: number;
reviewedByAdminId?: string;
reviewReason?: string;
status: "pending" | "approved" | "rejected";
targetUserId: string;
updatedAtMs?: number;
}
export interface CoinSellerSubRelationDto {
childUserId: string;
createdAtMs?: number;
createdByUserId?: string;
parentUserId: string;
relationId: string;
status: string;
updatedAtMs?: number;
}
export interface CoinSellerSubApplicationReviewChildDto {
avatar?: string;
canManageSubCoinSellers?: boolean;
contactInfo?: string;
displayUserId?: string;
merchantAssetType?: string;
regionId?: number;
regionName?: string;
status?: string;
updatedAtMs?: number;
userId: string;
username?: string;
}
export interface CoinSellerSubApplicationReviewResultDto {
application?: CoinSellerSubApplicationReviewApplicationDto;
child?: CoinSellerSubApplicationReviewChildDto;
relation?: CoinSellerSubRelationDto;
}
export interface CoinSellerStockCreditPayload {
coinAmount: number;
commandId: string;