feat(c2c): 完善C2C场外交易功能 - 收款信息与订单超时处理

## 后端更新

### Prisma Schema (0008_add_c2c_orders migration)
- 新增 C2cPaymentMethod 枚举 (ALIPAY/WECHAT/BANK)
- C2cOrder 模型新增字段:
  - 收款信息:paymentMethod, paymentAccount, paymentQrCode, paymentRealName
  - 超时配置:paymentTimeoutMinutes (默认15分钟), confirmTimeoutMinutes (默认60分钟)
  - 截止时间:paymentDeadline, confirmDeadline
- 新增索引优化超时查询

### API层
- c2c.dto.ts: 新增收款信息和超时配置字段
- c2c.controller.ts: 新增C2C控制器,支持完整的订单生命周期管理

### 业务层
- c2c.service.ts:
  - createOrder: 卖单必须提供收款信息验证
  - takeOrder: 接单时自动设置付款截止时间
  - confirmPayment: 确认付款时设置确认收款截止时间
  - processExpiredOrders/expireOrder: 处理超时订单(释放冻结资产)
- c2c-expiry.scheduler.ts: 每分钟执行超时订单检查(带分布式锁)

### 数据层
- c2c-order.repository.ts: 新增 findExpiredOrders 方法
- trading-account.repository.ts: 新增 unfreezeShares/unfreezeCash 方法

## 前端更新

### 数据模型
- c2c_order_model.dart:
  - 新增 C2cPaymentMethod 枚举
  - 新增收款信息和超时相关字段
  - 新增辅助方法:paymentMethodText, hasPaymentInfo, paymentRemainingSeconds, confirmRemainingSeconds

### API层
- trading_remote_datasource.dart: createC2cOrder/takeC2cOrder 支持收款信息参数

### 状态管理
- c2c_providers.dart: createOrder/takeOrder 方法支持收款信息参数

### UI层
- c2c_publish_page.dart:
  - 新增收款方式选择器 (支付宝/微信/银行卡)
  - 新增收款账号和收款人姓名输入框
  - 卖单发布时验证收款信息必填
  - 确认对话框显示收款信息摘要

- c2c_order_detail_page.dart:
  - 新增收款信息卡片展示(买家视角/卖家视角区分)
  - 新增倒计时进度条显示(付款/确认收款截止时间)
  - 剩余时间<5分钟时高亮警告
  - 支持复制收款账号

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 07:17:22 -08:00
parent 928d6c8df2
commit af339b19b9
16 changed files with 2313 additions and 9 deletions

View File

