feat: 实现P2P转账功能及前端资产页面优化
- trading-service: 添加P2pTransfer模型和P2P转账API - auth-service: 添加用户手机号查询接口用于转账验证 - frontend: 修复资产页面冻结份额显示和转账页面余额字段 - frontend: 添加P2P转账记录页面 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
06dbe133c2
commit
2597d0ef46
|
|
@ -1,7 +1,9 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { UserService, UserProfileResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
|
@ -23,4 +25,21 @@ export class UserController {
|
|||
const result = await this.userService.getProfile(user.accountSequence);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
* GET /user/lookup?phone=13800138000
|
||||
*/
|
||||
@Get('lookup')
|
||||
async lookupByPhone(
|
||||
@Query('phone') phone: string,
|
||||
@CurrentUser() currentUser: { accountSequence: string },
|
||||
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
|
||||
if (!phone || phone.length !== 11) {
|
||||
throw new BadRequestException('请输入有效的11位手机号');
|
||||
}
|
||||
|
||||
const result = await this.userService.lookupByPhone(phone);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,24 @@ export class UserService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
*/
|
||||
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
|
||||
const phoneVO = Phone.create(phone);
|
||||
const user = await this.userRepository.findByPhone(phoneVO);
|
||||
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
accountSequence: user.accountSequence.value,
|
||||
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换手机号
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "p2p_transfers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"transfer_no" TEXT NOT NULL,
|
||||
"from_account_sequence" TEXT NOT NULL,
|
||||
"to_account_sequence" TEXT NOT NULL,
|
||||
"to_phone" TEXT NOT NULL,
|
||||
"to_nickname" TEXT,
|
||||
"from_phone" TEXT,
|
||||
"from_nickname" TEXT,
|
||||
"amount" DECIMAL(30,8) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"error_message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "p2p_transfers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "p2p_transfers_transfer_no_key" ON "p2p_transfers"("transfer_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_from_account_sequence_idx" ON "p2p_transfers"("from_account_sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_to_account_sequence_idx" ON "p2p_transfers"("to_account_sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_status_idx" ON "p2p_transfers"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "p2p_transfers_created_at_idx" ON "p2p_transfers"("created_at" DESC);
|
||||
|
|
@ -392,6 +392,30 @@ model TransferRecord {
|
|||
@@map("transfer_records")
|
||||
}
|
||||
|
||||
// P2P用户间转账记录
|
||||
model P2pTransfer {
|
||||
id String @id @default(uuid())
|
||||
transferNo String @unique @map("transfer_no")
|
||||
fromAccountSequence String @map("from_account_sequence")
|
||||
toAccountSequence String @map("to_account_sequence")
|
||||
toPhone String @map("to_phone") // 收款方手机号(用于显示)
|
||||
toNickname String? @map("to_nickname") // 收款方昵称
|
||||
fromPhone String? @map("from_phone") // 发送方手机号
|
||||
fromNickname String? @map("from_nickname") // 发送方昵称
|
||||
amount Decimal @db.Decimal(30, 8)
|
||||
memo String? @db.Text // 备注
|
||||
status String @default("PENDING") // PENDING, COMPLETED, FAILED
|
||||
errorMessage String? @map("error_message")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@index([fromAccountSequence])
|
||||
@@index([toAccountSequence])
|
||||
@@index([status])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("p2p_transfers")
|
||||
}
|
||||
|
||||
// ==================== Outbox ====================
|
||||
|
||||
enum OutboxStatus {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ApplicationModule } from '../application/application.module';
|
|||
import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
||||
import { TradingController } from './controllers/trading.controller';
|
||||
import { TransferController } from './controllers/transfer.controller';
|
||||
import { P2pTransferController } from './controllers/p2p-transfer.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { AdminController } from './controllers/admin.controller';
|
||||
import { PriceController } from './controllers/price.controller';
|
||||
|
|
@ -17,6 +18,7 @@ import { PriceGateway } from './gateways/price.gateway';
|
|||
controllers: [
|
||||
TradingController,
|
||||
TransferController,
|
||||
P2pTransferController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
PriceController,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||
|
||||
class P2pTransferDto {
|
||||
@IsString()
|
||||
@Length(11, 11)
|
||||
@Matches(/^\d{11}$/, { message: '请输入有效的11位手机号' })
|
||||
toPhone: string;
|
||||
|
||||
@IsString()
|
||||
amount: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
@ApiTags('P2P Transfer')
|
||||
@ApiBearerAuth()
|
||||
@Controller('p2p')
|
||||
export class P2pTransferController {
|
||||
constructor(private readonly p2pTransferService: P2pTransferService) {}
|
||||
|
||||
@Post('transfer')
|
||||
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
||||
async transfer(
|
||||
@Body() dto: P2pTransferDto,
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const accountSequence = req.user?.accountSequence;
|
||||
if (!accountSequence) {
|
||||
throw new BadRequestException('Unauthorized');
|
||||
}
|
||||
|
||||
const token = authHeader?.replace('Bearer ', '') || '';
|
||||
const result = await this.p2pTransferService.transfer(
|
||||
accountSequence,
|
||||
dto.toPhone,
|
||||
dto.amount,
|
||||
dto.memo,
|
||||
token,
|
||||
);
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get('transfers/:accountSequence')
|
||||
@ApiOperation({ summary: '获取P2P转账历史' })
|
||||
@ApiParam({ name: 'accountSequence', required: true, description: '账户序列号' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||
async getHistory(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Req() req: any,
|
||||
@Query('page') page?: number,
|
||||
@Query('pageSize') pageSize?: number,
|
||||
) {
|
||||
// 验证只能查询自己的转账历史
|
||||
const currentUser = req.user?.accountSequence;
|
||||
if (!currentUser || currentUser !== accountSequence) {
|
||||
throw new BadRequestException('Unauthorized');
|
||||
}
|
||||
|
||||
const result = await this.p2pTransferService.getTransferHistory(
|
||||
accountSequence,
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
);
|
||||
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { InfrastructureModule } from '../infrastructure/infrastructure.module';
|
|||
import { ApiModule } from '../api/api.module';
|
||||
import { OrderService } from './services/order.service';
|
||||
import { TransferService } from './services/transfer.service';
|
||||
import { P2pTransferService } from './services/p2p-transfer.service';
|
||||
import { PriceService } from './services/price.service';
|
||||
import { BurnService } from './services/burn.service';
|
||||
import { AssetService } from './services/asset.service';
|
||||
|
|
@ -27,6 +28,7 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
|||
AssetService,
|
||||
OrderService,
|
||||
TransferService,
|
||||
P2pTransferService,
|
||||
MarketMakerService,
|
||||
C2cService,
|
||||
// Schedulers
|
||||
|
|
@ -35,6 +37,6 @@ import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
|||
PriceBroadcastScheduler,
|
||||
C2cExpiryScheduler,
|
||||
],
|
||||
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||
exports: [OrderService, TransferService, P2pTransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { TradingAccountAggregate } from '../../domain/aggregates/trading-account.aggregate';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
|
||||
interface RecipientInfo {
|
||||
exists: boolean;
|
||||
accountSequence?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface P2pTransferResult {
|
||||
transferNo: string;
|
||||
amount: string;
|
||||
toPhone: string;
|
||||
toNickname?: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface P2pTransferHistoryItem {
|
||||
transferNo: string;
|
||||
fromAccountSequence: string;
|
||||
toAccountSequence: string;
|
||||
toPhone: string;
|
||||
amount: string;
|
||||
memo?: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class P2pTransferService {
|
||||
private readonly logger = new Logger(P2pTransferService.name);
|
||||
private readonly authServiceUrl: string;
|
||||
private readonly minTransferAmount: number;
|
||||
|
||||
constructor(
|
||||
private readonly accountRepository: TradingAccountRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.authServiceUrl = this.configService.get<string>('AUTH_SERVICE_URL', 'http://localhost:3020');
|
||||
this.minTransferAmount = this.configService.get<number>('MIN_P2P_TRANSFER_AMOUNT', 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找收款方
|
||||
*/
|
||||
async lookupRecipient(phone: string, token: string): Promise<RecipientInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.authServiceUrl}/api/v2/auth/user/lookup?phone=${phone}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success || !result.data) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to lookup recipient: ${error.message}`);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P转账(积分值)
|
||||
*/
|
||||
async transfer(
|
||||
fromAccountSequence: string,
|
||||
toPhone: string,
|
||||
amount: string,
|
||||
memo?: string,
|
||||
token?: string,
|
||||
): Promise<P2pTransferResult> {
|
||||
const transferAmount = new Money(amount);
|
||||
|
||||
// 验证转账金额
|
||||
if (transferAmount.value.lessThan(this.minTransferAmount)) {
|
||||
throw new BadRequestException(`最小转账金额为 ${this.minTransferAmount}`);
|
||||
}
|
||||
|
||||
// 查找收款方
|
||||
const recipient = await this.lookupRecipient(toPhone, token || '');
|
||||
if (!recipient.exists || !recipient.accountSequence) {
|
||||
throw new NotFoundException('收款方账户不存在');
|
||||
}
|
||||
|
||||
// 不能转给自己
|
||||
if (recipient.accountSequence === fromAccountSequence) {
|
||||
throw new BadRequestException('不能转账给自己');
|
||||
}
|
||||
|
||||
// 查找发送方账户
|
||||
const fromAccount = await this.accountRepository.findByAccountSequence(fromAccountSequence);
|
||||
if (!fromAccount) {
|
||||
throw new NotFoundException('发送方账户不存在');
|
||||
}
|
||||
|
||||
// 检查余额
|
||||
if (fromAccount.availableCash.isLessThan(transferAmount)) {
|
||||
throw new BadRequestException('可用积分值不足');
|
||||
}
|
||||
|
||||
const transferNo = this.generateTransferNo();
|
||||
|
||||
// 使用事务执行转账
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. 创建转账记录
|
||||
await tx.p2pTransfer.create({
|
||||
data: {
|
||||
transferNo,
|
||||
fromAccountSequence,
|
||||
toAccountSequence: recipient.accountSequence!,
|
||||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
amount: transferAmount.value,
|
||||
memo,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 扣减发送方余额
|
||||
fromAccount.withdraw(transferAmount, transferNo);
|
||||
|
||||
// 保存发送方账户变动
|
||||
await tx.tradingAccount.update({
|
||||
where: { accountSequence: fromAccountSequence },
|
||||
data: {
|
||||
cashBalance: fromAccount.cashBalance.value,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录发送方交易流水
|
||||
for (const txn of fromAccount.pendingTransactions) {
|
||||
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,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: recipient.accountSequence,
|
||||
memo: `P2P转出给 ${toPhone}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
fromAccount.clearPendingTransactions();
|
||||
|
||||
// 3. 增加收款方余额
|
||||
let toAccount = await this.accountRepository.findByAccountSequence(recipient.accountSequence!);
|
||||
if (!toAccount) {
|
||||
toAccount = TradingAccountAggregate.create(recipient.accountSequence!);
|
||||
// 创建新账户
|
||||
await tx.tradingAccount.create({
|
||||
data: {
|
||||
accountSequence: recipient.accountSequence!,
|
||||
shareBalance: 0,
|
||||
cashBalance: 0,
|
||||
frozenShares: 0,
|
||||
frozenCash: 0,
|
||||
totalBought: 0,
|
||||
totalSold: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAccount.deposit(transferAmount, transferNo);
|
||||
|
||||
// 保存收款方账户变动
|
||||
await tx.tradingAccount.update({
|
||||
where: { accountSequence: recipient.accountSequence! },
|
||||
data: {
|
||||
cashBalance: toAccount.cashBalance.value,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录收款方交易流水
|
||||
for (const txn of toAccount.pendingTransactions) {
|
||||
await tx.tradingTransaction.create({
|
||||
data: {
|
||||
accountSequence: recipient.accountSequence!,
|
||||
type: txn.type,
|
||||
assetType: txn.assetType,
|
||||
amount: txn.amount.value,
|
||||
balanceBefore: txn.balanceBefore.value,
|
||||
balanceAfter: txn.balanceAfter.value,
|
||||
referenceId: transferNo,
|
||||
referenceType: 'P2P_TRANSFER',
|
||||
counterpartyType: 'USER',
|
||||
counterpartyAccountSeq: fromAccountSequence,
|
||||
memo: `P2P转入来自 ${fromAccountSequence}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 更新转账记录为完成
|
||||
await tx.p2pTransfer.update({
|
||||
where: { transferNo },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(`P2P transfer completed: ${transferNo}, ${fromAccountSequence} -> ${toPhone}, amount=${amount}`);
|
||||
|
||||
return {
|
||||
transferNo,
|
||||
amount,
|
||||
toPhone,
|
||||
toNickname: recipient.nickname,
|
||||
status: 'COMPLETED',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 更新转账记录为失败
|
||||
await this.prisma.p2pTransfer.update({
|
||||
where: { transferNo },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage: error.message,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
this.logger.error(`P2P transfer failed: ${transferNo}, ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取P2P转账历史
|
||||
*/
|
||||
async getTransferHistory(
|
||||
accountSequence: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
): Promise<{ data: P2pTransferHistoryItem[]; total: number }> {
|
||||
const [records, total] = await Promise.all([
|
||||
this.prisma.p2pTransfer.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromAccountSequence: accountSequence },
|
||||
{ toAccountSequence: accountSequence },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.p2pTransfer.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromAccountSequence: accountSequence },
|
||||
{ toAccountSequence: accountSequence },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = records.map((record) => ({
|
||||
transferNo: record.transferNo,
|
||||
fromAccountSequence: record.fromAccountSequence,
|
||||
toAccountSequence: record.toAccountSequence,
|
||||
toPhone: record.toPhone,
|
||||
amount: record.amount.toString(),
|
||||
memo: record.memo,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt,
|
||||
} as P2pTransferHistoryItem));
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
private generateTransferNo(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `P2P${timestamp}${random}`.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import '../../presentation/pages/c2c/c2c_order_detail_page.dart';
|
|||
import '../../presentation/pages/profile/team_page.dart';
|
||||
import '../../presentation/pages/profile/trading_records_page.dart';
|
||||
import '../../presentation/pages/trading/transfer_records_page.dart';
|
||||
import '../../presentation/pages/asset/p2p_transfer_records_page.dart';
|
||||
import '../../presentation/pages/profile/help_center_page.dart';
|
||||
import '../../presentation/pages/profile/about_page.dart';
|
||||
import '../../presentation/widgets/main_shell.dart';
|
||||
|
|
@ -167,6 +168,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
path: Routes.transferRecords,
|
||||
builder: (context, state) => const TransferRecordsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.p2pTransferRecords,
|
||||
builder: (context, state) => const P2pTransferRecordsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.helpCenter,
|
||||
builder: (context, state) => const HelpCenterPage(),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class Routes {
|
|||
static const String tradingRecords = '/trading-records';
|
||||
// 划转记录
|
||||
static const String transferRecords = '/transfer-records';
|
||||
// P2P转账记录
|
||||
static const String p2pTransferRecords = '/p2p-transfer-records';
|
||||
// 其他设置
|
||||
static const String helpCenter = '/help-center';
|
||||
static const String about = '/about';
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import '../../../core/network/price_websocket_service.dart';
|
|||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../domain/entities/asset_display.dart';
|
||||
import '../../../domain/entities/trade_order.dart';
|
||||
import '../../../data/models/trade_order_model.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/asset_providers.dart';
|
||||
import '../../providers/mining_providers.dart';
|
||||
import '../../providers/trading_providers.dart';
|
||||
import '../../widgets/shimmer_loading.dart';
|
||||
|
||||
class AssetPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -160,11 +163,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||
// 从 mining-service 获取每秒收益
|
||||
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
|
||||
// 获取订单列表,用于显示冻结状态
|
||||
final ordersAsync = ref.watch(ordersProvider);
|
||||
|
||||
// 提取数据和加载状态
|
||||
final isLoading = assetAsync.isLoading || accountSequence.isEmpty;
|
||||
final asset = assetAsync.valueOrNull;
|
||||
final shareAccount = shareAccountAsync.valueOrNull;
|
||||
final orders = ordersAsync.valueOrNull?.data ?? [];
|
||||
|
||||
// 获取每秒收益(优先使用 mining-service 的数据)
|
||||
final perSecondEarning = shareAccount?.perSecondEarning ?? '0';
|
||||
|
|
@ -205,6 +211,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
_lastAsset = null;
|
||||
ref.invalidate(accountAssetProvider(accountSequence));
|
||||
ref.invalidate(shareAccountProvider(accountSequence));
|
||||
ref.invalidate(ordersProvider);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
|
|
@ -227,7 +234,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
_buildQuickActions(context),
|
||||
const SizedBox(height: 24),
|
||||
// 资产列表 - 始终显示,数字部分闪烁,实时刷新
|
||||
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning),
|
||||
_buildAssetList(context, asset, isLoading, _currentShareBalance, perSecondEarning, orders),
|
||||
const SizedBox(height: 24),
|
||||
// 交易统计
|
||||
_buildEarningsCard(context, asset, isLoading),
|
||||
|
|
@ -480,7 +487,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning) {
|
||||
Widget _buildAssetList(BuildContext context, AssetDisplay? asset, bool isLoading, double currentShareBalance, String perSecondEarning, List<TradeOrder> orders) {
|
||||
// 使用实时积分股余额
|
||||
final shareBalance = asset != null && currentShareBalance > 0
|
||||
? currentShareBalance
|
||||
|
|
@ -490,6 +497,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
final currentPrice = double.tryParse(asset?.currentPrice ?? '0') ?? 0;
|
||||
final isDark = AppColors.isDark(context);
|
||||
|
||||
// 根据订单状态动态计算冻结原因
|
||||
final frozenShares = double.tryParse(asset?.frozenShares ?? '0') ?? 0;
|
||||
final frozenSharesSubtitle = _getFrozenSharesSubtitle(frozenShares, orders);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 积分股 - 实时刷新
|
||||
|
|
@ -528,12 +539,32 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
title: '冻结积分股',
|
||||
amount: asset?.frozenShares,
|
||||
isLoading: isLoading,
|
||||
subtitle: '交易挂单中',
|
||||
subtitle: frozenSharesSubtitle,
|
||||
onTap: frozenShares > 0 ? () => context.push(Routes.tradingRecords) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据订单状态获取冻结积分股的显示文字
|
||||
String? _getFrozenSharesSubtitle(double frozenShares, List<TradeOrder> orders) {
|
||||
if (frozenShares <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否有进行中的卖单(pending 或 partial)
|
||||
final hasPendingSellOrder = orders.any(
|
||||
(order) => order.isSell && (order.isPending || order.isPartial),
|
||||
);
|
||||
|
||||
if (hasPendingSellOrder) {
|
||||
return '交易挂单中';
|
||||
}
|
||||
|
||||
// 有冻结但没有进行中的挂单,可能是订单已成交但资产还未释放
|
||||
return '处理中';
|
||||
}
|
||||
|
||||
Widget _buildAssetItem({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
|
|
@ -542,6 +573,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
required String title,
|
||||
String? amount,
|
||||
bool isLoading = false,
|
||||
VoidCallback? onTap,
|
||||
String? valueInCny,
|
||||
String? tag,
|
||||
String? growthText,
|
||||
|
|
@ -551,20 +583,23 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
String? subtitle,
|
||||
}) {
|
||||
final isDark = AppColors.isDark(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardOf(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardOf(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark ? Colors.black.withOpacity(0.2) : Colors.black.withOpacity(0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
|
|
@ -698,6 +733,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||
Icon(Icons.chevron_right, size: 14, color: AppColors.textMutedOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,300 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../../data/models/p2p_transfer_model.dart';
|
||||
import '../../providers/transfer_providers.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
|
||||
/// P2P转账记录页面
|
||||
class P2pTransferRecordsPage extends ConsumerWidget {
|
||||
const P2pTransferRecordsPage({super.key});
|
||||
|
||||
static const Color _orange = Color(0xFFFF6B00);
|
||||
static const Color _green = Color(0xFF10B981);
|
||||
static const Color _red = Color(0xFFEF4444);
|
||||
static const Color _grayText = Color(0xFF6B7280);
|
||||
static const Color _darkText = Color(0xFF1F2937);
|
||||
static const Color _bgGray = Color(0xFFF3F4F6);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userNotifierProvider);
|
||||
final accountSequence = user.accountSequence ?? '';
|
||||
final recordsAsync = ref.watch(p2pTransferHistoryProvider(accountSequence));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: _darkText),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(p2pTransferHistoryProvider(accountSequence));
|
||||
},
|
||||
child: recordsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: _grayText),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'加载失败',
|
||||
style: TextStyle(color: _grayText),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(p2pTransferHistoryProvider(accountSequence)),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (records) {
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long,
|
||||
size: 64,
|
||||
color: _grayText.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'暂无转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: records.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildRecordCard(context, records[index], accountSequence);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(BuildContext context, P2pTransferModel record, String myAccountSequence) {
|
||||
// 判断是转出还是转入
|
||||
final isSend = record.fromAccountSequence == myAccountSequence;
|
||||
final statusColor = _getStatusColor(record.status);
|
||||
final statusText = _getStatusText(record.status);
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: (isSend ? _orange : _green).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
isSend ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 18,
|
||||
color: isSend ? _orange : _green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isSend ? '转出' : '转入',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
dateFormat.format(record.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
// 金额
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'转账金额',
|
||||
style: TextStyle(fontSize: 13, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
'${isSend ? '-' : '+'}${formatAmount(record.amount)} 积分值',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSend ? _orange : _green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 对方账号
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
isSend ? '收款方' : '付款方',
|
||||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
_maskPhone(record.toPhone),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 备注
|
||||
if (record.memo != null && record.memo!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'备注',
|
||||
style: TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
record.memo!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _darkText,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// 单号
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'转账单号',
|
||||
style: TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
record.transferNo,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _maskPhone(String phone) {
|
||||
if (phone.length != 11) return phone;
|
||||
return '${phone.substring(0, 3)}****${phone.substring(7)}';
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED':
|
||||
case 'SUCCESS':
|
||||
return _green;
|
||||
case 'PENDING':
|
||||
return _orange;
|
||||
case 'FAILED':
|
||||
return _red;
|
||||
default:
|
||||
return _grayText;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusText(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED':
|
||||
case 'SUCCESS':
|
||||
return '已完成';
|
||||
case 'PENDING':
|
||||
return '处理中';
|
||||
case 'FAILED':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/router/routes.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/asset_providers.dart';
|
||||
|
|
@ -44,7 +45,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
final assetAsync = ref.watch(accountAssetProvider(accountSequence));
|
||||
final transferState = ref.watch(transferNotifierProvider);
|
||||
|
||||
final availableShares = assetAsync.valueOrNull?.availableShares ?? '0';
|
||||
final availableCash = assetAsync.valueOrNull?.availableCash ?? '0';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bgGray,
|
||||
|
|
@ -64,6 +65,18 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.push(Routes.p2pTransferRecords),
|
||||
child: const Text(
|
||||
'转账记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
|
@ -76,7 +89,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 16),
|
||||
|
||||
// 转账金额
|
||||
_buildAmountSection(availableShares),
|
||||
_buildAmountSection(availableCash),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -86,7 +99,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
const SizedBox(height: 32),
|
||||
|
||||
// 发送按钮
|
||||
_buildSendButton(transferState, availableShares),
|
||||
_buildSendButton(transferState, availableCash),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
|
@ -248,7 +261,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountSection(String availableShares) {
|
||||
Widget _buildAmountSection(String availableCash) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
|
@ -271,7 +284,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'可用: ${formatAmount(availableShares)}',
|
||||
'可用: ${formatAmount(availableCash)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: _grayText,
|
||||
|
|
@ -301,7 +314,7 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
_amountController.text = availableShares;
|
||||
_amountController.text = availableCash;
|
||||
},
|
||||
child: const Text(
|
||||
'全部',
|
||||
|
|
@ -365,9 +378,9 @@ class _SendSharesPageState extends ConsumerState<SendSharesPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(TransferState transferState, String availableShares) {
|
||||
Widget _buildSendButton(TransferState transferState, String availableCash) {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
final available = double.tryParse(availableShares) ?? 0;
|
||||
final available = double.tryParse(availableCash) ?? 0;
|
||||
final isValid = _isRecipientVerified && amount > 0 && amount <= available;
|
||||
|
||||
return Padding(
|
||||
|
|
|
|||
Loading…
Reference in New Issue