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:
hailin 2026-01-30 06:44:19 -08:00
parent 817b7d3a9f
commit ca4e5393be
13 changed files with 462 additions and 39 deletions

View File

@ -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:

View File

@ -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' })

View File

@ -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")

View File

@ -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(

View File

@ -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,

View File

@ -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">

View File

@ -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 });
},
};

View File

@ -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' });
},
});
}

View File

@ -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';

View File

@ -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

View File

@ -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',
);
}
}

View File

@ -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(

View File

@ -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>(