@ -0,0 +1,71 @@
-- CreateEnum
CREATE TYPE "C2cOrderType" AS ENUM ('BUY', 'SELL');
-- CreateEnum
CREATE TYPE "C2cOrderStatus" AS ENUM ('PENDING', 'MATCHED', 'PAID', 'COMPLETED', 'CANCELLED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "C2cPaymentMethod" AS ENUM ('ALIPAY', 'WECHAT', 'BANK');
-- CreateTable
CREATE TABLE "c2c_orders" (
"id" TEXT NOT NULL,
"order_no" TEXT NOT NULL,
"type" "C2cOrderType" NOT NULL,
"status" "C2cOrderStatus" NOT NULL DEFAULT 'PENDING',
"maker_account_sequence" TEXT NOT NULL,
"maker_user_id" TEXT,
"maker_phone" TEXT,
"maker_nickname" TEXT,
"taker_account_sequence" TEXT,
"taker_user_id" TEXT,
"taker_phone" TEXT,
"taker_nickname" TEXT,
"price" DECIMAL(30,18) NOT NULL,
"quantity" DECIMAL(30,8) NOT NULL,
"total_amount" DECIMAL(30,8) NOT NULL,
"min_amount" DECIMAL(30,8) NOT NULL DEFAULT 0,
"max_amount" DECIMAL(30,8) NOT NULL DEFAULT 0,
"payment_method" "C2cPaymentMethod",
"payment_account" TEXT,
"payment_qr_code" TEXT,
"payment_real_name" TEXT,
"remark" TEXT,
"payment_timeout_minutes" INTEGER NOT NULL DEFAULT 15,
"confirm_timeout_minutes" INTEGER NOT NULL DEFAULT 60,
"payment_deadline" TIMESTAMP(3),
"confirm_deadline" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"matched_at" TIMESTAMP(3),
"paid_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"cancelled_at" TIMESTAMP(3),
"expired_at" TIMESTAMP(3),
CONSTRAINT "c2c_orders_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "c2c_orders_order_no_key" ON "c2c_orders"("order_no");
-- CreateIndex
CREATE INDEX "c2c_orders_status_idx" ON "c2c_orders"("status");
-- CreateIndex
CREATE INDEX "c2c_orders_type_status_idx" ON "c2c_orders"("type", "status");
-- CreateIndex
CREATE INDEX "c2c_orders_maker_account_sequence_idx" ON "c2c_orders"("maker_account_sequence");
-- CreateIndex
CREATE INDEX "c2c_orders_taker_account_sequence_idx" ON "c2c_orders"("taker_account_sequence");
-- CreateIndex
CREATE INDEX "c2c_orders_created_at_idx" ON "c2c_orders"("created_at" DESC);
-- CreateIndex
CREATE INDEX "c2c_orders_payment_deadline_idx" ON "c2c_orders"("payment_deadline");
-- CreateIndex
CREATE INDEX "c2c_orders_confirm_deadline_idx" ON "c2c_orders"("confirm_deadline");

View File

@ -584,3 +584,88 @@ model MarketMakerDailyStats {
@@index([marketMakerId, date(sort: Desc)])
@@map("market_maker_daily_stats")
}
// ==================== C2C 场外交易 ====================
// C2C订单类型
enum C2cOrderType {
BUY // 买入积分股(用积分值换积分股)
SELL // 卖出积分股(用积分股换积分值)
}
// C2C订单状态
enum C2cOrderStatus {
PENDING // 待接单
MATCHED // 已匹配(等待付款)
PAID // 已付款(等待确认收款)
COMPLETED // 已完成
CANCELLED // 已取消
EXPIRED // 已过期
}
// C2C收款方式
enum C2cPaymentMethod {
ALIPAY // 支付宝
WECHAT // 微信
BANK // 银行卡
}
// C2C订单
model C2cOrder {
id String @id @default(uuid())
orderNo String @unique @map("order_no")
type C2cOrderType
status C2cOrderStatus @default(PENDING)
// 挂单方Maker
makerAccountSequence String @map("maker_account_sequence")
makerUserId String? @map("maker_user_id")
makerPhone String? @map("maker_phone")
makerNickname String? @map("maker_nickname")
// 接单方Taker
takerAccountSequence String? @map("taker_account_sequence")
takerUserId String? @map("taker_user_id")
takerPhone String? @map("taker_phone")
takerNickname String? @map("taker_nickname")
// 交易信息
price Decimal @db.Decimal(30, 18) // 单价
quantity Decimal @db.Decimal(30, 8) // 数量(积分股)
totalAmount Decimal @map("total_amount") @db.Decimal(30, 8) // 总金额(积分值)
minAmount Decimal @default(0) @map("min_amount") @db.Decimal(30, 8) // 最小交易量
maxAmount Decimal @default(0) @map("max_amount") @db.Decimal(30, 8) // 最大交易量
// 卖方收款信息(卖单必填,买单买家需提供)
paymentMethod C2cPaymentMethod? @map("payment_method") // 收款方式
paymentAccount String? @map("payment_account") // 收款账号
paymentQrCode String? @map("payment_qr_code") // 收款二维码URL
paymentRealName String? @map("payment_real_name") // 收款人实名
// 备注
remark String? @db.Text
// 订单超时配置
paymentTimeoutMinutes Int @default(15) @map("payment_timeout_minutes") // 付款超时时间(分钟)
confirmTimeoutMinutes Int @default(60) @map("confirm_timeout_minutes") // 确认收款超时时间(分钟)
paymentDeadline DateTime? @map("payment_deadline") // 付款截止时间
confirmDeadline DateTime? @map("confirm_deadline") // 确认收款截止时间
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
matchedAt DateTime? @map("matched_at")
paidAt DateTime? @map("paid_at")
completedAt DateTime? @map("completed_at")
cancelledAt DateTime? @map("cancelled_at")
expiredAt DateTime? @map("expired_at")
@@index([status])
@@index([type, status])
@@index([makerAccountSequence])
@@index([takerAccountSequence])
@@index([createdAt(sort: Desc)])
@@index([paymentDeadline])
@@index([confirmDeadline])
@@map("c2c_orders")
}

View File

@ -9,6 +9,7 @@ import { PriceController } from './controllers/price.controller';
import { BurnController } from './controllers/burn.controller';
import { AssetController } from './controllers/asset.controller';
import { MarketMakerController } from './controllers/market-maker.controller';
import { C2cController } from './controllers/c2c.controller';
import { PriceGateway } from './gateways/price.gateway';
@Module({
@ -22,6 +23,7 @@ import { PriceGateway } from './gateways/price.gateway';
BurnController,
AssetController,
MarketMakerController,
C2cController,
],
providers: [PriceGateway],
exports: [PriceGateway],

View File

@ -0,0 +1,251 @@
import {
Controller,
Get,
Post,
Param,
Query,
Body,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { C2cService } from '../../application/services/c2c.service';
import {
CreateC2cOrderDto,
TakeC2cOrderDto,
QueryC2cOrdersDto,
QueryMyC2cOrdersDto,
C2cOrderResponseDto,
C2cOrdersPageResponseDto,
} from '../dto/c2c.dto';
import { C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
@ApiTags('C2C Trading')
@ApiBearerAuth()
@Controller('c2c')
export class C2cController {
constructor(private readonly c2cService: C2cService) {}
/**
* DTO
*/
private toResponseDto(order: C2cOrderEntity): C2cOrderResponseDto {
return {
orderNo: order.orderNo,
type: order.type,
status: order.status,
makerAccountSequence: order.makerAccountSequence,
makerPhone: order.makerPhone || undefined,
makerNickname: order.makerNickname || undefined,
takerAccountSequence: order.takerAccountSequence || undefined,
takerPhone: order.takerPhone || undefined,
takerNickname: order.takerNickname || undefined,
price: order.price,
quantity: order.quantity,
totalAmount: order.totalAmount,
minAmount: order.minAmount,
maxAmount: order.maxAmount,
// 收款信息
paymentMethod: order.paymentMethod || undefined,
paymentAccount: order.paymentAccount || undefined,
paymentQrCode: order.paymentQrCode || undefined,
paymentRealName: order.paymentRealName || undefined,
// 超时信息
paymentTimeoutMinutes: order.paymentTimeoutMinutes,
confirmTimeoutMinutes: order.confirmTimeoutMinutes,
paymentDeadline: order.paymentDeadline || undefined,
confirmDeadline: order.confirmDeadline || undefined,
// 其他
remark: order.remark || undefined,
createdAt: order.createdAt,
matchedAt: order.matchedAt || undefined,
paidAt: order.paidAt || undefined,
completedAt: order.completedAt || undefined,
expiredAt: order.expiredAt || undefined,
};
}
@Get('orders')
@ApiOperation({ summary: '获取C2C市场订单列表待接单的广告' })
@ApiResponse({ status: 200, description: '订单列表' })
async getMarketOrders(
@Query() query: QueryC2cOrdersDto,
@Req() req: any,
): Promise<C2cOrdersPageResponseDto> {
const accountSequence = req.user?.accountSequence;
const result = await this.c2cService.getMarketOrders({
type: query.type,
page: query.page ?? 1,
pageSize: query.pageSize ?? 20,
excludeAccountSequence: accountSequence, // 排除自己的订单
});
return {
data: result.data.map((o) => this.toResponseDto(o)),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
}
@Get('orders/my')
@ApiOperation({ summary: '获取我的C2C订单' })
@ApiResponse({ status: 200, description: '我的订单列表' })
async getMyOrders(
@Query() query: QueryMyC2cOrdersDto,
@Req() req: any,
): Promise<C2cOrdersPageResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const result = await this.c2cService.getMyOrders(accountSequence, {
status: query.status,
page: query.page ?? 1,
pageSize: query.pageSize ?? 20,
});
return {
data: result.data.map((o) => this.toResponseDto(o)),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
}
@Post('orders')
@ApiOperation({ summary: '创建C2C订单发布广告' })
@ApiResponse({ status: 201, description: '订单创建成功' })
async createOrder(
@Body() dto: CreateC2cOrderDto,
@Req() req: any,
): Promise<C2cOrderResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const order = await this.c2cService.createOrder(
accountSequence,
dto.type,
dto.price,
dto.quantity,
{
minAmount: dto.minAmount,
maxAmount: dto.maxAmount,
// 收款信息
paymentMethod: dto.paymentMethod as any,
paymentAccount: dto.paymentAccount,
paymentQrCode: dto.paymentQrCode,
paymentRealName: dto.paymentRealName,
remark: dto.remark,
userId: req.user?.userId,
phone: req.user?.phone,
nickname: req.user?.nickname,
},
);
return this.toResponseDto(order);
}
@Get('orders/:orderNo')
@ApiOperation({ summary: '获取C2C订单详情' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '订单详情' })
async getOrderDetail(@Param('orderNo') orderNo: string): Promise<C2cOrderResponseDto> {
const order = await this.c2cService.getOrderDetail(orderNo);
return this.toResponseDto(order);
}
@Post('orders/:orderNo/take')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '接单(吃单)' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '接单成功' })
async takeOrder(
@Param('orderNo') orderNo: string,
@Body() dto: TakeC2cOrderDto,
@Req() req: any,
): Promise<C2cOrderResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const order = await this.c2cService.takeOrder(orderNo, accountSequence, {
quantity: dto.quantity,
// 收款信息接买单时由taker提供
paymentMethod: dto.paymentMethod as any,
paymentAccount: dto.paymentAccount,
paymentQrCode: dto.paymentQrCode,
paymentRealName: dto.paymentRealName,
userId: req.user?.userId,
phone: req.user?.phone,
nickname: req.user?.nickname,
});
return this.toResponseDto(order);
}
@Post('orders/:orderNo/cancel')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '取消C2C订单' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '取消成功' })
async cancelOrder(
@Param('orderNo') orderNo: string,
@Req() req: any,
): Promise<{ success: boolean }> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
await this.c2cService.cancelOrder(orderNo, accountSequence);
return { success: true };
}
@Post('orders/:orderNo/confirm-payment')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '确认付款(买方操作)' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '确认付款成功' })
async confirmPayment(
@Param('orderNo') orderNo: string,
@Req() req: any,
): Promise<C2cOrderResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const order = await this.c2cService.confirmPayment(orderNo, accountSequence);
return this.toResponseDto(order);
}
@Post('orders/:orderNo/confirm-received')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '确认收款(卖方操作)' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '确认收款成功,交易完成' })
async confirmReceived(
@Param('orderNo') orderNo: string,
@Req() req: any,
): Promise<C2cOrderResponseDto> {
const accountSequence = req.user?.accountSequence;
if (!accountSequence) {
throw new Error('Unauthorized');
}
const order = await this.c2cService.confirmReceived(orderNo, accountSequence);
return this.toResponseDto(order);
}
}

View File

