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)])
|
@@index([marketMakerId, date(sort: Desc)])
|
||||||
@@map("market_maker_daily_stats")
|
@@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 { BurnController } from './controllers/burn.controller';
|
||||||
import { AssetController } from './controllers/asset.controller';
|
import { AssetController } from './controllers/asset.controller';
|
||||||
import { MarketMakerController } from './controllers/market-maker.controller';
|
import { MarketMakerController } from './controllers/market-maker.controller';
|
||||||
|
import { C2cController } from './controllers/c2c.controller';
|
||||||
import { PriceGateway } from './gateways/price.gateway';
|
import { PriceGateway } from './gateways/price.gateway';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -22,6 +23,7 @@ import { PriceGateway } from './gateways/price.gateway';
|
||||||
BurnController,
|
BurnController,
|
||||||
AssetController,
|
AssetController,
|
||||||
MarketMakerController,
|
MarketMakerController,
|
||||||
|
C2cController,
|
||||||
],
|
],
|
||||||
providers: [PriceGateway],
|
providers: [PriceGateway],
|
||||||
exports: [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 { BurnService } from './services/burn.service';
|
||||||
import { AssetService } from './services/asset.service';
|
import { AssetService } from './services/asset.service';
|
||||||
import { MarketMakerService } from './services/market-maker.service';
|
import { MarketMakerService } from './services/market-maker.service';
|
||||||
|
import { C2cService } from './services/c2c.service';
|
||||||
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
import { OutboxScheduler } from './schedulers/outbox.scheduler';
|
||||||
import { BurnScheduler } from './schedulers/burn.scheduler';
|
import { BurnScheduler } from './schedulers/burn.scheduler';
|
||||||
import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler';
|
import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler';
|
||||||
|
import { C2cExpiryScheduler } from './schedulers/c2c-expiry.scheduler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -26,11 +28,13 @@ import { PriceBroadcastScheduler } from './schedulers/price-broadcast.scheduler'
|
||||||
OrderService,
|
OrderService,
|
||||||
TransferService,
|
TransferService,
|
||||||
MarketMakerService,
|
MarketMakerService,
|
||||||
|
C2cService,
|
||||||
// Schedulers
|
// Schedulers
|
||||||
OutboxScheduler,
|
OutboxScheduler,
|
||||||
BurnScheduler,
|
BurnScheduler,
|
||||||
PriceBroadcastScheduler,
|
PriceBroadcastScheduler,
|
||||||
|
C2cExpiryScheduler,
|
||||||
],
|
],
|
||||||
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService],
|
exports: [OrderService, TransferService, PriceService, BurnService, AssetService, MarketMakerService, C2cService],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
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 { CirculationPoolRepository } from './persistence/repositories/circulation-pool.repository';
|
||||||
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
|
import { PriceSnapshotRepository } from './persistence/repositories/price-snapshot.repository';
|
||||||
import { ProcessedEventRepository } from './persistence/repositories/processed-event.repository';
|
import { ProcessedEventRepository } from './persistence/repositories/processed-event.repository';
|
||||||
|
import { C2cOrderRepository } from './persistence/repositories/c2c-order.repository';
|
||||||
import { RedisService } from './redis/redis.service';
|
import { RedisService } from './redis/redis.service';
|
||||||
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
import { KafkaProducerService } from './kafka/kafka-producer.service';
|
||||||
import { UserRegisteredConsumer } from './kafka/consumers/user-registered.consumer';
|
import { UserRegisteredConsumer } from './kafka/consumers/user-registered.consumer';
|
||||||
|
|
@ -51,6 +52,7 @@ import { CdcConsumerService } from './kafka/cdc-consumer.service';
|
||||||
CirculationPoolRepository,
|
CirculationPoolRepository,
|
||||||
PriceSnapshotRepository,
|
PriceSnapshotRepository,
|
||||||
ProcessedEventRepository,
|
ProcessedEventRepository,
|
||||||
|
C2cOrderRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
CdcConsumerService,
|
CdcConsumerService,
|
||||||
{
|
{
|
||||||
|
|
@ -75,6 +77,7 @@ import { CdcConsumerService } from './kafka/cdc-consumer.service';
|
||||||
CirculationPoolRepository,
|
CirculationPoolRepository,
|
||||||
PriceSnapshotRepository,
|
PriceSnapshotRepository,
|
||||||
ProcessedEventRepository,
|
ProcessedEventRepository,
|
||||||
|
C2cOrderRepository,
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
RedisService,
|
RedisService,
|
||||||
ClientsModule,
|
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 };
|
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 {
|
private toDomain(record: any): TradingAccountAggregate {
|
||||||
return TradingAccountAggregate.reconstitute({
|
return TradingAccountAggregate.reconstitute({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@ abstract class TradingRemoteDataSource {
|
||||||
required String quantity,
|
required String quantity,
|
||||||
String? minAmount,
|
String? minAmount,
|
||||||
String? maxAmount,
|
String? maxAmount,
|
||||||
|
// 收款信息(卖单必填)
|
||||||
|
String? paymentMethod,
|
||||||
|
String? paymentAccount,
|
||||||
|
String? paymentQrCode,
|
||||||
|
String? paymentRealName,
|
||||||
String? remark,
|
String? remark,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -103,7 +108,15 @@ abstract class TradingRemoteDataSource {
|
||||||
Future<C2cOrderModel> getC2cOrderDetail(String orderNo);
|
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订单
|
/// 取消C2C订单
|
||||||
Future<void> cancelC2cOrder(String orderNo);
|
Future<void> cancelC2cOrder(String orderNo);
|
||||||
|
|
@ -414,6 +427,11 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||||
required String quantity,
|
required String quantity,
|
||||||
String? minAmount,
|
String? minAmount,
|
||||||
String? maxAmount,
|
String? maxAmount,
|
||||||
|
// 收款信息(卖单必填)
|
||||||
|
String? paymentMethod,
|
||||||
|
String? paymentAccount,
|
||||||
|
String? paymentQrCode,
|
||||||
|
String? paymentRealName,
|
||||||
String? remark,
|
String? remark,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -424,6 +442,11 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||||
};
|
};
|
||||||
if (minAmount != null) data['minAmount'] = minAmount;
|
if (minAmount != null) data['minAmount'] = minAmount;
|
||||||
if (maxAmount != null) data['maxAmount'] = maxAmount;
|
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;
|
if (remark != null && remark.isNotEmpty) data['remark'] = remark;
|
||||||
|
|
||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
|
|
@ -449,10 +472,23 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
try {
|
||||||
final data = <String, dynamic>{};
|
final data = <String, dynamic>{};
|
||||||
if (quantity != null) data['quantity'] = quantity;
|
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(
|
final response = await client.post(
|
||||||
ApiEndpoints.c2cTakeOrder(orderNo),
|
ApiEndpoints.c2cTakeOrder(orderNo),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ enum C2cOrderStatus {
|
||||||
expired, // 已过期
|
expired, // 已过期
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C2C收款方式
|
||||||
|
enum C2cPaymentMethod {
|
||||||
|
alipay, // 支付宝
|
||||||
|
wechat, // 微信
|
||||||
|
bank, // 银行卡
|
||||||
|
}
|
||||||
|
|
||||||
/// C2C订单模型
|
/// C2C订单模型
|
||||||
class C2cOrderModel {
|
class C2cOrderModel {
|
||||||
final String orderNo;
|
final String orderNo;
|
||||||
|
|
@ -29,6 +36,17 @@ class C2cOrderModel {
|
||||||
final String totalAmount; // 总金额(积分值)
|
final String totalAmount; // 总金额(积分值)
|
||||||
final String minAmount; // 最小交易量
|
final String minAmount; // 最小交易量
|
||||||
final String maxAmount; // 最大交易量
|
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 C2cOrderStatus status;
|
||||||
final String? remark;
|
final String? remark;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
@ -51,6 +69,17 @@ class C2cOrderModel {
|
||||||
required this.totalAmount,
|
required this.totalAmount,
|
||||||
required this.minAmount,
|
required this.minAmount,
|
||||||
required this.maxAmount,
|
required this.maxAmount,
|
||||||
|
// 收款信息
|
||||||
|
this.paymentMethod,
|
||||||
|
this.paymentAccount,
|
||||||
|
this.paymentQrCode,
|
||||||
|
this.paymentRealName,
|
||||||
|
// 超时配置
|
||||||
|
this.paymentTimeoutMinutes = 15,
|
||||||
|
this.confirmTimeoutMinutes = 60,
|
||||||
|
this.paymentDeadline,
|
||||||
|
this.confirmDeadline,
|
||||||
|
// 其他
|
||||||
required this.status,
|
required this.status,
|
||||||
this.remark,
|
this.remark,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
|
@ -75,6 +104,21 @@ class C2cOrderModel {
|
||||||
totalAmount: json['totalAmount']?.toString() ?? '0',
|
totalAmount: json['totalAmount']?.toString() ?? '0',
|
||||||
minAmount: json['minAmount']?.toString() ?? '0',
|
minAmount: json['minAmount']?.toString() ?? '0',
|
||||||
maxAmount: json['maxAmount']?.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']),
|
status: _parseOrderStatus(json['status']),
|
||||||
remark: json['remark'],
|
remark: json['remark'],
|
||||||
createdAt: json['createdAt'] != null
|
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) {
|
static C2cOrderStatus _parseOrderStatus(String? status) {
|
||||||
switch (status?.toUpperCase()) {
|
switch (status?.toUpperCase()) {
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
|
|
@ -135,6 +193,37 @@ class C2cOrderModel {
|
||||||
|
|
||||||
String get typeText => isBuy ? '买入' : '卖出';
|
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 {
|
String get statusText {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case C2cOrderStatus.pending:
|
case C2cOrderStatus.pending:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,21 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
||||||
static const Color _grayText = Color(0xFF6B7280);
|
static const Color _grayText = Color(0xFF6B7280);
|
||||||
static const Color _bgGray = Color(0xFFF3F4F6);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final orderAsync = ref.watch(c2cOrderDetailProvider(widget.orderNo));
|
final orderAsync = ref.watch(c2cOrderDetailProvider(widget.orderNo));
|
||||||
|
|
@ -98,6 +113,14 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
||||||
// 订单信息
|
// 订单信息
|
||||||
_buildOrderInfoCard(order, isMaker),
|
_buildOrderInfoCard(order, isMaker),
|
||||||
|
|
||||||
|
// 收款信息卡片(已匹配或已付款状态时显示)
|
||||||
|
if (order.hasPaymentInfo &&
|
||||||
|
(order.isMatched || order.isPaid))
|
||||||
|
...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPaymentInfoCard(order, isBuyer),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 交易双方信息
|
// 交易双方信息
|
||||||
|
|
@ -124,6 +147,7 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
||||||
String statusText;
|
String statusText;
|
||||||
String statusDesc;
|
String statusDesc;
|
||||||
IconData statusIcon;
|
IconData statusIcon;
|
||||||
|
int? remainingSeconds;
|
||||||
|
|
||||||
switch (order.status) {
|
switch (order.status) {
|
||||||
case C2cOrderStatus.pending:
|
case C2cOrderStatus.pending:
|
||||||
|
|
@ -135,13 +159,19 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
||||||
case C2cOrderStatus.matched:
|
case C2cOrderStatus.matched:
|
||||||
statusColor = Colors.blue;
|
statusColor = Colors.blue;
|
||||||
statusText = '待付款';
|
statusText = '待付款';
|
||||||
statusDesc = '买方需在规定时间内付款';
|
remainingSeconds = order.paymentRemainingSeconds;
|
||||||
|
statusDesc = remainingSeconds != null && remainingSeconds > 0
|
||||||
|
? '请在 ${_formatRemainingTime(remainingSeconds)} 内完成付款'
|
||||||
|
: '买方需在规定时间内付款';
|
||||||
statusIcon = Icons.payment;
|
statusIcon = Icons.payment;
|
||||||
break;
|
break;
|
||||||
case C2cOrderStatus.paid:
|
case C2cOrderStatus.paid:
|
||||||
statusColor = Colors.purple;
|
statusColor = Colors.purple;
|
||||||
statusText = '待确认';
|
statusText = '待确认';
|
||||||
statusDesc = '卖方需确认收款后释放资产';
|
remainingSeconds = order.confirmRemainingSeconds;
|
||||||
|
statusDesc = remainingSeconds != null && remainingSeconds > 0
|
||||||
|
? '请在 ${_formatRemainingTime(remainingSeconds)} 内确认收款'
|
||||||
|
: '卖方需确认收款后释放资产';
|
||||||
statusIcon = Icons.check_circle_outline;
|
statusIcon = Icons.check_circle_outline;
|
||||||
break;
|
break;
|
||||||
case C2cOrderStatus.completed:
|
case C2cOrderStatus.completed:
|
||||||
|
|
@ -194,13 +224,64 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
statusDesc,
|
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) {
|
Widget _buildOrderInfoCard(C2cOrderModel order, bool isMaker) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
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}) {
|
Widget _buildInfoRow(String label, String value, {bool canCopy = false}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,18 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
final _quantityController = TextEditingController();
|
final _quantityController = TextEditingController();
|
||||||
final _remarkController = TextEditingController();
|
final _remarkController = TextEditingController();
|
||||||
|
|
||||||
|
// 收款信息(卖单必填)
|
||||||
|
String _paymentMethod = 'ALIPAY'; // ALIPAY, WECHAT, BANK
|
||||||
|
final _paymentAccountController = TextEditingController();
|
||||||
|
final _paymentRealNameController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_priceController.dispose();
|
_priceController.dispose();
|
||||||
_quantityController.dispose();
|
_quantityController.dispose();
|
||||||
_remarkController.dispose();
|
_remarkController.dispose();
|
||||||
|
_paymentAccountController.dispose();
|
||||||
|
_paymentRealNameController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +105,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 收款信息(卖单必填)
|
||||||
|
if (_selectedType == 1) ...[
|
||||||
|
_buildPaymentInfoInput(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
// 备注
|
// 备注
|
||||||
_buildRemarkInput(),
|
_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() {
|
Widget _buildRemarkInput() {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -438,7 +606,15 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
Widget _buildPublishButton(C2cTradingState c2cState) {
|
Widget _buildPublishButton(C2cTradingState c2cState) {
|
||||||
final price = double.tryParse(_priceController.text) ?? 0;
|
final price = double.tryParse(_priceController.text) ?? 0;
|
||||||
final quantity = double.tryParse(_quantityController.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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -524,6 +700,29 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
final quantity = _quantityController.text.trim();
|
final quantity = _quantityController.text.trim();
|
||||||
final remark = _remarkController.text.trim();
|
final remark = _remarkController.text.trim();
|
||||||
final type = _selectedType == 0 ? 'BUY' : 'SELL';
|
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>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
|
@ -542,6 +741,16 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
'总额: ${formatAmount((double.parse(price) * double.parse(quantity)).toString())} 积分值',
|
'总额: ${formatAmount((double.parse(price) * double.parse(quantity)).toString())} 积分值',
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_selectedType == 0
|
_selectedType == 0
|
||||||
|
|
@ -574,6 +783,10 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
||||||
type: type,
|
type: type,
|
||||||
price: price,
|
price: price,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
|
// 收款信息(卖单时传递)
|
||||||
|
paymentMethod: isSell ? _paymentMethod : null,
|
||||||
|
paymentAccount: isSell ? _paymentAccountController.text.trim() : null,
|
||||||
|
paymentRealName: isSell ? _paymentRealNameController.text.trim() : null,
|
||||||
remark: remark.isEmpty ? null : remark,
|
remark: remark.isEmpty ? null : remark,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
|
||||||
required String quantity,
|
required String quantity,
|
||||||
String? minAmount,
|
String? minAmount,
|
||||||
String? maxAmount,
|
String? maxAmount,
|
||||||
|
// 收款信息(卖单必填)
|
||||||
|
String? paymentMethod,
|
||||||
|
String? paymentAccount,
|
||||||
|
String? paymentQrCode,
|
||||||
|
String? paymentRealName,
|
||||||
String? remark,
|
String? remark,
|
||||||
}) async {
|
}) async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
@ -92,6 +97,10 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
minAmount: minAmount,
|
minAmount: minAmount,
|
||||||
maxAmount: maxAmount,
|
maxAmount: maxAmount,
|
||||||
|
paymentMethod: paymentMethod,
|
||||||
|
paymentAccount: paymentAccount,
|
||||||
|
paymentQrCode: paymentQrCode,
|
||||||
|
paymentRealName: paymentRealName,
|
||||||
remark: remark,
|
remark: remark,
|
||||||
);
|
);
|
||||||
state = state.copyWith(isLoading: false, lastOrder: order);
|
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);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
try {
|
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);
|
state = state.copyWith(isLoading: false, lastOrder: order);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue