feat(p2p-transfer): 实现P2P划转手续费功能(全栈)
## 功能概述 - P2P转账(积分值)支持手续费收取,手续费存入系统成本费账户 S0000000002 - 发送方实际扣除 = 转账金额 + 手续费,接收方全额收到转账金额 - 手续费金额和最小划转金额可在管理后台动态配置(默认: 手续费5, 最小划转6) ## 后端 — mining-admin-service - GET /configs/p2p-transfer-fee: 管理端获取手续费配置(需鉴权) - POST /configs/p2p-transfer-fee: 管理端设置手续费配置,校验最小划转 > 手续费 - GET /configs/internal/p2p-transfer-fee: 内部调用端点(@Public 无鉴权) ## 后端 — trading-service - Prisma schema: P2pTransfer model 新增 fee Decimal(30,8) 字段 - docker-compose: 新增 MINING_ADMIN_SERVICE_URL 环境变量 - p2p-transfer.service: 动态获取手续费配置,余额校验含手续费, 事务内分别记录转账流水和手续费流水(P2P_TRANSFER_FEE), 手续费存入系统成本费账户 S0000000002 - p2p-transfer.controller: 新增 GET /p2p/transfer-fee-config 代理端点 - 转账结果和历史记录新增 fee 字段返回 ## 前端 — mining-admin-web - configs.api.ts: 新增 getP2pTransferFee / setP2pTransferFee API - use-configs.ts: 新增 useP2pTransferFee / useSetP2pTransferFee hooks - configs/page.tsx: 新增"P2P划转手续费设置"卡片(手续费 + 最小划转金额) ## 前端 — mining-app (Flutter) - api_endpoints.dart: 新增 p2pTransferFeeConfig 端点常量 - p2p_transfer_fee_config_model.dart: 新增手续费配置 Model - trading_remote_datasource.dart: 新增 getP2pTransferFeeConfig 方法 - transfer_providers.dart: 新增 p2pTransferFeeConfigProvider - send_shares_page.dart: 发送页面显示手续费信息、最小划转金额提示、 实际扣除金额计算、"全部"按钮扣除手续费、确认弹窗展示手续费明细 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
817b7d3a9f
commit
ca4e5393be
|
|
@ -113,6 +113,7 @@ services:
|
|||
# 2.0 内部服务调用
|
||||
MINING_SERVICE_URL: http://mining-service:3021
|
||||
AUTH_SERVICE_URL: http://auth-service:3024
|
||||
MINING_ADMIN_SERVICE_URL: http://mining-admin-service:3023
|
||||
# JWT 配置 (与 auth-service 共享密钥以验证 token)
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-change-in-production}
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Controller, Get, Post, Delete, Body, Param, Query, Req, Logger } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Param, Query, Req, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConfigManagementService } from '../../application/services/config.service';
|
||||
import { Public } from '../../shared/guards/admin-auth.guard';
|
||||
|
||||
class SetConfigDto { category: string; key: string; value: string; description?: string; }
|
||||
|
||||
|
|
@ -187,6 +188,67 @@ export class ConfigController {
|
|||
}
|
||||
}
|
||||
|
||||
// ============ P2P 划转手续费配置 ============
|
||||
|
||||
@Get('p2p-transfer-fee')
|
||||
@ApiOperation({ summary: '获取P2P划转手续费配置' })
|
||||
async getP2pTransferFee() {
|
||||
const [feeConfig, minAmountConfig] = await Promise.all([
|
||||
this.configService.getConfig('trading', 'p2p_transfer_fee'),
|
||||
this.configService.getConfig('trading', 'min_p2p_transfer_amount'),
|
||||
]);
|
||||
return {
|
||||
fee: feeConfig?.configValue ?? '5',
|
||||
minTransferAmount: minAmountConfig?.configValue ?? '6',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('p2p-transfer-fee')
|
||||
@ApiOperation({ summary: '设置P2P划转手续费配置' })
|
||||
async setP2pTransferFee(
|
||||
@Body() body: { fee: string; minTransferAmount: string },
|
||||
@Req() req: any,
|
||||
) {
|
||||
const fee = parseFloat(body.fee);
|
||||
const minAmount = parseFloat(body.minTransferAmount);
|
||||
|
||||
if (isNaN(fee) || fee < 0) {
|
||||
throw new BadRequestException('手续费必须 >= 0');
|
||||
}
|
||||
if (isNaN(minAmount) || minAmount <= fee) {
|
||||
throw new BadRequestException('最小划转金额必须大于手续费');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.configService.setConfig(
|
||||
req.admin.id, 'trading', 'p2p_transfer_fee',
|
||||
body.fee, 'P2P划转手续费(积分值)',
|
||||
),
|
||||
this.configService.setConfig(
|
||||
req.admin.id, 'trading', 'min_p2p_transfer_amount',
|
||||
body.minTransferAmount, 'P2P最小划转金额(积分值)',
|
||||
),
|
||||
]);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('internal/p2p-transfer-fee')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取P2P划转手续费配置(内部调用,无需鉴权)' })
|
||||
async getP2pTransferFeeInternal() {
|
||||
const [feeConfig, minAmountConfig] = await Promise.all([
|
||||
this.configService.getConfig('trading', 'p2p_transfer_fee'),
|
||||
this.configService.getConfig('trading', 'min_p2p_transfer_amount'),
|
||||
]);
|
||||
return {
|
||||
fee: feeConfig?.configValue ?? '5',
|
||||
minTransferAmount: minAmountConfig?.configValue ?? '6',
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 通用配置 ============
|
||||
|
||||
@Get(':category/:key')
|
||||
@ApiOperation({ summary: '获取单个配置' })
|
||||
@ApiParam({ name: 'category' })
|
||||
|
|
|
|||
|
|
@ -403,6 +403,7 @@ model P2pTransfer {
|
|||
fromPhone String? @map("from_phone") // 发送方手机号
|
||||
fromNickname String? @map("from_nickname") // 发送方昵称
|
||||
amount Decimal @db.Decimal(30, 8)
|
||||
fee Decimal @default(0) @db.Decimal(30, 8) // P2P转账手续费
|
||||
memo String? @db.Text // 备注
|
||||
status String @default("PENDING") // PENDING, COMPLETED, FAILED
|
||||
errorMessage String? @map("error_message")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ class P2pTransferDto {
|
|||
export class P2pTransferController {
|
||||
constructor(private readonly p2pTransferService: P2pTransferService) {}
|
||||
|
||||
@Get('transfer-fee-config')
|
||||
@ApiOperation({ summary: '获取P2P转账手续费配置' })
|
||||
async getTransferFeeConfig() {
|
||||
return this.p2pTransferService.getP2pFeeConfigPublic();
|
||||
}
|
||||
|
||||
@Post('transfer')
|
||||
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
||||
async transfer(
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ interface RecipientInfo {
|
|||
nickname?: string;
|
||||
}
|
||||
|
||||
interface P2pFeeConfig {
|
||||
fee: number;
|
||||
minTransferAmount: number;
|
||||
}
|
||||
|
||||
export interface P2pTransferResult {
|
||||
transferNo: string;
|
||||
amount: string;
|
||||
fee: string;
|
||||
toPhone: string;
|
||||
toNickname?: string;
|
||||
status: string;
|
||||
|
|
@ -26,16 +32,20 @@ export interface P2pTransferHistoryItem {
|
|||
toAccountSequence: string;
|
||||
toPhone: string;
|
||||
amount: string;
|
||||
fee?: string;
|
||||
memo?: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** 系统成本费账户序列号 */
|
||||
const FEE_ACCOUNT_SEQUENCE = 'S0000000002';
|
||||
|
||||
@Injectable()
|
||||
export class P2pTransferService {
|
||||
private readonly logger = new Logger(P2pTransferService.name);
|
||||
private readonly authServiceUrl: string;
|
||||
private readonly minTransferAmount: number;
|
||||
private readonly miningAdminServiceUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly accountRepository: TradingAccountRepository,
|
||||
|
|
@ -43,7 +53,44 @@ export class P2pTransferService {
|
|||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://auth-service:3024');
|
||||
this.minTransferAmount = this.configService.get<number>('MIN_P2P_TRANSFER_AMOUNT', 0.01);
|
||||
this.miningAdminServiceUrl = this.configService.get<string>(
|
||||
'MINING_ADMIN_SERVICE_URL', 'http://mining-admin-service:3023',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取P2P手续费配置(从 mining-admin-service 动态读取)
|
||||
*/
|
||||
private async getP2pFeeConfig(): Promise<P2pFeeConfig> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.miningAdminServiceUrl}/api/v2/configs/internal/p2p-transfer-fee`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
this.logger.warn('Failed to fetch P2P fee config, using defaults');
|
||||
return { fee: 5, minTransferAmount: 6 };
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
return {
|
||||
fee: parseFloat(data.fee) || 5,
|
||||
minTransferAmount: parseFloat(data.minTransferAmount) || 6,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to fetch P2P fee config: ${error.message}`);
|
||||
return { fee: 5, minTransferAmount: 6 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取P2P手续费配置(供 Controller 暴露给前端)
|
||||
*/
|
||||
async getP2pFeeConfigPublic(): Promise<{ fee: string; minTransferAmount: string }> {
|
||||
const config = await this.getP2pFeeConfig();
|
||||
return {
|
||||
fee: config.fee.toString(),
|
||||
minTransferAmount: config.minTransferAmount.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,7 +122,7 @@ export class P2pTransferService {
|
|||
}
|
||||
|
||||
/**
|
||||
* P2P转账(积分值)
|
||||
* P2P转账(积分值)— 含手续费扣除
|
||||
*/
|
||||
async transfer(
|
||||
fromAccountSequence: string,
|
||||
|
|
@ -86,9 +133,13 @@ export class P2pTransferService {
|
|||
): Promise<P2pTransferResult> {
|
||||
const transferAmount = new Money(amount);
|
||||
|
||||
// 验证转账金额
|
||||
if (transferAmount.value.lessThan(this.minTransferAmount)) {
|
||||
throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`);
|
||||
// 获取动态手续费配置
|
||||
const feeConfig = await this.getP2pFeeConfig();
|
||||
const feeAmount = new Money(feeConfig.fee.toString());
|
||||
|
||||
// 验证最小转账金额
|
||||
if (transferAmount.value.lessThan(feeConfig.minTransferAmount)) {
|
||||
throw new BadRequestException(`最小转账金额为 ${feeConfig.minTransferAmount} 积分值`);
|
||||
}
|
||||
|
||||
// 查找收款方
|
||||
|
|
@ -108,9 +159,14 @@ export class P2pTransferService {
|
|||
throw new NotFoundException('发送方账户不存在');
|
||||
}
|
||||
|
||||
// 检查余额
|
||||
if (fromAccount.availableCash.isLessThan(transferAmount)) {
|
||||
throw new BadRequestException('可用积分值不足');
|
||||
// 计算总扣除金额 = 转账金额 + 手续费
|
||||
const totalDeduction = transferAmount.add(feeAmount);
|
||||
|
||||
// 检查余额(需覆盖手续费)
|
||||
if (fromAccount.availableCash.isLessThan(totalDeduction)) {
|
||||
throw new BadRequestException(
|
||||
`可用积分值不足(需要 ${totalDeduction.toFixed(2)},含手续费 ${feeAmount.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
|
||||
const transferNo = this.generateTransferNo();
|
||||
|
|
@ -127,13 +183,14 @@ export class P2pTransferService {
|
|||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
amount: transferAmount.value,
|
||||
fee: feeAmount.value,
|
||||
memo,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 扣减发送方余额
|
||||
fromAccount.withdraw(transferAmount, transferNo);
|
||||
// 2. 扣减发送方余额(转账金额 + 手续费)
|
||||
fromAccount.withdraw(totalDeduction, transferNo);
|
||||
|
||||
// 保存发送方账户变动
|
||||
await tx.tradingAccount.update({
|
||||
|
|
@ -143,27 +200,48 @@ export class P2pTransferService {
|
|||
},
|
||||
});
|
||||
|
||||
// 记录发送方交易流水
|
||||
for (const txn of fromAccount.pendingTransactions) {
|
||||
// 手动写入发送方交易流水(拆分为转账和手续费两条记录)
|
||||
const balanceBeforeTransfer = fromAccount.cashBalance.add(totalDeduction);
|
||||
const balanceAfterTransfer = balanceBeforeTransfer.subtract(transferAmount);
|
||||
|
||||
// 流水1: 转账金额
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: fromAccountSequence,
|
||||
type: 'WITHDRAW',
|
||||
assetType: 'CASH',
|
||||
amount: transferAmount.value,
|
||||
balanceBefore: balanceBeforeTransfer.value,
|
||||
balanceAfter: balanceAfterTransfer.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: recipient.accountSequence,
|
||||
memo: `P2P转出给 ${toPhone}`,
|
||||
},
|
||||
});
|
||||
|
||||
// 流水2: 手续费(仅当手续费 > 0)
|
||||
if (!feeAmount.isZero()) {
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: fromAccountSequence,
|
||||
type: txn.type,
|
||||
assetType: txn.assetType,
|
||||
amount: txn.amount.value,
|
||||
balanceBefore: txn.balanceBefore.value,
|
||||
balanceAfter: txn.balanceAfter.value,
|
||||
type: 'WITHDRAW',
|
||||
assetType: 'CASH',
|
||||
amount: feeAmount.value,
|
||||
balanceBefore: balanceAfterTransfer.value,
|
||||
balanceAfter: fromAccount.cashBalance.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: recipient.accountSequence,
|
||||
memo: `P2P转出给 ${toPhone}`,
|
||||
referenceType: 'P2P_TRANSFER_FEE',
|
||||
counterpartyType: 'SYSTEM',
|
||||
memo: 'P2P转账手续费',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fromAccount.clearPendingTransactions();
|
||||
|
||||
// 3. 增加收款方余额
|
||||
// 3. 增加收款方余额(仅转账金额,不含手续费)
|
||||
let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!);
|
||||
if (!toAccount) {
|
||||
toAccount = TradingAccountAggregate.create(recipient.accountSequence!);
|
||||
|
|
@ -210,7 +288,42 @@ export class P2pTransferService {
|
|||
});
|
||||
}
|
||||
|
||||
// 4. 更新转账记录为完成
|
||||
// 4. 手续费存入系统成本费账户(仅当手续费 > 0)
|
||||
if (!feeAmount.isZero()) {
|
||||
let feeAccount = await this.accountRepository.findByAccountSequence(FEE_ACCOUNT_SEQUENCE);
|
||||
if (feeAccount) {
|
||||
feeAccount.deposit(feeAmount, transferNo);
|
||||
|
||||
await tx.tradingAccount.update({
|
||||
where: { accountSequence: FEE_ACCOUNT_SEQUENCE },
|
||||
data: {
|
||||
cashBalance: feeAccount.cashBalance.value,
|
||||
},
|
||||
});
|
||||
|
||||
for (const txn of feeAccount.pendingTransactions) {
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: FEE_ACCOUNT_SEQUENCE,
|
||||
type: txn.type,
|
||||
assetType: txn.assetType,
|
||||
amount: txn.amount.value,
|
||||
balanceBefore: txn.balanceBefore.value,
|
||||
balanceAfter: txn.balanceAfter.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER_FEE',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: fromAccountSequence,
|
||||
memo: 'P2P转账手续费收入',
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Fee account ${FEE_ACCOUNT_SEQUENCE} not found, fee not deposited`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新转账记录为完成
|
||||
await tx.p2pTransfer.update({
|
||||
where: { transferNo },
|
||||
data: {
|
||||
|
|
@ -220,11 +333,14 @@ export class P2pTransferService {
|
|||
});
|
||||
});
|
||||
|
||||
this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`);
|
||||
this.logger.log(
|
||||
`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}, fee=${feeAmount.toFixed(8)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
transferNo,
|
||||
amount,
|
||||
fee: feeAmount.toFixed(8),
|
||||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
status: 'COMPLETED',
|
||||
|
|
@ -285,6 +401,7 @@ export class P2pTransferService {
|
|||
toAccountSequence: record.toAccountSequence,
|
||||
toPhone: record.toPhone,
|
||||
amount: record.amount.toString(),
|
||||
fee: record.fee.toString(),
|
||||
memo: record.memo,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining } from '@/features/configs/hooks/use-configs';
|
||||
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining, useP2pTransferFee, useSetP2pTransferFee } from '@/features/configs/hooks/use-configs';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -31,8 +31,20 @@ export default function ConfigsPage() {
|
|||
const activateMining = useActivateMining();
|
||||
const deactivateMining = useDeactivateMining();
|
||||
|
||||
const { data: feeConfig, isLoading: feeLoading } = useP2pTransferFee();
|
||||
const setP2pTransferFee = useSetP2pTransferFee();
|
||||
|
||||
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [feeValue, setFeeValue] = useState('');
|
||||
const [minAmountValue, setMinAmountValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (feeConfig) {
|
||||
setFeeValue(feeConfig.fee);
|
||||
setMinAmountValue(feeConfig.minTransferAmount);
|
||||
}
|
||||
}, [feeConfig]);
|
||||
|
||||
const handleEdit = (config: SystemConfig) => {
|
||||
setEditingConfig(config);
|
||||
|
|
@ -206,6 +218,61 @@ export default function ConfigsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* P2P划转手续费设置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">P2P划转手续费设置</CardTitle>
|
||||
<CardDescription>配置用户间划转(P2P转账)的手续费和最小划转金额</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feeLoading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>划转手续费 (积分值)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={feeValue}
|
||||
onChange={(e) => setFeeValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最小划转金额 (积分值)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={minAmountValue}
|
||||
onChange={(e) => setMinAmountValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{parseFloat(minAmountValue) <= parseFloat(feeValue) && feeValue !== '' && minAmountValue !== '' && (
|
||||
<p className="text-sm text-red-500">最小划转金额必须大于手续费</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setP2pTransferFee.mutate({ fee: feeValue, minTransferAmount: minAmountValue })}
|
||||
disabled={
|
||||
setP2pTransferFee.isPending ||
|
||||
parseFloat(minAmountValue) <= parseFloat(feeValue) ||
|
||||
isNaN(parseFloat(feeValue)) ||
|
||||
isNaN(parseFloat(minAmountValue))
|
||||
}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{setP2pTransferFee.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
|
|
|
|||
|
|
@ -54,4 +54,13 @@ export const configsApi = {
|
|||
const response = await apiClient.post('/configs/mining/deactivate');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getP2pTransferFee: async (): Promise<{ fee: string; minTransferAmount: string }> => {
|
||||
const response = await apiClient.get('/configs/p2p-transfer-fee');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
setP2pTransferFee: async (fee: string, minTransferAmount: string): Promise<void> => {
|
||||
await apiClient.post('/configs/p2p-transfer-fee', { fee, minTransferAmount });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,3 +94,28 @@ export function useDeactivateMining() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useP2pTransferFee() {
|
||||
return useQuery({
|
||||
queryKey: ['configs', 'p2p-transfer-fee'],
|
||||
queryFn: () => configsApi.getP2pTransferFee(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetP2pTransferFee() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ fee, minTransferAmount }: { fee: string; minTransferAmount: string }) =>
|
||||
configsApi.setP2pTransferFee(fee, minTransferAmount),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['configs', 'p2p-transfer-fee'] });
|
||||
toast({ title: 'P2P划转手续费配置已更新', variant: 'success' as any });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.message || '更新失败';
|
||||
toast({ title: message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class ApiEndpoints {
|
|||
|
||||
// P2P Transfer endpoints (用户间转账)
|
||||
static const String p2pTransfer = '/api/v2/trading/p2p/transfer';
|
||||
static const String p2pTransferFeeConfig = '/api/v2/trading/p2p/transfer-fee-config';
|
||||
static String p2pTransferHistory(String accountSequence) =>
|
||||
'/api/v2/trading/p2p/transfers/$accountSequence';
|
||||
static const String lookupAccount = '/api/v2/auth/user/lookup';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../../models/market_overview_model.dart';
|
|||
import '../../models/asset_display_model.dart';
|
||||
import '../../models/kline_model.dart';
|
||||
import '../../models/p2p_transfer_model.dart';
|
||||
import '../../models/p2p_transfer_fee_config_model.dart';
|
||||
import '../../models/c2c_order_model.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
|
|
@ -80,6 +81,9 @@ abstract class TradingRemoteDataSource {
|
|||
/// 获取P2P转账历史
|
||||
Future<List<P2pTransferModel>> getP2pTransferHistory(String accountSequence);
|
||||
|
||||
/// 获取P2P转账手续费配置
|
||||
Future<P2pTransferFeeConfigModel> getP2pTransferFeeConfig();
|
||||
|
||||
// ============ C2C交易接口 ============
|
||||
|
||||
/// 获取C2C订单列表(市场广告)
|
||||
|
|
@ -404,6 +408,17 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<P2pTransferFeeConfigModel> getP2pTransferFeeConfig() async {
|
||||
try {
|
||||
final response = await client.get(ApiEndpoints.p2pTransferFeeConfig);
|
||||
return P2pTransferFeeConfigModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
// 失败时返回默认值
|
||||
return P2pTransferFeeConfigModel(fee: '5', minTransferAmount: '6');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ C2C交易实现 ============
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
class P2pTransferFeeConfigModel {
|
||||
final String fee;
|
||||
final String minTransferAmount;
|
||||
|
||||
P2pTransferFeeConfigModel({
|
||||
required this.fee,
|
||||
required this.minTransferAmount,
|
||||
});
|
||||
|
||||
factory P2pTransferFeeConfigModel.fromJson(Map<String, dynamic> json) {
|
||||
return P2pTransferFeeConfigModel(
|
||||
fee: json['fee']?.toString() ?? '5',
|
||||
minTransferAmount: json['minTransferAmount']?.toString() ?? '6',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,8 +45,12 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
final accountSequence = user.accountSequence ?? '';
|
||||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||
final transferState = ref.watch(transferNotifierProvider);
|
||||
final feeConfigAsync = ref.watch(p2pTransferFeeConfigProvider);
|
||||
|
||||
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
|
||||
final feeConfig = feeConfigAsync.valueOrNull;
|
||||
final feeAmount = double.tryParse(feeConfig?.fee ?? '5') ?? 5;
|
||||
final minTransferAmount = double.tryParse(feeConfig?.minTransferAmount ?? '6') ?? 6;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
|
|
@ -92,7 +96,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
// 转账金额
|
||||
_buildAmountSection(availableCash),
|
||||
_buildAmountSection(availableCash, feeAmount, minTransferAmount),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -102,7 +106,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 32),
|
||||
|
||||
// 发送按钮
|
||||
_buildSendButton(transferState, availableCash),
|
||||
_buildSendButton(transferState, availableCash, feeAmount, minTransferAmount),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -275,7 +279,9 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountSection(String availableCash) {
|
||||
Widget _buildAmountSection(String availableCash, double feeAmount, double minTransferAmount) {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
|
@ -314,7 +320,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入转账数量',
|
||||
hintText: '最小 ${minTransferAmount.toStringAsFixed(0)} 积分值',
|
||||
hintStyle: const TextStyle(color: _grayText),
|
||||
filled: true,
|
||||
fillColor: _bgGray,
|
||||
|
|
@ -328,7 +334,12 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
_amountController.text = availableCash;
|
||||
final available = double.tryParse(availableCash) ?? 0;
|
||||
final maxAmount = available - feeAmount;
|
||||
if (maxAmount > 0) {
|
||||
_amountController.text = maxAmount.toStringAsFixed(8);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text(
|
||||
'全部',
|
||||
|
|
@ -343,6 +354,81 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
// 手续费信息
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _orange.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'划转手续费:',
|
||||
style: TextStyle(fontSize: 13, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
'${feeAmount.toStringAsFixed(feeAmount == feeAmount.roundToDouble() ? 0 : 2)} 积分值',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: _darkText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'最小划转金额:',
|
||||
style: TextStyle(fontSize: 13, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
'${minTransferAmount.toStringAsFixed(minTransferAmount == minTransferAmount.roundToDouble() ? 0 : 2)} 积分值',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: _darkText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (amount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Divider(),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'实际扣除:',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: _darkText,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(amount + feeAmount).toStringAsFixed(2)} 积分值',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: _orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -392,10 +478,13 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(TransferState transferState, String availableCash) {
|
||||
Widget _buildSendButton(TransferState transferState, String availableCash, double feeAmount, double minTransferAmount) {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
final available = double.tryParse(availableCash) ?? 0;
|
||||
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
|
||||
final totalRequired = amount + feeAmount;
|
||||
final isValid = _isRecipientVerified &&
|
||||
amount >= minTransferAmount &&
|
||||
totalRequired <= available;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -404,7 +493,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: isValid && !transferState.isLoading
|
||||
? _handleTransfer
|
||||
? () => _handleTransfer(feeAmount)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _orange,
|
||||
|
|
@ -463,7 +552,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'1. 转账前请确认收款方账号正确\n2. 积分值转账不可撤销,请谨慎操作\n3. 转账后将从您的可用积分值中扣除\n4. 积分值是通过卖出积分股获得的,如需转账请先卖出积分股',
|
||||
'1. 转账前请确认收款方账号正确\n2. 积分值转账不可撤销,请谨慎操作\n3. 转账后将从您的可用积分值中扣除转账金额及手续费\n4. 积分值是通过卖出积分股获得的,如需转账请先卖出积分股',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
|
|
@ -566,13 +655,18 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
return '${phone.substring(0, 3)}****${phone.substring(7)}';
|
||||
}
|
||||
|
||||
Future<void> _handleTransfer() async {
|
||||
Future<void> _handleTransfer(double feeAmount) async {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认转账'),
|
||||
content: Text(
|
||||
'确定要向 $_recipientNickname 发送 ${_amountController.text} 积分值吗?\n\n此操作不可撤销。',
|
||||
'确定要向 $_recipientNickname 发送 ${_amountController.text} 积分值吗?\n'
|
||||
'手续费: ${feeAmount.toStringAsFixed(feeAmount == feeAmount.roundToDouble() ? 0 : 2)} 积分值\n'
|
||||
'实际扣除: ${(amount + feeAmount).toStringAsFixed(2)} 积分值\n\n'
|
||||
'此操作不可撤销。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/datasources/remote/trading_remote_datasource.dart';
|
||||
import '../../data/models/p2p_transfer_model.dart';
|
||||
import '../../data/models/p2p_transfer_fee_config_model.dart';
|
||||
import '../../core/di/injection.dart';
|
||||
|
||||
/// P2P转账状态
|
||||
|
|
@ -101,6 +102,14 @@ final transferNotifierProvider =
|
|||
(ref) => TransferNotifier(getIt<TradingRemoteDataSource>()),
|
||||
);
|
||||
|
||||
/// P2P转账手续费配置
|
||||
final p2pTransferFeeConfigProvider = FutureProvider<P2pTransferFeeConfigModel>(
|
||||
(ref) async {
|
||||
final dataSource = getIt<TradingRemoteDataSource>();
|
||||
return dataSource.getP2pTransferFeeConfig();
|
||||
},
|
||||
);
|
||||
|
||||
/// P2P转账历史记录
|
||||
final p2pTransferHistoryProvider =
|
||||
FutureProvider.family<List<P2pTransferModel>, String>(
|
||||
|
|
|
|||
Loading…
Reference in New Issue