币商转账

This commit is contained in:
zhx 2026-06-15 01:28:58 +08:00
parent aa7ce50e4f
commit 1447de8563
7 changed files with 212 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {
createCoinSeller,
createRegion,
creditCoinSellerStock,
debitCoinSellerStock,
deleteAgency,
disableRegion,
listAgencies,
@ -61,6 +62,11 @@ test("host org list APIs use generated admin paths and filters", async () => {
rechargeAmount: "100.000000",
type: "usdt_purchase",
});
await debitCoinSellerStock("1001", {
coinAmount: 1000,
commandId: "coin-seller-stock-debit-test",
reason: "deduct bad stock",
});
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
@ -72,7 +78,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[9];
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -104,6 +111,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(coinSellerStatusInit?.method).toBe("PATCH");
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
expect(coinSellerStockInit?.method).toBe("POST");
expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
expect(coinSellerStockDebitInit?.method).toBe("POST");
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
expect(agencyDeleteInit?.method).toBe("POST");
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });

View File

@ -14,6 +14,8 @@ import type {
CoinSellerSalaryRatesPayload,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStockDebitDto,
CoinSellerStockDebitPayload,
CoinSellerStatusPayload,
CountryDto,
CountryPayload,
@ -283,6 +285,19 @@ export function creditCoinSellerStock(
);
}
export function debitCoinSellerStock(
userId: EntityId,
payload: CoinSellerStockDebitPayload,
): Promise<CoinSellerStockDebitDto> {
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
`/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`,
{
body: payload,
method: "POST",
},
);
}
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto>(

View File

@ -6,6 +6,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createCoinSeller,
creditCoinSellerStock,
debitCoinSellerStock,
getCoinSellerSalaryRates,
listCoinSellers,
replaceCoinSellerSalaryRates,
@ -15,6 +16,7 @@ import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import {
coinSellerStatusSchema,
coinSellerStockCreditSchema,
coinSellerStockDebitSchema,
createCoinSellerSchema,
} from "@/features/host-org/schema";
@ -35,6 +37,11 @@ const emptyStockForm = () => ({
rechargeAmount: "",
type: "usdt_purchase",
});
const emptyStockDebitForm = () => ({
coinAmount: "",
commandId: makeCommandId("coin-seller-stock-debit"),
reason: "",
});
const emptyRateTier = (index = 0) => ({
coinPerUsd: "",
enabled: true,
@ -57,6 +64,7 @@ export function useHostCoinSellersPage() {
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [stockForm, setStockForm] = useState(emptyStockForm);
const [stockDebitForm, setStockDebitForm] = useState(emptyStockDebitForm);
const [rateRegionId, setRateRegionId] = useState("");
const [rateTiers, setRateTiers] = useState([]);
const [loadingRates, setLoadingRates] = useState(false);
@ -158,6 +166,15 @@ export function useHostCoinSellersPage() {
setActiveAction("stock");
};
const openStockDebit = (seller) => {
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
return;
}
setSelectedSeller(seller);
setStockDebitForm(emptyStockDebitForm());
setActiveAction("stock-debit");
};
const openSellerLedger = (seller) => {
if (!seller?.userId || !abilities.canLedger) {
return;
@ -308,6 +325,20 @@ export function useHostCoinSellersPage() {
});
};
const submitStockDebit = async (event) => {
event.preventDefault();
if (!selectedSeller?.userId) {
return;
}
await runAction(`coin-seller-stock-debit-${selectedSeller.userId}`, "币商金币已扣除", async () => {
const payload = parseForm(coinSellerStockDebitSchema, stockDebitForm);
await debitCoinSellerStock(selectedSeller.userId, payload);
setStockDebitForm(emptyStockDebitForm());
closeAction();
await reload();
});
};
const submitRateSettings = async (event) => {
event.preventDefault();
if (!rateRegionId) {
@ -383,6 +414,7 @@ export function useHostCoinSellersPage() {
openEditSeller,
openSellerLedger,
openStockCredit,
openStockDebit,
page,
query,
regionId,
@ -400,7 +432,9 @@ export function useHostCoinSellersPage() {
setEditForm,
setPage,
setStockForm,
setStockDebitForm,
status,
stockDebitForm,
stockForm,
sortBy,
sortDirection,
@ -408,6 +442,7 @@ export function useHostCoinSellersPage() {
submitCreateSeller,
submitEditSeller,
submitStockCredit,
submitStockDebit,
toggleSeller,
updateRateTier,
};

View File

@ -3,6 +3,7 @@ import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
@ -79,6 +80,7 @@ export function HostCoinSellersPage() {
const page = useHostCoinSellersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const stockDebitForm = page.stockDebitForm || { coinAmount: "", reason: "" };
const createDisabled = !page.abilities.canCreate;
const updateDisabled = !page.abilities.canUpdate;
const columns = [
@ -95,7 +97,7 @@ export function HostCoinSellersPage() {
key: "actions",
label: "操作",
render: (item) => <SellerActions item={item} page={page} />,
width: "minmax(90px, 0.5fr)",
width: "minmax(130px, 0.65fr)",
}
: null,
]
@ -303,6 +305,52 @@ export function HostCoinSellersPage() {
</AdminFormSection>
</HostOrgActionModal>
<HostOrgActionModal
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
open={page.activeAction === "stock-debit"}
sectioned={false}
size="standard"
submitLabel="确认扣除"
onClose={page.closeAction}
onSubmit={page.submitStockDebit}
title="币商金币扣除"
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
<AdminFormReadOnlyField
label="当前库存余额"
value={formatNumber(page.selectedSeller?.merchantBalance)}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="扣除信息">
<AdminFormAmountField
disabled={!page.abilities.canStockCredit}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="扣除金币"
required
unit="金币"
value={stockDebitForm.coinAmount}
onChange={(event) =>
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
}
/>
</AdminFormSection>
<AdminFormSection title="备注信息">
<TextField
disabled={!page.abilities.canStockCredit}
label="原因"
multiline
required
minRows={2}
value={stockDebitForm.reason}
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
/>
</AdminFormSection>
</HostOrgActionModal>
<SideDrawer
className={styles.ledgerDrawer}
contentClassName={styles.ledgerDrawerBody}
@ -464,6 +512,16 @@ function SellerActions({ item, page }) {
<MonetizationOnOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canStockCredit ? (
<AdminActionIconButton
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
label="扣金币"
sx={coinDebitActionSx}
onClick={() => page.openStockDebit(item)}
>
<RemoveCircleOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
@ -555,3 +613,17 @@ const coinCreditActionSx = {
color: "#f59e0b",
},
};
const coinDebitActionSx = {
borderColor: "rgba(220, 38, 38, 0.34)",
backgroundColor: "rgba(254, 226, 226, 0.65)",
color: "#b91c1c",
"&:hover": {
borderColor: "rgba(220, 38, 38, 0.58)",
backgroundColor: "rgba(254, 202, 202, 0.75)",
color: "#991b1b",
},
"& .MuiSvgIcon-root": {
color: "#dc2626",
},
};

View File

@ -130,6 +130,12 @@ export const coinSellerStockCreditSchema = z
}
});
export const coinSellerStockDebitSchema = z.object({
coinAmount: z.coerce.number().int().positive("请输入扣除金币"),
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"),
});
export const createAgencySchema = commandBaseSchema.extend({
joinEnabled: z.boolean().default(true),
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
@ -181,6 +187,7 @@ export type BDStatusForm = z.infer<typeof bdStatusSchema>;
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;

View File

@ -30,8 +30,9 @@ const ledgerTypeLabels = {
const bizTypeLabels = {
coin_seller_coin_compensation: "金币补偿",
coin_seller_stock_purchase: "币商进货",
coin_seller_stock_purchase: "USDT进货",
coin_seller_transfer: "币商转用户",
manual_credit: "金币增加",
salary_transfer_to_coin_seller: "工资转币商",
};
@ -50,10 +51,22 @@ const baseColumns = [
},
{
key: "amount",
label: "转账金额",
label: "金币变动",
render: (entry) => <AmountValue entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "transferUsdMinor",
label: "转账金额",
render: (entry) => <SalaryTransferAmount entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "paidAmountMicro",
label: "USDT数量",
render: (entry) => <PaidUSDTAmount entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "receiver",
label: "收款人信息",
@ -197,7 +210,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
columns={tableColumns}
emptyLabel="当前无币商流水"
items={items}
minWidth={fixedSellerUserId ? "890px" : "1160px"}
minWidth={fixedSellerUserId ? "1130px" : "1380px"}
pagination={
total > 0
? {
@ -247,11 +260,44 @@ function AmountValue({ entry }) {
}
function ledgerTypeLabel(entry) {
return (
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-"
);
if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) {
return "金币扣除";
}
return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function SalaryTransferAmount({ entry }) {
if (entry.bizType !== "salary_transfer_to_coin_seller" && entry.ledgerType !== "salary_transfer_received") {
return "-";
}
const amountMinor = Number(entry.transferUsdMinor || entry.metadata?.salary_usd_minor || 0);
return amountMinor > 0 ? `$${formatUSDMinor(amountMinor)}` : "-";
}
function PaidUSDTAmount({ entry }) {
const stockType = entry.stockType || entry.metadata?.stock_type;
if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") {
return "-";
}
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-";
}
function formatUSDMinor(value) {
return (Number(value || 0) / 100).toFixed(2);
}
function formatUSDTMicro(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) {
return "0";
}
const whole = Math.trunc(amount / 1_000_000);
const fraction = Math.trunc(Math.abs(amount % 1_000_000));
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
}

View File

@ -228,10 +228,15 @@ export interface CoinSellerLedgerDto {
metadata?: Record<string, unknown>;
operator?: CoinAdjustmentOperatorDto;
operatorUserId?: string;
paidAmountMicro?: number;
paidCurrencyCode?: string;
receiver: CoinLedgerUserDto;
reason?: string;
seller: CoinLedgerUserDto;
sellerBalanceAfter: number;
stockType?: string;
transactionId: string;
transferUsdMinor?: number;
}
export interface CoinAdjustmentOperatorDto {
@ -601,6 +606,21 @@ export interface CoinSellerStockCreditDto {
transactionId: string;
}
export interface CoinSellerStockDebitPayload {
coinAmount: number;
commandId: string;
evidenceRef?: string;
reason: string;
}
export interface CoinSellerStockDebitDto {
availableDelta: number;
balanceAfter: number;
coinAmount: number;
sellerUserId: string;
transactionId: string;
}
export interface CoinSellerSalaryRateTierDto {
coinPerUsd: number;
enabled?: boolean;