@ -0,0 +1,181 @@
import { IsString, IsIn, IsOptional, IsNumberString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// ==================== C2C 订单类型和状态 ====================
export enum C2cOrderType {
BUY = 'BUY', // 买入积分股(用积分值换积分股)
SELL = 'SELL', // 卖出积分股(用积分股换积分值)
}
export enum C2cOrderStatus {
PENDING = 'PENDING', // 待接单
MATCHED = 'MATCHED', // 已匹配(等待付款)
PAID = 'PAID', // 已付款(等待确认收款)
COMPLETED = 'COMPLETED', // 已完成
CANCELLED = 'CANCELLED', // 已取消
EXPIRED = 'EXPIRED', // 已过期
}
// 收款方式
export enum C2cPaymentMethod {
ALIPAY = 'ALIPAY', // 支付宝
WECHAT = 'WECHAT', // 微信
BANK = 'BANK', // 银行卡
}
// ==================== 请求 DTO ====================
export class CreateC2cOrderDto {
@ApiProperty({ enum: ['BUY', 'SELL'], description: '订单类型' })
@IsIn(['BUY', 'SELL'])
type: 'BUY' | 'SELL';
@ApiProperty({ description: '单价' })
@IsNumberString()
price: string;
@ApiProperty({ description: '数量(积分股)' })
@IsNumberString()
quantity: string;
@ApiPropertyOptional({ description: '最小交易量' })
@IsOptional()
@IsNumberString()
minAmount?: string;
@ApiPropertyOptional({ description: '最大交易量' })
@IsOptional()
@IsNumberString()
maxAmount?: string;
// 收款信息(卖单必填)
@ApiPropertyOptional({ enum: ['ALIPAY', 'WECHAT', 'BANK'], description: '收款方式' })
@IsOptional()
@IsIn(['ALIPAY', 'WECHAT', 'BANK'])
paymentMethod?: 'ALIPAY' | 'WECHAT' | 'BANK';
@ApiPropertyOptional({ description: '收款账号' })
@IsOptional()
@IsString()
paymentAccount?: string;
@ApiPropertyOptional({ description: '收款二维码URL' })
@IsOptional()
@IsString()
paymentQrCode?: string;
@ApiPropertyOptional({ description: '收款人实名' })
@IsOptional()
@IsString()
paymentRealName?: string;
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
remark?: string;
}
export class TakeC2cOrderDto {
@ApiPropertyOptional({ description: '交易数量(可选,默认全部)' })
@IsOptional()
@IsNumberString()
quantity?: string;
// 买单被接单时,买家需要提供收款信息
@ApiPropertyOptional({ enum: ['ALIPAY', 'WECHAT', 'BANK'], description: '收款方式(接买单时需提供)' })
@IsOptional()
@IsIn(['ALIPAY', 'WECHAT', 'BANK'])
paymentMethod?: 'ALIPAY' | 'WECHAT' | 'BANK';
@ApiPropertyOptional({ description: '收款账号(接买单时需提供)' })
@IsOptional()
@IsString()
paymentAccount?: string;
@ApiPropertyOptional({ description: '收款二维码URL' })
@IsOptional()
@IsString()
paymentQrCode?: string;
@ApiPropertyOptional({ description: '收款人实名(接买单时需提供)' })
@IsOptional()
@IsString()
paymentRealName?: string;
}
export class QueryC2cOrdersDto {
@ApiPropertyOptional({ enum: ['BUY', 'SELL'], description: '订单类型' })
@IsOptional()
@IsIn(['BUY', 'SELL'])
type?: 'BUY' | 'SELL';
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
page?: number;
@ApiPropertyOptional({ description: '每页数量' })
@IsOptional()
pageSize?: number;
}
export class QueryMyC2cOrdersDto {
@ApiPropertyOptional({
enum: ['PENDING', 'MATCHED', 'PAID', 'COMPLETED', 'CANCELLED', 'EXPIRED'],
description: '订单状态',
})
@IsOptional()
@IsIn(['PENDING', 'MATCHED', 'PAID', 'COMPLETED', 'CANCELLED', 'EXPIRED'])
status?: string;
@ApiPropertyOptional({ description: '页码' })
@IsOptional()
page?: number;
@ApiPropertyOptional({ description: '每页数量' })
@IsOptional()
pageSize?: number;
}
// ==================== 响应 DTO ====================
export class C2cOrderResponseDto {
orderNo: string;
type: string;
status: string;
makerAccountSequence: string;
makerPhone?: string;
makerNickname?: string;
takerAccountSequence?: string;
takerPhone?: string;
takerNickname?: string;
price: string;
quantity: string;
totalAmount: string;
minAmount: string;
maxAmount: string;
// 收款信息
paymentMethod?: string;
paymentAccount?: string;
paymentQrCode?: string;
paymentRealName?: string;
// 超时信息
paymentTimeoutMinutes: number;
confirmTimeoutMinutes: number;
paymentDeadline?: Date;
confirmDeadline?: Date;
// 其他
remark?: string;
createdAt: Date;
matchedAt?: Date;
paidAt?: Date;
completedAt?: Date;
expiredAt?: Date;
}
export class C2cOrdersPageResponseDto {
data: C2cOrderResponseDto[];
total: number;
page: number;
pageSize: number;
}

View File

@ -8,9 +8,11 @@ import { PriceService } from './services/price.service';
import { BurnService } from './services/burn.service';
import { AssetService } from './services/asset.service';
import { MarketMakerService } from './services/market-maker.service';
import { C2cService } from './services/c2c.service';
import { OutboxScheduler } from './schedulers/outbox.scheduler';
import { BurnScheduler } from './schedulers/burn.scheduler';
import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler';
import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
@Module({
imports: [
@ -26,11 +28,13 @@ import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler'
OrderService,
TransferService,
MarketMakerService,
C2cService,
// Schedulers
OutboxScheduler,
BurnScheduler,
PriceBroadcastScheduler,
C2cExpiryScheduler,
],
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService],
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
})
export class ApplicationModule {}

View File

