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:
parent
928d6c8df2
commit
af339b19b9
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue