diff --git a/backend/services/trading-service/prisma/migrations/0008_add_c2c_orders/migration.sql b/backend/services/trading-service/prisma/migrations/0008_add_c2c_orders/migration.sql new file mode 100644 index 00000000..195b9524 --- /dev/null +++ b/backend/services/trading-service/prisma/migrations/0008_add_c2c_orders/migration.sql @@ -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"); diff --git a/backend/services/trading-service/prisma/schema.prisma b/backend/services/trading-service/prisma/schema.prisma index a7f843f2..79591cd3 100644 --- a/backend/services/trading-service/prisma/schema.prisma +++ b/backend/services/trading-service/prisma/schema.prisma @@ -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") +} diff --git a/backend/services/trading-service/src/api/api.module.ts b/backend/services/trading-service/src/api/api.module.ts index 32cbbcd1..d50e1e82 100644 --- a/backend/services/trading-service/src/api/api.module.ts +++ b/backend/services/trading-service/src/api/api.module.ts @@ -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], diff --git a/backend/services/trading-service/src/api/controllers/c2c.controller.ts b/backend/services/trading-service/src/api/controllers/c2c.controller.ts new file mode 100644 index 00000000..be7fd95f --- /dev/null +++ b/backend/services/trading-service/src/api/controllers/c2c.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const accountSequence = req.user?.accountSequence; + if (!accountSequence) { + throw new Error('Unauthorized'); + } + + const order = await this.c2cService.confirmReceived(orderNo, accountSequence); + return this.toResponseDto(order); + } +} diff --git a/backend/services/trading-service/src/api/dto/c2c.dto.ts b/backend/services/trading-service/src/api/dto/c2c.dto.ts new file mode 100644 index 00000000..5645027f --- /dev/null +++ b/backend/services/trading-service/src/api/dto/c2c.dto.ts @@ -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; +} diff --git a/backend/services/trading-service/src/application/application.module.ts b/backend/services/trading-service/src/application/application.module.ts index 9294f601..36cd29ef 100644 --- a/backend/services/trading-service/src/application/application.module.ts +++ b/backend/services/trading-service/src/application/application.module.ts @@ -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 {} diff --git a/backend/services/trading-service/src/application/schedulers/c2c-expiry.scheduler.ts b/backend/services/trading-service/src/application/schedulers/c2c-expiry.scheduler.ts new file mode 100644 index 00000000..f53720e0 --- /dev/null +++ b/backend/services/trading-service/src/application/schedulers/c2c-expiry.scheduler.ts @@ -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 { + // 使用分布式锁防止多实例并发执行 + 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); + } + } +} diff --git a/backend/services/trading-service/src/application/services/c2c.service.ts b/backend/services/trading-service/src/application/services/c2c.service.ts new file mode 100644 index 00000000..86b21595 --- /dev/null +++ b/backend/services/trading-service/src/application/services/c2c.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const order = await this.c2cOrderRepository.findByOrderNo(orderNo); + if (!order) { + throw new NotFoundException('订单不存在'); + } + return order; + } + + /** + * 处理超时订单(由定时任务调用) + */ + async processExpiredOrders(): Promise { + 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 { + 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); + } + } +} diff --git a/backend/services/trading-service/src/infrastructure/infrastructure.module.ts b/backend/services/trading-service/src/infrastructure/infrastructure.module.ts index 2e792552..fdda47d6 100644 --- a/backend/services/trading-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/trading-service/src/infrastructure/infrastructure.module.ts @@ -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, diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts new file mode 100644 index 00000000..d09224b0 --- /dev/null +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts @@ -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 { + const record = await this.prisma.c2cOrder.findUnique({ where: { id } }); + return record ? this.toEntity(record) : null; + } + + /** + * 根据订单号查询订单 + */ + async findByOrderNo(orderNo: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/trading-account.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/trading-account.repository.ts index cf356d47..7253c18b 100644 --- a/backend/services/trading-service/src/infrastructure/persistence/repositories/trading-account.repository.ts +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/trading-account.repository.ts @@ -80,6 +80,54 @@ export class TradingAccountRepository { return { data: records, total }; } + /** + * 冻结积分股 + */ + async freezeShares(accountSequence: string, amount: { toNumber: () => number }): Promise { + await this.prisma.tradingAccount.update({ + where: { accountSequence }, + data: { + frozenShares: { increment: amount.toNumber() }, + }, + }); + } + + /** + * 解冻积分股 + */ + async unfreezeShares(accountSequence: string, amount: { toNumber: () => number }): Promise { + await this.prisma.tradingAccount.update({ + where: { accountSequence }, + data: { + frozenShares: { decrement: amount.toNumber() }, + }, + }); + } + + /** + * 冻结积分值(现金) + */ + async freezeCash(accountSequence: string, amount: { toNumber: () => number }): Promise { + await this.prisma.tradingAccount.update({ + where: { accountSequence }, + data: { + frozenCash: { increment: amount.toNumber() }, + }, + }); + } + + /** + * 解冻积分值(现金) + */ + async unfreezeCash(accountSequence: string, amount: { toNumber: () => number }): Promise { + await this.prisma.tradingAccount.update({ + where: { accountSequence }, + data: { + frozenCash: { decrement: amount.toNumber() }, + }, + }); + } + private toDomain(record: any): TradingAccountAggregate { return TradingAccountAggregate.reconstitute({ id: record.id, diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index f9b4db2f..66d24af4 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -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 getC2cOrderDetail(String orderNo); /// 接单(吃单) - Future takeC2cOrder(String orderNo, {String? quantity}); + Future takeC2cOrder( + String orderNo, { + String? quantity, + // 收款信息(接买单时由taker提供) + String? paymentMethod, + String? paymentAccount, + String? paymentQrCode, + String? paymentRealName, + }); /// 取消C2C订单 Future 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 takeC2cOrder(String orderNo, {String? quantity}) async { + Future takeC2cOrder( + String orderNo, { + String? quantity, + // 收款信息(接买单时由taker提供) + String? paymentMethod, + String? paymentAccount, + String? paymentQrCode, + String? paymentRealName, + }) async { try { final data = {}; 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), diff --git a/frontend/mining-app/lib/data/models/c2c_order_model.dart b/frontend/mining-app/lib/data/models/c2c_order_model.dart index 90029354..6774a27c 100644 --- a/frontend/mining-app/lib/data/models/c2c_order_model.dart +++ b/frontend/mining-app/lib/data/models/c2c_order_model.dart @@ -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: diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart index 0ccd22b0..c31c5194 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart @@ -25,6 +25,21 @@ class _C2cOrderDetailPageState extends ConsumerState { 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 { // 订单信息 _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 { String statusText; String statusDesc; IconData statusIcon; + int? remainingSeconds; switch (order.status) { case C2cOrderStatus.pending: @@ -135,13 +159,19 @@ class _C2cOrderDetailPageState extends ConsumerState { 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 { 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( + 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 { ); } + 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), diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart index 40fb1f30..69fae63c 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart @@ -28,11 +28,18 @@ class _C2cPublishPageState extends ConsumerState { 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 { const SizedBox(height: 16), + // 收款信息(卖单必填) + if (_selectedType == 1) ...[ + _buildPaymentInfoInput(), + const SizedBox(height: 16), + ], + // 备注 _buildRemarkInput(), @@ -346,6 +359,161 @@ class _C2cPublishPageState extends ConsumerState { ); } + 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 { 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 { 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( @@ -542,6 +741,16 @@ class _C2cPublishPageState extends ConsumerState { '总额: ${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 { 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, ); diff --git a/frontend/mining-app/lib/presentation/providers/c2c_providers.dart b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart index 68e20647..1d422f8e 100644 --- a/frontend/mining-app/lib/presentation/providers/c2c_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart @@ -82,6 +82,11 @@ class C2cTradingNotifier extends StateNotifier { 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 { 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 { } /// 接单 - Future takeOrder(String orderNo, {String? quantity}) async { + Future 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) {