@ -0,0 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { C2cService } from '../services/c2c.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
/**
* C2C订单超时处理定时任务
*
*
* 1. MATCHED状态但付款超时15->
* 2. PAID状态但确认收款超时60->
*/
@Injectable()
export class C2cExpiryScheduler {
private readonly logger = new Logger(C2cExpiryScheduler.name);
constructor(
private readonly c2cService: C2cService,
private readonly redis: RedisService,
) {}
/**
*
*/
@Cron(CronExpression.EVERY_MINUTE)
async processExpiredOrders(): Promise<void> {
// 使用分布式锁防止多实例并发执行
const lockKey = 'c2c:expiry:scheduler:lock';
const lockValue = await this.redis.acquireLock(lockKey, 60);
if (!lockValue) {
return; // 其他实例正在处理
}
try {
const processedCount = await this.c2cService.processExpiredOrders();
if (processedCount > 0) {
this.logger.log(`C2C超时处理完成: 处理了 ${processedCount} 个订单`);
}
} catch (error) {
this.logger.error('C2C超时处理失败', error);
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
}

View File

@ -0,0 +1,653 @@
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { C2cOrderRepository, C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { Money } from '../../domain/value-objects/money.vo';
import Decimal from 'decimal.js';
// C2C 订单类型常量
const C2C_ORDER_TYPE = {
BUY: 'BUY',
SELL: 'SELL',
} as const;
// C2C 订单状态常量
const C2C_ORDER_STATUS = {
PENDING: 'PENDING',
MATCHED: 'MATCHED',
PAID: 'PAID',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
EXPIRED: 'EXPIRED',
} as const;
// C2C 收款方式常量
const C2C_PAYMENT_METHOD = {
ALIPAY: 'ALIPAY',
WECHAT: 'WECHAT',
BANK: 'BANK',
} as const;
type C2cPaymentMethod = typeof C2C_PAYMENT_METHOD[keyof typeof C2C_PAYMENT_METHOD];
// 默认超时时间配置(分钟)
const DEFAULT_PAYMENT_TIMEOUT_MINUTES = 15;
const DEFAULT_CONFIRM_TIMEOUT_MINUTES = 60;
/**
* C2C
*
*
* 1. 广createOrder-> 状态: PENDING
* - BUY 类型: 冻结用户的积分值cashBalance
* - SELL 类型: 冻结用户的积分股shareBalance
* 2. takeOrder-> 状态: MATCHED
* -
* 3. confirmPayment-> 状态: PAID
* 4. confirmReceived-> 状态: COMPLETED
* -
*/
@Injectable()
export class C2cService {
private readonly logger = new Logger(C2cService.name);
constructor(
private readonly c2cOrderRepository: C2cOrderRepository,
private readonly tradingAccountRepository: TradingAccountRepository,
private readonly redis: RedisService,
private readonly prisma: PrismaService,
) {}
/**
* C2C订单号
*/
private generateOrderNo(): string {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '');
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `C2C${dateStr}${timeStr}${random}`;
}
/**
* C2C订单广
*/
async createOrder(
accountSequence: string,
type: 'BUY' | 'SELL',
price: string,
quantity: string,
options?: {
minAmount?: string;
maxAmount?: string;
// 收款信息(卖单必填)
paymentMethod?: C2cPaymentMethod;
paymentAccount?: string;
paymentQrCode?: string;
paymentRealName?: string;
remark?: string;
userId?: string;
phone?: string;
nickname?: string;
},
): Promise<C2cOrderEntity> {
const lockKey = `c2c:create:${accountSequence}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
throw new BadRequestException('操作太频繁,请稍后再试');
}
try {
const priceDecimal = new Decimal(price);
const quantityDecimal = new Decimal(quantity);
if (priceDecimal.lte(0)) {
throw new BadRequestException('价格必须大于0');
}
if (quantityDecimal.lte(0)) {
throw new BadRequestException('数量必须大于0');
}
// 卖单必须提供收款信息
if (type === C2C_ORDER_TYPE.SELL) {
if (!options?.paymentMethod) {
throw new BadRequestException('卖单必须提供收款方式');
}
if (!options?.paymentAccount) {
throw new BadRequestException('卖单必须提供收款账号');
}
if (!options?.paymentRealName) {
throw new BadRequestException('卖单必须提供收款人实名');
}
}
// 计算总金额
const totalAmount = priceDecimal.mul(quantityDecimal);
// 获取用户交易账户
const account = await this.tradingAccountRepository.findByAccountSequence(accountSequence);
if (!account) {
throw new NotFoundException('交易账户不存在');
}
// 检查余额并冻结资产
if (type === C2C_ORDER_TYPE.BUY) {
// 买入订单:需要冻结积分值(现金)
const totalAmountMoney = new Money(totalAmount);
if (account.availableCash.isLessThan(totalAmountMoney)) {
throw new BadRequestException(`积分值余额不足,需要 ${totalAmount.toString()},可用 ${account.availableCash.toString()}`);
}
// 冻结积分值
await this.tradingAccountRepository.freezeCash(accountSequence, totalAmount);
} else {
// 卖出订单:需要冻结积分股
const quantityMoney = new Money(quantityDecimal);
if (account.availableShares.isLessThan(quantityMoney)) {
throw new BadRequestException(`积分股余额不足,需要 ${quantity},可用 ${account.availableShares.toString()}`);
}
// 冻结积分股
await this.tradingAccountRepository.freezeShares(accountSequence, quantityDecimal);
}
// 创建订单
const orderNo = this.generateOrderNo();
const order = await this.c2cOrderRepository.create({
orderNo,
type: type as any,
makerAccountSequence: accountSequence,
makerUserId: options?.userId,
makerPhone: options?.phone,
makerNickname: options?.nickname,
price: priceDecimal.toString(),
quantity: quantityDecimal.toString(),
totalAmount: totalAmount.toString(),
minAmount: options?.minAmount,
maxAmount: options?.maxAmount,
// 收款信息(卖单时填写)
paymentMethod: options?.paymentMethod,
paymentAccount: options?.paymentAccount,
paymentQrCode: options?.paymentQrCode,
paymentRealName: options?.paymentRealName,
remark: options?.remark,
});
this.logger.log(`C2C订单创建成功: ${orderNo}, 类型: ${type}, 数量: ${quantity}, 价格: ${price}`);
return order;
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
/**
*
*/
async takeOrder(
orderNo: string,
takerAccountSequence: string,
options?: {
quantity?: string;
// 收款信息接买单时由taker提供因为taker是卖方
paymentMethod?: C2cPaymentMethod;
paymentAccount?: string;
paymentQrCode?: string;
paymentRealName?: string;
userId?: string;
phone?: string;
nickname?: string;
},
): Promise<C2cOrderEntity> {
const lockKey = `c2c:take:${orderNo}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
throw new BadRequestException('操作太频繁,请稍后再试');
}
try {
// 查询订单
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
if (order.status !== C2C_ORDER_STATUS.PENDING) {
throw new BadRequestException('订单状态不正确,无法接单');
}
if (order.makerAccountSequence === takerAccountSequence) {
throw new BadRequestException('不能接自己的订单');
}
// 如果是买单,接单方(卖方)必须提供收款信息
if (order.type === C2C_ORDER_TYPE.BUY) {
if (!options?.paymentMethod) {
throw new BadRequestException('接单必须提供收款方式');
}
if (!options?.paymentAccount) {
throw new BadRequestException('接单必须提供收款账号');
}
if (!options?.paymentRealName) {
throw new BadRequestException('接单必须提供收款人实名');
}
}
// 获取接单方账户
const takerAccount = await this.tradingAccountRepository.findByAccountSequence(takerAccountSequence);
if (!takerAccount) {
throw new NotFoundException('交易账户不存在');
}
const quantityDecimal = new Decimal(order.quantity);
const totalAmountDecimal = new Decimal(order.totalAmount);
// 接单方需要冻结对应资产
if (order.type === C2C_ORDER_TYPE.BUY) {
// 挂单方要买入积分股,接单方需要有积分股来卖出
const quantityMoney = new Money(quantityDecimal);
if (takerAccount.availableShares.isLessThan(quantityMoney)) {
throw new BadRequestException(`积分股余额不足,需要 ${order.quantity},可用 ${takerAccount.availableShares.toString()}`);
}
// 冻结接单方的积分股
await this.tradingAccountRepository.freezeShares(takerAccountSequence, quantityDecimal);
} else {
// 挂单方要卖出积分股,接单方需要有积分值来买入
const totalAmountMoney = new Money(totalAmountDecimal);
if (takerAccount.availableCash.isLessThan(totalAmountMoney)) {
throw new BadRequestException(`积分值余额不足,需要 ${order.totalAmount},可用 ${takerAccount.availableCash.toString()}`);
}
// 冻结接单方的积分值
await this.tradingAccountRepository.freezeCash(takerAccountSequence, totalAmountDecimal);
}
// 计算超时时间
const now = new Date();
const paymentDeadline = new Date(now.getTime() + DEFAULT_PAYMENT_TIMEOUT_MINUTES * 60 * 1000);
// 更新订单状态为已匹配
const updateData: any = {
takerAccountSequence,
takerUserId: options?.userId,
takerPhone: options?.phone,
takerNickname: options?.nickname,
matchedAt: now,
paymentDeadline,
};
// 如果是买单,接单方提供收款信息
if (order.type === C2C_ORDER_TYPE.BUY) {
updateData.paymentMethod = options?.paymentMethod;
updateData.paymentAccount = options?.paymentAccount;
updateData.paymentQrCode = options?.paymentQrCode;
updateData.paymentRealName = options?.paymentRealName;
}
const updatedOrder = await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.MATCHED as any, updateData);
this.logger.log(`C2C订单接单成功: ${orderNo}, 接单方: ${takerAccountSequence}`);
return updatedOrder!;
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
/**
*
*/
async cancelOrder(orderNo: string, accountSequence: string): Promise<void> {
const lockKey = `c2c:cancel:${orderNo}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
throw new BadRequestException('操作太频繁,请稍后再试');
}
try {
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
if (order.makerAccountSequence !== accountSequence) {
throw new ForbiddenException('无权取消此订单');
}
if (order.status !== C2C_ORDER_STATUS.PENDING) {
throw new BadRequestException('只能取消待接单的订单');
}
const quantityDecimal = new Decimal(order.quantity);
const totalAmountDecimal = new Decimal(order.totalAmount);
// 解冻挂单方的资产
if (order.type === C2C_ORDER_TYPE.BUY) {
await this.tradingAccountRepository.unfreezeCash(accountSequence, totalAmountDecimal);
} else {
await this.tradingAccountRepository.unfreezeShares(accountSequence, quantityDecimal);
}
// 更新订单状态
await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.CANCELLED as any, {
cancelledAt: new Date(),
});
this.logger.log(`C2C订单取消成功: ${orderNo}`);
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
/**
*
* - BUY订单maker
* - SELL订单taker
*/
async confirmPayment(orderNo: string, accountSequence: string): Promise<C2cOrderEntity> {
const lockKey = `c2c:payment:${orderNo}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
throw new BadRequestException('操作太频繁,请稍后再试');
}
try {
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
if (order.status !== C2C_ORDER_STATUS.MATCHED) {
throw new BadRequestException('订单状态不正确');
}
// 确定买方身份
const buyerAccountSequence = order.type === C2C_ORDER_TYPE.BUY
? order.makerAccountSequence
: order.takerAccountSequence;
if (accountSequence !== buyerAccountSequence) {
throw new ForbiddenException('只有买方可以确认付款');
}
// 计算确认收款超时时间
const now = new Date();
const confirmDeadline = new Date(now.getTime() + DEFAULT_CONFIRM_TIMEOUT_MINUTES * 60 * 1000);
const updatedOrder = await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.PAID as any, {
paidAt: now,
confirmDeadline,
});
this.logger.log(`C2C订单确认付款: ${orderNo}`);
return updatedOrder!;
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
/**
*
* - BUY订单taker
* - SELL订单maker
*/
async confirmReceived(orderNo: string, accountSequence: string): Promise<C2cOrderEntity> {
const lockKey = `c2c:received:${orderNo}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
throw new BadRequestException('操作太频繁,请稍后再试');
}
try {
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
if (order.status !== C2C_ORDER_STATUS.PAID) {
throw new BadRequestException('订单状态不正确,买方尚未确认付款');
}
// 确定卖方身份
const sellerAccountSequence = order.type === C2C_ORDER_TYPE.BUY
? order.takerAccountSequence
: order.makerAccountSequence;
if (accountSequence !== sellerAccountSequence) {
throw new ForbiddenException('只有卖方可以确认收款');
}
// 执行转账(在事务中完成)
await this.executeTransfer(order);
const updatedOrder = await this.c2cOrderRepository.updateStatus(orderNo, C2C_ORDER_STATUS.COMPLETED as any, {
completedAt: new Date(),
});
this.logger.log(`C2C订单完成: ${orderNo}`);
return updatedOrder!;
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
/**
* C2C交易转账
*/
private async executeTransfer(order: C2cOrderEntity): Promise<void> {
const quantityDecimal = new Decimal(order.quantity);
const totalAmountDecimal = new Decimal(order.totalAmount);
// 确定买卖双方
let buyerAccountSequence: string;
let sellerAccountSequence: string;
if (order.type === C2C_ORDER_TYPE.BUY) {
// BUY订单maker是买方taker是卖方
buyerAccountSequence = order.makerAccountSequence;
sellerAccountSequence = order.takerAccountSequence!;
} else {
// SELL订单taker是买方maker是卖方
buyerAccountSequence = order.takerAccountSequence!;
sellerAccountSequence = order.makerAccountSequence;
}
// 使用事务执行转账
await this.prisma.$transaction(async (tx) => {
// 1. 解冻买方的积分值并扣除
await tx.tradingAccount.update({
where: { accountSequence: buyerAccountSequence },
data: {
frozenCash: { decrement: totalAmountDecimal.toNumber() },
cashBalance: { decrement: totalAmountDecimal.toNumber() },
},
});
// 2. 解冻卖方的积分股并扣除
await tx.tradingAccount.update({
where: { accountSequence: sellerAccountSequence },
data: {
frozenShares: { decrement: quantityDecimal.toNumber() },
shareBalance: { decrement: quantityDecimal.toNumber() },
totalSold: { increment: quantityDecimal.toNumber() },
},
});
// 3. 买方获得积分股
await tx.tradingAccount.update({
where: { accountSequence: buyerAccountSequence },
data: {
shareBalance: { increment: quantityDecimal.toNumber() },
totalBought: { increment: quantityDecimal.toNumber() },
},
});
// 4. 卖方获得积分值
await tx.tradingAccount.update({
where: { accountSequence: sellerAccountSequence },
data: {
cashBalance: { increment: totalAmountDecimal.toNumber() },
},
});
// 5. 记录交易流水(买方)
const buyerAccount = await tx.tradingAccount.findUnique({
where: { accountSequence: buyerAccountSequence },
});
await tx.tradingTransaction.create({
data: {
accountSequence: buyerAccountSequence,
type: 'C2C_BUY',
assetType: 'SHARE',
amount: quantityDecimal.toNumber(),
balanceBefore: new Decimal(buyerAccount!.shareBalance).minus(quantityDecimal).toNumber(),
balanceAfter: buyerAccount!.shareBalance,
referenceId: order.orderNo,
referenceType: 'C2C_ORDER',
counterpartyType: 'USER',
counterpartyAccountSeq: sellerAccountSequence,
memo: `C2C买入 ${order.quantity} 积分股,单价 ${order.price}`,
},
});
// 6. 记录交易流水(卖方)
const sellerAccount = await tx.tradingAccount.findUnique({
where: { accountSequence: sellerAccountSequence },
});
await tx.tradingTransaction.create({
data: {
accountSequence: sellerAccountSequence,
type: 'C2C_SELL',
assetType: 'CASH',
amount: totalAmountDecimal.toNumber(),
balanceBefore: new Decimal(sellerAccount!.cashBalance).minus(totalAmountDecimal).toNumber(),
balanceAfter: sellerAccount!.cashBalance,
referenceId: order.orderNo,
referenceType: 'C2C_ORDER',
counterpartyType: 'USER',
counterpartyAccountSeq: buyerAccountSequence,
memo: `C2C卖出 ${order.quantity} 积分股,单价 ${order.price}`,
},
});
});
this.logger.log(`C2C交易转账完成: ${order.orderNo}, 买方: ${buyerAccountSequence}, 卖方: ${sellerAccountSequence}`);
}
/**
* 广
*/
async getMarketOrders(options: {
type?: 'BUY' | 'SELL';
page: number;
pageSize: number;
excludeAccountSequence?: string;
}): Promise<{ data: C2cOrderEntity[]; total: number; page: number; pageSize: number }> {
const result = await this.c2cOrderRepository.findMarketOrders({
type: options.type as any,
page: options.page,
pageSize: options.pageSize,
excludeAccountSequence: options.excludeAccountSequence,
});
return {
...result,
page: options.page,
pageSize: options.pageSize,
};
}
/**
*
*/
async getMyOrders(
accountSequence: string,
options: {
status?: string;
page: number;
pageSize: number;
},
): Promise<{ data: C2cOrderEntity[]; total: number; page: number; pageSize: number }> {
const result = await this.c2cOrderRepository.findByAccountSequence(accountSequence, {
status: options.status as any,
page: options.page,
pageSize: options.pageSize,
});
return {
...result,
page: options.page,
pageSize: options.pageSize,
};
}
/**
*
*/
async getOrderDetail(orderNo: string): Promise<C2cOrderEntity> {
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
if (!order) {
throw new NotFoundException('订单不存在');
}
return order;
}
/**
*
*/
async processExpiredOrders(): Promise<number> {
const expiredOrders = await this.c2cOrderRepository.findExpiredOrders();
let processedCount = 0;
for (const order of expiredOrders) {
try {
await this.expireOrder(order);
processedCount++;
} catch (error) {
this.logger.error(`处理超时订单失败: ${order.orderNo}`, error);
}
}
if (processedCount > 0) {
this.logger.log(`处理了 ${processedCount} 个超时订单`);
}
return processedCount;
}
/**
* 使
*/
private async expireOrder(order: C2cOrderEntity): Promise<void> {
const lockKey = `c2c:expire:${order.orderNo}`;
const lockValue = await this.redis.acquireLock(lockKey, 30);
if (!lockValue) {
return; // 其他进程正在处理
}
try {
// 重新获取订单,确保状态一致
const freshOrder = await this.c2cOrderRepository.findByOrderNo(order.orderNo);
if (!freshOrder || (freshOrder.status !== C2C_ORDER_STATUS.MATCHED && freshOrder.status !== C2C_ORDER_STATUS.PAID)) {
return;
}
const quantityDecimal = new Decimal(freshOrder.quantity);
const totalAmountDecimal = new Decimal(freshOrder.totalAmount);
// 解冻双方资产
if (freshOrder.type === C2C_ORDER_TYPE.BUY) {
// BUY订单maker冻结了积分值taker冻结了积分股
await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, totalAmountDecimal);
if (freshOrder.takerAccountSequence) {
await this.tradingAccountRepository.unfreezeShares(freshOrder.takerAccountSequence, quantityDecimal);
}
} else {
// SELL订单maker冻结了积分股taker冻结了积分值
await this.tradingAccountRepository.unfreezeShares(freshOrder.makerAccountSequence, quantityDecimal);
if (freshOrder.takerAccountSequence) {
await this.tradingAccountRepository.unfreezeCash(freshOrder.takerAccountSequence, totalAmountDecimal);
}
}
// 更新订单状态为过期
await this.c2cOrderRepository.updateStatus(freshOrder.orderNo, C2C_ORDER_STATUS.EXPIRED as any, {
expiredAt: new Date(),
});
this.logger.log(`C2C订单已过期: ${freshOrder.orderNo}, 原状态: ${freshOrder.status}`);
} finally {
await this.redis.releaseLock(lockKey, lockValue);
}
}
}

View File

@ -11,6 +11,7 @@ import { SharePoolRepository } from './persistence/repositories/share-pool.repos
import { CirculationPoolRepository } from './persistence/repositories/circulation-pool.repository';
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
import { ProcessedEventRepository } from './persistence/repositories/processed-event.repository';
import { C2cOrderRepository } from './persistence/repositories/c2c-order.repository';
import { RedisService } from './redis/redis.service';
import { KafkaProducerService } from './kafka/kafka-producer.service';
import { UserRegisteredConsumer } from './kafka/consumers/user-registered.consumer';
@ -51,6 +52,7 @@ import { CdcConsumerService } from './kafka/cdc-consumer.service';
CirculationPoolRepository,
PriceSnapshotRepository,
ProcessedEventRepository,
C2cOrderRepository,
KafkaProducerService,
CdcConsumerService,
{
@ -75,6 +77,7 @@ import { CdcConsumerService } from './kafka/cdc-consumer.service';
CirculationPoolRepository,
PriceSnapshotRepository,
ProcessedEventRepository,
C2cOrderRepository,
KafkaProducerService,
RedisService,
ClientsModule,

View File

@ -0,0 +1,326 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
// C2C 订单类型常量
const C2C_ORDER_TYPE = {
BUY: 'BUY',
SELL: 'SELL',
} as const;
// C2C 订单状态常量
const C2C_ORDER_STATUS = {
PENDING: 'PENDING',
MATCHED: 'MATCHED',
PAID: 'PAID',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
EXPIRED: 'EXPIRED',
} as const;
// C2C 收款方式常量
const C2C_PAYMENT_METHOD = {
ALIPAY: 'ALIPAY',
WECHAT: 'WECHAT',
BANK: 'BANK',
} as const;
type C2cOrderType = typeof C2C_ORDER_TYPE[keyof typeof C2C_ORDER_TYPE];
type C2cOrderStatus = typeof C2C_ORDER_STATUS[keyof typeof C2C_ORDER_STATUS];
type C2cPaymentMethod = typeof C2C_PAYMENT_METHOD[keyof typeof C2C_PAYMENT_METHOD];
export interface C2cOrderEntity {
id: string;
orderNo: string;
type: C2cOrderType;
status: C2cOrderStatus;
makerAccountSequence: string;
makerUserId?: string | null;
makerPhone?: string | null;
makerNickname?: string | null;
takerAccountSequence?: string | null;
takerUserId?: string | null;
takerPhone?: string | null;
takerNickname?: string | null;
price: string;
quantity: string;
totalAmount: string;
minAmount: string;
maxAmount: string;
// 收款信息
paymentMethod?: C2cPaymentMethod | null;
paymentAccount?: string | null;
paymentQrCode?: string | null;
paymentRealName?: string | null;
// 超时配置
paymentTimeoutMinutes: number;
confirmTimeoutMinutes: number;
paymentDeadline?: Date | null;
confirmDeadline?: Date | null;
// 其他
remark?: string | null;
createdAt: Date;
updatedAt: Date;
matchedAt?: Date | null;
paidAt?: Date | null;
completedAt?: Date | null;
cancelledAt?: Date | null;
expiredAt?: Date | null;
}
@Injectable()
export class C2cOrderRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* ID查询订单
*/
async findById(id: string): Promise<C2cOrderEntity | null> {
const record = await this.prisma.c2cOrder.findUnique({ where: { id } });
return record ? this.toEntity(record) : null;
}
/**
*
*/
async findByOrderNo(orderNo: string): Promise<C2cOrderEntity | null> {
const record = await this.prisma.c2cOrder.findUnique({ where: { orderNo } });
return record ? this.toEntity(record) : null;
}
/**
*
*/
async create(data: {
orderNo: string;
type: C2cOrderType;
makerAccountSequence: string;
makerUserId?: string;
makerPhone?: string;
makerNickname?: string;
price: string;
quantity: string;
totalAmount: string;
minAmount?: string;
maxAmount?: string;
// 收款信息
paymentMethod?: C2cPaymentMethod;
paymentAccount?: string;
paymentQrCode?: string;
paymentRealName?: string;
remark?: string;
}): Promise<C2cOrderEntity> {
const record = await this.prisma.c2cOrder.create({
data: {
orderNo: data.orderNo,
type: data.type as any,
status: C2C_ORDER_STATUS.PENDING as any,
makerAccountSequence: data.makerAccountSequence,
makerUserId: data.makerUserId,
makerPhone: data.makerPhone,
makerNickname: data.makerNickname,
price: data.price,
quantity: data.quantity,
totalAmount: data.totalAmount,
minAmount: data.minAmount || '0',
maxAmount: data.maxAmount || '0',
// 收款信息
paymentMethod: data.paymentMethod as any,
paymentAccount: data.paymentAccount,
paymentQrCode: data.paymentQrCode,
paymentRealName: data.paymentRealName,
remark: data.remark,
},
});
return this.toEntity(record);
}
/**
*
*/
async updateStatus(
orderNo: string,
status: C2cOrderStatus,
additionalData?: Partial<{
takerAccountSequence: string;
takerUserId: string;
takerPhone: string;
takerNickname: string;
// 收款信息接买单时由taker提供
paymentMethod: C2cPaymentMethod;
paymentAccount: string;
paymentQrCode: string;
paymentRealName: string;
// 超时时间
paymentDeadline: Date;
confirmDeadline: Date;
// 时间戳
matchedAt: Date;
paidAt: Date;
completedAt: Date;
cancelledAt: Date;
expiredAt: Date;
}>,
): Promise<C2cOrderEntity | null> {
const record = await this.prisma.c2cOrder.update({
where: { orderNo },
data: {
status: status as any,
...additionalData,
paymentMethod: additionalData?.paymentMethod as any,
},
});
return this.toEntity(record);
}
/**
*
*/
async findExpiredOrders(): Promise<C2cOrderEntity[]> {
const now = new Date();
const records = await this.prisma.c2cOrder.findMany({
where: {
OR: [
// MATCHED状态但付款超时
{
status: C2C_ORDER_STATUS.MATCHED as any,
paymentDeadline: { lt: now },
},
// PAID状态但确认超时
{
status: C2C_ORDER_STATUS.PAID as any,
confirmDeadline: { lt: now },
},
],
},
});
return records.map((r: any) => this.toEntity(r));
}
/**
* 广
*/
async findMarketOrders(options: {
type?: C2cOrderType;
page: number;
pageSize: number;
excludeAccountSequence?: string;
}): Promise<{ data: C2cOrderEntity[]; total: number }> {
const where: any = {
status: C2C_ORDER_STATUS.PENDING,
};
if (options.type) {
where.type = options.type;
}
// 排除自己的订单
if (options.excludeAccountSequence) {
where.makerAccountSequence = { not: options.excludeAccountSequence };
}
const [records, total] = await Promise.all([
this.prisma.c2cOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
}),
this.prisma.c2cOrder.count({ where }),
]);
return {
data: records.map((r: any) => this.toEntity(r)),
total,
};
}
/**
* maker和taker的订单
*/
async findByAccountSequence(
accountSequence: string,
options: {
status?: C2cOrderStatus;
page: number;
pageSize: number;
},
): Promise<{ data: C2cOrderEntity[]; total: number }> {
const where: any = {
OR: [{ makerAccountSequence: accountSequence }, { takerAccountSequence: accountSequence }],
};
if (options.status) {
where.status = options.status;
}
const [records, total] = await Promise.all([
this.prisma.c2cOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
}),
this.prisma.c2cOrder.count({ where }),
]);
return {
data: records.map((r: any) => this.toEntity(r)),
total,
};
}
/**
*
*/
async findPendingByMaker(makerAccountSequence: string): Promise<C2cOrderEntity[]> {
const records = await this.prisma.c2cOrder.findMany({
where: {
makerAccountSequence,
status: C2C_ORDER_STATUS.PENDING as any,
},
orderBy: { createdAt: 'desc' },
});
return records.map((r: any) => this.toEntity(r));
}
/**
* Prisma记录转为实体
*/
private toEntity(record: any): C2cOrderEntity {
return {
id: record.id,
orderNo: record.orderNo,
type: record.type as C2cOrderType,
status: record.status as C2cOrderStatus,
makerAccountSequence: record.makerAccountSequence,
makerUserId: record.makerUserId,
makerPhone: record.makerPhone,
makerNickname: record.makerNickname,
takerAccountSequence: record.takerAccountSequence,
takerUserId: record.takerUserId,
takerPhone: record.takerPhone,
takerNickname: record.takerNickname,
price: record.price.toString(),
quantity: record.quantity.toString(),
totalAmount: record.totalAmount.toString(),
minAmount: record.minAmount.toString(),
maxAmount: record.maxAmount.toString(),
// 收款信息
paymentMethod: record.paymentMethod as C2cPaymentMethod | null,
paymentAccount: record.paymentAccount,
paymentQrCode: record.paymentQrCode,
paymentRealName: record.paymentRealName,
// 超时配置
paymentTimeoutMinutes: record.paymentTimeoutMinutes,
confirmTimeoutMinutes: record.confirmTimeoutMinutes,
paymentDeadline: record.paymentDeadline,
confirmDeadline: record.confirmDeadline,
// 其他
remark: record.remark,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
matchedAt: record.matchedAt,
paidAt: record.paidAt,
completedAt: record.completedAt,
cancelledAt: record.cancelledAt,
expiredAt: record.expiredAt,
};
}
}

View File

@ -80,6 +80,54 @@ export class TradingAccountRepository {
return { data: records, total };
}
/**
*
*/
async freezeShares(accountSequence: string, amount: { toNumber: () => number }): Promise<void> {
await this.prisma.tradingAccount.update({
where: { accountSequence },
data: {
frozenShares: { increment: amount.toNumber() },
},
});
}
/**
*
*/
async unfreezeShares(accountSequence: string, amount: { toNumber: () => number }): Promise<void> {
await this.prisma.tradingAccount.update({
where: { accountSequence },
data: {
frozenShares: { decrement: amount.toNumber() },
},
});
}
/**
*
*/
async freezeCash(accountSequence: string, amount: { toNumber: () => number }): Promise<void> {
await this.prisma.tradingAccount.update({
where: { accountSequence },
data: {
frozenCash: { increment: amount.toNumber() },
},
});
}
/**
*
*/
async unfreezeCash(accountSequence: string, amount: { toNumber: () => number }): Promise<void> {
await this.prisma.tradingAccount.update({
where: { accountSequence },
data: {
frozenCash: { decrement: amount.toNumber() },
},
});
}
private toDomain(record: any): TradingAccountAggregate {
return TradingAccountAggregate.reconstitute({
id: record.id,

View File

@ -96,6 +96,11 @@ abstract class TradingRemoteDataSource {
required String quantity,
String? minAmount,
String? maxAmount,
//
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
String? remark,
});
@ -103,7 +108,15 @@ abstract class TradingRemoteDataSource {
Future<C2cOrderModel> getC2cOrderDetail(String orderNo);
///
Future<C2cOrderModel> takeC2cOrder(String orderNo, {String? quantity});
Future<C2cOrderModel> takeC2cOrder(
String orderNo, {
String? quantity,
// taker提供
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
});
/// C2C订单
Future<void> cancelC2cOrder(String orderNo);
@ -414,6 +427,11 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
required String quantity,
String? minAmount,
String? maxAmount,
//
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
String? remark,
}) async {
try {
@ -424,6 +442,11 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
};
if (minAmount != null) data['minAmount'] = minAmount;
if (maxAmount != null) data['maxAmount'] = maxAmount;
//
if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
if (paymentAccount != null) data['paymentAccount'] = paymentAccount;
if (paymentQrCode != null) data['paymentQrCode'] = paymentQrCode;
if (paymentRealName != null) data['paymentRealName'] = paymentRealName;
if (remark != null && remark.isNotEmpty) data['remark'] = remark;
final response = await client.post(
@ -449,10 +472,23 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
}
@override
Future<C2cOrderModel> takeC2cOrder(String orderNo, {String? quantity}) async {
Future<C2cOrderModel> takeC2cOrder(
String orderNo, {
String? quantity,
// taker提供
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
}) async {
try {
final data = <String, dynamic>{};
if (quantity != null) data['quantity'] = quantity;
//
if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
if (paymentAccount != null) data['paymentAccount'] = paymentAccount;
if (paymentQrCode != null) data['paymentQrCode'] = paymentQrCode;
if (paymentRealName != null) data['paymentRealName'] = paymentRealName;
final response = await client.post(
ApiEndpoints.c2cTakeOrder(orderNo),

View File

@ -14,6 +14,13 @@ enum C2cOrderStatus {
expired, //
}
/// C2C收款方式
enum C2cPaymentMethod {
alipay, //
wechat, //
bank, //
}
/// C2C订单模型
class C2cOrderModel {
final String orderNo;
@ -29,6 +36,17 @@ class C2cOrderModel {
final String totalAmount; //
final String minAmount; //
final String maxAmount; //
//
final C2cPaymentMethod? paymentMethod; //
final String? paymentAccount; //
final String? paymentQrCode; // URL
final String? paymentRealName; //
//
final int paymentTimeoutMinutes; //
final int confirmTimeoutMinutes; //
final DateTime? paymentDeadline; //
final DateTime? confirmDeadline; //
//
final C2cOrderStatus status;
final String? remark;
final DateTime createdAt;
@ -51,6 +69,17 @@ class C2cOrderModel {
required this.totalAmount,
required this.minAmount,
required this.maxAmount,
//
this.paymentMethod,
this.paymentAccount,
this.paymentQrCode,
this.paymentRealName,
//
this.paymentTimeoutMinutes = 15,
this.confirmTimeoutMinutes = 60,
this.paymentDeadline,
this.confirmDeadline,
//
required this.status,
this.remark,
required this.createdAt,
@ -75,6 +104,21 @@ class C2cOrderModel {
totalAmount: json['totalAmount']?.toString() ?? '0',
minAmount: json['minAmount']?.toString() ?? '0',
maxAmount: json['maxAmount']?.toString() ?? '0',
//
paymentMethod: _parsePaymentMethod(json['paymentMethod']),
paymentAccount: json['paymentAccount'],
paymentQrCode: json['paymentQrCode'],
paymentRealName: json['paymentRealName'],
//
paymentTimeoutMinutes: json['paymentTimeoutMinutes'] ?? 15,
confirmTimeoutMinutes: json['confirmTimeoutMinutes'] ?? 60,
paymentDeadline: json['paymentDeadline'] != null
? DateTime.parse(json['paymentDeadline'])
: null,
confirmDeadline: json['confirmDeadline'] != null
? DateTime.parse(json['confirmDeadline'])
: null,
//
status: _parseOrderStatus(json['status']),
remark: json['remark'],
createdAt: json['createdAt'] != null
@ -106,6 +150,20 @@ class C2cOrderModel {
}
}
static C2cPaymentMethod? _parsePaymentMethod(String? method) {
if (method == null) return null;
switch (method.toUpperCase()) {
case 'ALIPAY':
return C2cPaymentMethod.alipay;
case 'WECHAT':
return C2cPaymentMethod.wechat;
case 'BANK':
return C2cPaymentMethod.bank;
default:
return null;
}
}
static C2cOrderStatus _parseOrderStatus(String? status) {
switch (status?.toUpperCase()) {
case 'PENDING':
@ -135,6 +193,37 @@ class C2cOrderModel {
String get typeText => isBuy ? '买入' : '卖出';
///
String get paymentMethodText {
switch (paymentMethod) {
case C2cPaymentMethod.alipay:
return '支付宝';
case C2cPaymentMethod.wechat:
return '微信';
case C2cPaymentMethod.bank:
return '银行卡';
default:
return '未设置';
}
}
///
bool get hasPaymentInfo => paymentMethod != null && paymentAccount != null;
///
int? get paymentRemainingSeconds {
if (paymentDeadline == null) return null;
final remaining = paymentDeadline!.difference(DateTime.now()).inSeconds;
return remaining > 0 ? remaining : 0;
}
///
int? get confirmRemainingSeconds {
if (confirmDeadline == null) return null;
final remaining = confirmDeadline!.difference(DateTime.now()).inSeconds;
return remaining > 0 ? remaining : 0;
}
String get statusText {
switch (status) {
case C2cOrderStatus.pending:

View File

@ -25,6 +25,21 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
static const Color _grayText = Color(0xFF6B7280);
static const Color _bgGray = Color(0xFFF3F4F6);
//
@override
void initState() {
super.initState();
//
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() {});
return true;
}
return false;
});
}
@override
Widget build(BuildContext context) {
final orderAsync = ref.watch(c2cOrderDetailProvider(widget.orderNo));
@ -98,6 +113,14 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
//
_buildOrderInfoCard(order, isMaker),
//
if (order.hasPaymentInfo &&
(order.isMatched || order.isPaid))
...[
const SizedBox(height: 16),
_buildPaymentInfoCard(order, isBuyer),
],
const SizedBox(height: 16),
//
@ -124,6 +147,7 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
String statusText;
String statusDesc;
IconData statusIcon;
int? remainingSeconds;
switch (order.status) {
case C2cOrderStatus.pending:
@ -135,13 +159,19 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
case C2cOrderStatus.matched:
statusColor = Colors.blue;
statusText = '待付款';
statusDesc = '买方需在规定时间内付款';
remainingSeconds = order.paymentRemainingSeconds;
statusDesc = remainingSeconds != null && remainingSeconds > 0
? '请在 ${_formatRemainingTime(remainingSeconds)} 内完成付款'
: '买方需在规定时间内付款';
statusIcon = Icons.payment;
break;
case C2cOrderStatus.paid:
statusColor = Colors.purple;
statusText = '待确认';
statusDesc = '卖方需确认收款后释放资产';
remainingSeconds = order.confirmRemainingSeconds;
statusDesc = remainingSeconds != null && remainingSeconds > 0
? '请在 ${_formatRemainingTime(remainingSeconds)} 内确认收款'
: '卖方需确认收款后释放资产';
statusIcon = Icons.check_circle_outline;
break;
case C2cOrderStatus.completed:
@ -194,13 +224,64 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
const SizedBox(height: 8),
Text(
statusDesc,
style: const TextStyle(fontSize: 14, color: _grayText),
style: TextStyle(
fontSize: 14,
color: remainingSeconds != null && remainingSeconds < 300
? _red
: _grayText,
fontWeight: remainingSeconds != null && remainingSeconds < 300
? FontWeight.bold
: FontWeight.normal,
),
),
//
if (remainingSeconds != null && remainingSeconds > 0) ...[
const SizedBox(height: 16),
_buildCountdownProgress(order, remainingSeconds),
],
],
),
);
}
Widget _buildCountdownProgress(C2cOrderModel order, int remainingSeconds) {
final totalSeconds = order.isMatched
? order.paymentTimeoutMinutes * 60
: order.confirmTimeoutMinutes * 60;
final progress = remainingSeconds / totalSeconds;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: _bgGray,
valueColor: AlwaysStoppedAnimation<Color>(
remainingSeconds < 300 ? _red : _orange,
),
minHeight: 6,
),
),
const SizedBox(height: 8),
Text(
'剩余时间 ${_formatRemainingTime(remainingSeconds)}',
style: TextStyle(
fontSize: 12,
color: remainingSeconds < 300 ? _red : _grayText,
),
),
],
);
}
String _formatRemainingTime(int seconds) {
if (seconds <= 0) return '00:00';
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
Widget _buildOrderInfoCard(C2cOrderModel order, bool isMaker) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@ -261,6 +342,198 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
);
}
Widget _buildPaymentInfoCard(C2cOrderModel order, bool isBuyer) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isBuyer ? _green.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: isBuyer ? Border.all(color: _green.withOpacity(0.3)) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPaymentIcon(order.paymentMethod),
size: 20,
color: _orange,
),
const SizedBox(width: 8),
Text(
isBuyer ? '请向以下账户付款' : '您的收款信息',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
],
),
const SizedBox(height: 16),
//
_buildPaymentInfoRow(
label: '收款方式',
value: order.paymentMethodText,
icon: _getPaymentIcon(order.paymentMethod),
),
//
if (order.paymentAccount != null)
_buildPaymentInfoRow(
label: '收款账号',
value: order.paymentAccount!,
canCopy: true,
),
//
if (order.paymentRealName != null)
_buildPaymentInfoRow(
label: '收款人',
value: order.paymentRealName!,
),
//
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'付款金额',
style: TextStyle(fontSize: 14, color: _grayText),
),
Row(
children: [
Text(
formatAmount(order.totalAmount),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _orange,
),
),
const SizedBox(width: 4),
const Text(
'积分值',
style: TextStyle(fontSize: 12, color: _grayText),
),
],
),
],
),
),
//
if (isBuyer) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: const [
Icon(Icons.warning_amber, size: 16, color: _red),
SizedBox(width: 8),
Expanded(
child: Text(
'请确保转账金额准确,转账后点击"已付款"按钮',
style: TextStyle(fontSize: 12, color: _red),
),
),
],
),
),
],
],
),
);
}
Widget _buildPaymentInfoRow({
required String label,
required String value,
IconData? icon,
bool canCopy = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: _grayText),
),
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: _darkText),
const SizedBox(width: 4),
],
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _darkText,
),
),
if (canCopy) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已复制'),
duration: Duration(seconds: 1),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'复制',
style: TextStyle(fontSize: 12, color: _orange),
),
),
),
],
],
),
],
),
);
}
IconData _getPaymentIcon(C2cPaymentMethod? method) {
switch (method) {
case C2cPaymentMethod.alipay:
return Icons.account_balance_wallet;
case C2cPaymentMethod.wechat:
return Icons.chat_bubble;
case C2cPaymentMethod.bank:
return Icons.credit_card;
default:
return Icons.payment;
}
}
Widget _buildInfoRow(String label, String value, {bool canCopy = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),

View File

@ -28,11 +28,18 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
final _quantityController = TextEditingController();
final _remarkController = TextEditingController();
//
String _paymentMethod = 'ALIPAY'; // ALIPAY, WECHAT, BANK
final _paymentAccountController = TextEditingController();
final _paymentRealNameController = TextEditingController();
@override
void dispose() {
_priceController.dispose();
_quantityController.dispose();
_remarkController.dispose();
_paymentAccountController.dispose();
_paymentRealNameController.dispose();
super.dispose();
}
@ -98,6 +105,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
const SizedBox(height: 16),
//
if (_selectedType == 1) ...[
_buildPaymentInfoInput(),
const SizedBox(height: 16),
],
//
_buildRemarkInput(),
@ -346,6 +359,161 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
);
}
Widget _buildPaymentInfoInput() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Text(
'收款信息',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _darkText,
),
),
SizedBox(width: 8),
Text(
'(必填)',
style: TextStyle(fontSize: 12, color: _red),
),
],
),
const SizedBox(height: 16),
//
const Text(
'收款方式',
style: TextStyle(fontSize: 14, color: _grayText),
),
const SizedBox(height: 8),
Row(
children: [
_buildPaymentMethodChip('ALIPAY', '支付宝', Icons.account_balance_wallet),
const SizedBox(width: 12),
_buildPaymentMethodChip('WECHAT', '微信', Icons.chat_bubble),
const SizedBox(width: 12),
_buildPaymentMethodChip('BANK', '银行卡', Icons.credit_card),
],
),
const SizedBox(height: 16),
//
const Text(
'收款账号',
style: TextStyle(fontSize: 14, color: _grayText),
),
const SizedBox(height: 8),
TextField(
controller: _paymentAccountController,
decoration: InputDecoration(
hintText: _paymentMethod == 'BANK' ? '请输入银行卡号' : '请输入收款账号',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
),
),
const SizedBox(height: 16),
//
const Text(
'收款人姓名',
style: TextStyle(fontSize: 14, color: _grayText),
),
const SizedBox(height: 8),
TextField(
controller: _paymentRealNameController,
decoration: InputDecoration(
hintText: '请输入收款人真实姓名',
hintStyle: const TextStyle(color: _grayText),
filled: true,
fillColor: _bgGray,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _orange, width: 2),
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: const [
Icon(Icons.info_outline, size: 16, color: _orange),
SizedBox(width: 8),
Expanded(
child: Text(
'买家会根据您填写的收款信息进行付款,请确保信息准确',
style: TextStyle(fontSize: 12, color: _orange),
),
),
],
),
),
],
),
);
}
Widget _buildPaymentMethodChip(String value, String label, IconData icon) {
final isSelected = _paymentMethod == value;
return GestureDetector(
onTap: () => setState(() => _paymentMethod = value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? _orange : _bgGray,
borderRadius: BorderRadius.circular(8),
border: isSelected ? null : Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: isSelected ? Colors.white : _grayText,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : _darkText,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
);
}
Widget _buildRemarkInput() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
@ -438,7 +606,15 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
Widget _buildPublishButton(C2cTradingState c2cState) {
final price = double.tryParse(_priceController.text) ?? 0;
final quantity = double.tryParse(_quantityController.text) ?? 0;
final isValid = price > 0 && quantity > 0;
final isSell = _selectedType == 1;
//
bool isValid = price > 0 && quantity > 0;
if (isSell) {
isValid = isValid &&
_paymentAccountController.text.trim().isNotEmpty &&
_paymentRealNameController.text.trim().isNotEmpty;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@ -524,6 +700,29 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
final quantity = _quantityController.text.trim();
final remark = _remarkController.text.trim();
final type = _selectedType == 0 ? 'BUY' : 'SELL';
final isSell = _selectedType == 1;
//
if (isSell) {
if (_paymentAccountController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写收款账号'), backgroundColor: _red),
);
return;
}
if (_paymentRealNameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写收款人姓名'), backgroundColor: _red),
);
return;
}
}
final paymentMethodText = _paymentMethod == 'ALIPAY'
? '支付宝'
: _paymentMethod == 'WECHAT'
? '微信'
: '银行卡';
//
final confirmed = await showDialog<bool>(
@ -542,6 +741,16 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
'总额: ${formatAmount((double.parse(price) * double.parse(quantity)).toString())} 积分值',
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (isSell) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text('收款方式: $paymentMethodText'),
const SizedBox(height: 4),
Text('收款账号: ${_paymentAccountController.text.trim()}'),
const SizedBox(height: 4),
Text('收款人: ${_paymentRealNameController.text.trim()}'),
],
const SizedBox(height: 16),
Text(
_selectedType == 0
@ -574,6 +783,10 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
type: type,
price: price,
quantity: quantity,
//
paymentMethod: isSell ? _paymentMethod : null,
paymentAccount: isSell ? _paymentAccountController.text.trim() : null,
paymentRealName: isSell ? _paymentRealNameController.text.trim() : null,
remark: remark.isEmpty ? null : remark,
);

View File

@ -82,6 +82,11 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
required String quantity,
String? minAmount,
String? maxAmount,
//
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
String? remark,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
@ -92,6 +97,10 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
quantity: quantity,
minAmount: minAmount,
maxAmount: maxAmount,
paymentMethod: paymentMethod,
paymentAccount: paymentAccount,
paymentQrCode: paymentQrCode,
paymentRealName: paymentRealName,
remark: remark,
);
state = state.copyWith(isLoading: false, lastOrder: order);
@ -103,10 +112,25 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
}
///
Future<bool> takeOrder(String orderNo, {String? quantity}) async {
Future<bool> takeOrder(
String orderNo, {
String? quantity,
// taker提供
String? paymentMethod,
String? paymentAccount,
String? paymentQrCode,
String? paymentRealName,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final order = await _dataSource.takeC2cOrder(orderNo, quantity: quantity);
final order = await _dataSource.takeC2cOrder(
orderNo,
quantity: quantity,
paymentMethod: paymentMethod,
paymentAccount: paymentAccount,
paymentQrCode: paymentQrCode,
paymentRealName: paymentRealName,
);
state = state.copyWith(isLoading: false, lastOrder: order);
return true;
} catch (e) {