feat(c2c): 订单详情增强、付款水单上传、买单逻辑修复及schema对齐
- 订单详情页显示完整手机号和收款账号信息 - 新增付款水单上传功能(前后端全链路) - 修复时间显示为本地时区格式 - 移除买单发布时的积分值余额验证(买方通过外部绿积分支付) - 前端买单发布页隐藏余额卡片和"全部"按钮 - 补齐 Prisma migration 与 schema 差异(payment_proof_url、 bot_purchased索引、market_maker_deposits/withdraws表) - docker-compose 新增 trading-uploads 卷用于水单持久化 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4df23b02b8
commit
ce173451f5
|
|
@ -121,6 +121,8 @@ services:
|
|||
FUSDT_MARKET_MAKER_ADDRESS: ${FUSDT_MARKET_MAKER_ADDRESS:-}
|
||||
EUSDT_MARKET_MAKER_USERNAME: ${EUSDT_MARKET_MAKER_USERNAME:-}
|
||||
EUSDT_MARKET_MAKER_ADDRESS: ${EUSDT_MARKET_MAKER_ADDRESS:-}
|
||||
volumes:
|
||||
- trading-uploads:/app/uploads
|
||||
ports:
|
||||
- "3022:3022"
|
||||
healthcheck:
|
||||
|
|
@ -349,6 +351,8 @@ services:
|
|||
volumes:
|
||||
mining-admin-uploads:
|
||||
driver: local
|
||||
trading-uploads:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
rwa-network:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
-- ============================================================================
|
||||
-- 补齐 schema 与 migration 的差异
|
||||
-- 新增: payment_proof_url 字段, bot_purchased 索引,
|
||||
-- 做市商充提记录表 (market_maker_deposits, market_maker_withdraws)
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. c2c_orders: 补充 payment_proof_url 字段
|
||||
ALTER TABLE "c2c_orders" ADD COLUMN "payment_proof_url" TEXT;
|
||||
|
||||
-- 2. c2c_orders: 补充 bot_purchased 索引
|
||||
CREATE INDEX "c2c_orders_bot_purchased_idx" ON "c2c_orders"("bot_purchased");
|
||||
|
||||
-- 3. 创建 MarketMakerAssetType 枚举
|
||||
CREATE TYPE "MarketMakerAssetType" AS ENUM ('EUSDT', 'FUSDT');
|
||||
|
||||
-- 4. 创建 MarketMakerDepositStatus 枚举
|
||||
CREATE TYPE "MarketMakerDepositStatus" AS ENUM ('DETECTED', 'CONFIRMING', 'CONFIRMED', 'CREDITED', 'FAILED');
|
||||
|
||||
-- 5. 创建 MarketMakerWithdrawStatus 枚举
|
||||
CREATE TYPE "MarketMakerWithdrawStatus" AS ENUM ('PENDING', 'SIGNING', 'SIGNED', 'BROADCAST', 'CONFIRMING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- 6. 创建 market_maker_deposits 表
|
||||
CREATE TABLE "market_maker_deposits" (
|
||||
"id" TEXT NOT NULL,
|
||||
"market_maker_id" TEXT NOT NULL,
|
||||
"chain_type" VARCHAR(20) NOT NULL,
|
||||
"tx_hash" VARCHAR(66) NOT NULL,
|
||||
"block_number" BIGINT NOT NULL,
|
||||
"block_timestamp" TIMESTAMP(3) NOT NULL,
|
||||
"asset_type" "MarketMakerAssetType" NOT NULL,
|
||||
"token_contract" VARCHAR(42) NOT NULL,
|
||||
"from_address" VARCHAR(42) NOT NULL,
|
||||
"to_address" VARCHAR(42) NOT NULL,
|
||||
"amount" DECIMAL(36,8) NOT NULL,
|
||||
"amount_raw" DECIMAL(78,0) NOT NULL,
|
||||
"confirmations" INTEGER NOT NULL DEFAULT 0,
|
||||
"required_confirms" INTEGER NOT NULL DEFAULT 12,
|
||||
"status" "MarketMakerDepositStatus" NOT NULL DEFAULT 'DETECTED',
|
||||
"credited_at" TIMESTAMP(3),
|
||||
"credited_amount" DECIMAL(36,8),
|
||||
"ledger_id" TEXT,
|
||||
"error_message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "market_maker_deposits_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "market_maker_deposits_tx_hash_key" ON "market_maker_deposits"("tx_hash");
|
||||
CREATE INDEX "market_maker_deposits_market_maker_id_idx" ON "market_maker_deposits"("market_maker_id");
|
||||
CREATE INDEX "market_maker_deposits_status_idx" ON "market_maker_deposits"("status");
|
||||
CREATE INDEX "market_maker_deposits_chain_type_status_idx" ON "market_maker_deposits"("chain_type", "status");
|
||||
CREATE INDEX "market_maker_deposits_asset_type_idx" ON "market_maker_deposits"("asset_type");
|
||||
CREATE INDEX "market_maker_deposits_block_number_idx" ON "market_maker_deposits"("block_number");
|
||||
CREATE INDEX "market_maker_deposits_created_at_idx" ON "market_maker_deposits"("created_at" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "market_maker_deposits" ADD CONSTRAINT "market_maker_deposits_market_maker_id_fkey" FOREIGN KEY ("market_maker_id") REFERENCES "market_maker_configs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- 7. 创建 market_maker_withdraws 表
|
||||
CREATE TABLE "market_maker_withdraws" (
|
||||
"id" TEXT NOT NULL,
|
||||
"withdraw_no" TEXT NOT NULL,
|
||||
"market_maker_id" TEXT NOT NULL,
|
||||
"asset_type" "MarketMakerAssetType" NOT NULL,
|
||||
"amount" DECIMAL(36,8) NOT NULL,
|
||||
"chain_type" VARCHAR(20) NOT NULL,
|
||||
"token_contract" VARCHAR(42) NOT NULL,
|
||||
"from_address" VARCHAR(42) NOT NULL,
|
||||
"to_address" VARCHAR(42) NOT NULL,
|
||||
"tx_hash" VARCHAR(66),
|
||||
"block_number" BIGINT,
|
||||
"confirmations" INTEGER NOT NULL DEFAULT 0,
|
||||
"gas_used" DECIMAL(36,0),
|
||||
"gas_price" DECIMAL(36,0),
|
||||
"status" "MarketMakerWithdrawStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"ledger_id" TEXT,
|
||||
"error_message" TEXT,
|
||||
"retry_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"signed_at" TIMESTAMP(3),
|
||||
"broadcast_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"cancelled_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "market_maker_withdraws_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "market_maker_withdraws_withdraw_no_key" ON "market_maker_withdraws"("withdraw_no");
|
||||
CREATE INDEX "market_maker_withdraws_market_maker_id_idx" ON "market_maker_withdraws"("market_maker_id");
|
||||
CREATE INDEX "market_maker_withdraws_status_idx" ON "market_maker_withdraws"("status");
|
||||
CREATE INDEX "market_maker_withdraws_asset_type_idx" ON "market_maker_withdraws"("asset_type");
|
||||
CREATE INDEX "market_maker_withdraws_tx_hash_idx" ON "market_maker_withdraws"("tx_hash");
|
||||
CREATE INDEX "market_maker_withdraws_created_at_idx" ON "market_maker_withdraws"("created_at" DESC);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "market_maker_withdraws" ADD CONSTRAINT "market_maker_withdraws_market_maker_id_fkey" FOREIGN KEY ("market_maker_id") REFERENCES "market_maker_configs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -679,6 +679,9 @@ model C2cOrder {
|
|||
paymentDeadline DateTime? @map("payment_deadline") // 付款截止时间
|
||||
confirmDeadline DateTime? @map("confirm_deadline") // 确认收款截止时间
|
||||
|
||||
// 付款水单(买方上传的付款凭证图片)
|
||||
paymentProofUrl String? @map("payment_proof_url")
|
||||
|
||||
// ============ Bot 自动购买相关 ============
|
||||
// 卖家 Kava 地址(从 SyncedUser 表获取,用于接收 dUSDT)
|
||||
sellerKavaAddress String? @map("seller_kava_address")
|
||||
|
|
|
|||
|
|
@ -6,16 +6,25 @@ import {
|
|||
Query,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
} from '@nestjs/swagger';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { C2cService } from '../../application/services/c2c.service';
|
||||
import {
|
||||
CreateC2cOrderDto,
|
||||
|
|
@ -57,6 +66,8 @@ export class C2cController {
|
|||
paymentAccount: order.paymentAccount || undefined,
|
||||
paymentQrCode: order.paymentQrCode || undefined,
|
||||
paymentRealName: order.paymentRealName || undefined,
|
||||
// 付款水单
|
||||
paymentProofUrl: order.paymentProofUrl || undefined,
|
||||
// 卖家 Kava 地址
|
||||
sellerKavaAddress: order.sellerKavaAddress || undefined,
|
||||
// 超时信息
|
||||
|
|
@ -250,4 +261,53 @@ export class C2cController {
|
|||
const order = await this.c2cService.confirmReceived(orderNo, accountSequence);
|
||||
return this.toResponseDto(order);
|
||||
}
|
||||
|
||||
@Post('orders/:orderNo/upload-proof')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (_req: any, file: any, cb: any) => {
|
||||
if (!file.mimetype.match(/^image\/(jpeg|png|gif|webp)$/)) {
|
||||
cb(new BadRequestException('仅支持 JPEG/PNG/GIF/WebP 图片'), false);
|
||||
} else {
|
||||
cb(null, true);
|
||||
}
|
||||
},
|
||||
}))
|
||||
@ApiOperation({ summary: '上传付款水单(买方操作)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: '上传成功' })
|
||||
async uploadPaymentProof(
|
||||
@Param('orderNo') orderNo: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Req() req: any,
|
||||
): Promise<C2cOrderResponseDto> {
|
||||
const accountSequence = req.user?.accountSequence;
|
||||
if (!accountSequence) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!file) {
|
||||
throw new BadRequestException('请上传付款水单图片');
|
||||
}
|
||||
|
||||
const order = await this.c2cService.uploadPaymentProof(orderNo, accountSequence, file);
|
||||
return this.toResponseDto(order);
|
||||
}
|
||||
|
||||
@Get('proofs/:filename')
|
||||
@ApiOperation({ summary: '获取付款水单图片' })
|
||||
@ApiParam({ name: 'filename', description: '文件名' })
|
||||
async getProofImage(
|
||||
@Param('filename') filename: string,
|
||||
@Res() res: any,
|
||||
): Promise<void> {
|
||||
// 防止路径遍历攻击
|
||||
const safeName = path.basename(filename);
|
||||
const filePath = path.resolve('./uploads/c2c-proofs', safeName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
res.sendFile(filePath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ export class C2cOrderResponseDto {
|
|||
paymentAccount?: string;
|
||||
paymentQrCode?: string;
|
||||
paymentRealName?: string;
|
||||
// 付款水单
|
||||
paymentProofUrl?: string;
|
||||
// 卖家 Kava 地址(绿积分转账地址)
|
||||
sellerKavaAddress?: string;
|
||||
// 超时信息
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
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';
|
||||
|
|
@ -138,12 +140,7 @@ export class C2cService {
|
|||
|
||||
// 检查余额并冻结资产
|
||||
if (type === C2C_ORDER_TYPE.BUY) {
|
||||
// #16: 买入订单:验证积分值余额(买方通过外部系统支付绿积分)
|
||||
const totalAmountMoney = new Money(totalAmount);
|
||||
if (account.availableCash.isLessThan(totalAmountMoney)) {
|
||||
throw new BadRequestException(`积分值余额不足,需要 ${totalAmount.toString()},可用 ${account.availableCash.toString()}`);
|
||||
}
|
||||
// #12: 不冻结(买方通过外部1.0系统支付绿积分,系统无法冻结外部资产)
|
||||
// 买入订单:买方通过外部系统支付绿积分,不需要验证积分值余额也不冻结
|
||||
} else {
|
||||
// 卖出订单:需要冻结积分值(C2C交易的是积分值)
|
||||
const quantityMoney = new Money(quantityDecimal);
|
||||
|
|
@ -618,6 +615,52 @@ export class C2cService {
|
|||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传付款水单(买方操作)
|
||||
*/
|
||||
async uploadPaymentProof(
|
||||
orderNo: string,
|
||||
accountSequence: string,
|
||||
file: { buffer: Buffer; originalname: string; mimetype: string },
|
||||
): Promise<C2cOrderEntity> {
|
||||
const order = await this.c2cOrderRepository.findByOrderNo(orderNo);
|
||||
if (!order) {
|
||||
throw new NotFoundException('订单不存在');
|
||||
}
|
||||
|
||||
// 只有 MATCHED 或 PAID 状态可以上传水单
|
||||
if (order.status !== C2C_ORDER_STATUS.MATCHED && order.status !== C2C_ORDER_STATUS.PAID) {
|
||||
throw new BadRequestException('当前订单状态不允许上传水单');
|
||||
}
|
||||
|
||||
// 确定买方身份
|
||||
const buyerAccountSequence = order.type === C2C_ORDER_TYPE.BUY
|
||||
? order.makerAccountSequence
|
||||
: order.takerAccountSequence;
|
||||
|
||||
if (accountSequence !== buyerAccountSequence) {
|
||||
throw new ForbiddenException('只有买方可以上传付款水单');
|
||||
}
|
||||
|
||||
// 保存文件到本地
|
||||
const uploadDir = path.resolve('./uploads/c2c-proofs');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const filename = `${orderNo}-${Date.now()}${ext}`;
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// 生成访问URL(通过 API 网关访问)
|
||||
const proofUrl = `/api/v2/trading/c2c/proofs/${filename}`;
|
||||
|
||||
const updated = await this.c2cOrderRepository.updatePaymentProof(orderNo, proofUrl);
|
||||
this.logger.log(`C2C订单上传付款水单: ${orderNo}, file: ${filename}`);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理超时订单(由定时任务调用)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface C2cOrderEntity {
|
|||
confirmTimeoutMinutes: number;
|
||||
paymentDeadline?: Date | null;
|
||||
confirmDeadline?: Date | null;
|
||||
// 付款水单
|
||||
paymentProofUrl?: string | null;
|
||||
// Bot 自动购买相关
|
||||
sellerKavaAddress?: string | null;
|
||||
botPurchased: boolean;
|
||||
|
|
@ -153,6 +155,8 @@ export class C2cOrderRepository {
|
|||
// #13: 部分成交时更新数量和金额
|
||||
quantity: string;
|
||||
totalAmount: string;
|
||||
// 付款水单
|
||||
paymentProofUrl: string;
|
||||
// 超时时间
|
||||
paymentDeadline: Date;
|
||||
confirmDeadline: Date;
|
||||
|
|
@ -323,6 +327,17 @@ export class C2cOrderRepository {
|
|||
return this.toEntity(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新付款水单URL
|
||||
*/
|
||||
async updatePaymentProof(orderNo: string, paymentProofUrl: string): Promise<C2cOrderEntity | null> {
|
||||
const record = await this.prisma.c2cOrder.update({
|
||||
where: { orderNo },
|
||||
data: { paymentProofUrl },
|
||||
});
|
||||
return this.toEntity(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Prisma记录转为实体
|
||||
*/
|
||||
|
|
@ -355,6 +370,8 @@ export class C2cOrderRepository {
|
|||
confirmTimeoutMinutes: record.confirmTimeoutMinutes,
|
||||
paymentDeadline: record.paymentDeadline,
|
||||
confirmDeadline: record.confirmDeadline,
|
||||
// 付款水单
|
||||
paymentProofUrl: record.paymentProofUrl,
|
||||
// Bot 自动购买相关
|
||||
sellerKavaAddress: record.sellerKavaAddress,
|
||||
botPurchased: record.botPurchased,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class ApiEndpoints {
|
|||
static String c2cCancelOrder(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/cancel';
|
||||
static String c2cConfirmPayment(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-payment';
|
||||
static String c2cConfirmReceived(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/confirm-received';
|
||||
static String c2cUploadProof(String orderNo) => '/api/v2/trading/c2c/orders/$orderNo/upload-proof';
|
||||
|
||||
// Contribution Service 2.0 (Kong路由: /api/v2/contribution -> /api/v1/contributions)
|
||||
static const String contributionStats = '/api/v2/contribution/stats';
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@ import 'package:intl/intl.dart';
|
|||
|
||||
String formatDateTime(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return DateFormat('yyyy-MM-dd HH:mm:ss').format(date);
|
||||
return DateFormat('yyyy-MM-dd HH:mm:ss').format(date.toLocal());
|
||||
}
|
||||
|
||||
String formatDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return DateFormat('yyyy-MM-dd').format(date);
|
||||
return DateFormat('yyyy-MM-dd').format(date.toLocal());
|
||||
}
|
||||
|
||||
String formatTime(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return DateFormat('HH:mm:ss').format(date);
|
||||
return DateFormat('HH:mm:ss').format(date.toLocal());
|
||||
}
|
||||
|
||||
String formatRelative(DateTime? date) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../models/asset_display_model.dart';
|
|||
import '../../models/kline_model.dart';
|
||||
import '../../models/p2p_transfer_model.dart';
|
||||
import '../../models/p2p_transfer_fee_config_model.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../models/c2c_order_model.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
|
|
@ -137,6 +138,9 @@ abstract class TradingRemoteDataSource {
|
|||
|
||||
/// 确认收款(卖方操作)
|
||||
Future<C2cOrderModel> confirmC2cReceived(String orderNo);
|
||||
|
||||
/// 上传付款水单(买方操作)
|
||||
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath);
|
||||
}
|
||||
|
||||
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||
|
|
@ -581,4 +585,20 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
|||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<C2cOrderModel> uploadC2cPaymentProof(String orderNo, String filePath) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
final response = await client.post(
|
||||
ApiEndpoints.c2cUploadProof(orderNo),
|
||||
data: formData,
|
||||
);
|
||||
return C2cOrderModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ class C2cOrderModel {
|
|||
final int confirmTimeoutMinutes; // 确认收款超时时间(分钟)
|
||||
final DateTime? paymentDeadline; // 付款截止时间
|
||||
final DateTime? confirmDeadline; // 确认收款截止时间
|
||||
// 付款水单
|
||||
final String? paymentProofUrl; // 付款水单图片URL
|
||||
// Bot 自动购买相关
|
||||
final bool botPurchased; // 是否被 Bot 购买
|
||||
final String? sellerKavaAddress; // 卖家 Kava 地址
|
||||
|
|
@ -84,6 +86,8 @@ class C2cOrderModel {
|
|||
this.confirmTimeoutMinutes = 60,
|
||||
this.paymentDeadline,
|
||||
this.confirmDeadline,
|
||||
// 付款水单
|
||||
this.paymentProofUrl,
|
||||
// Bot 自动购买相关
|
||||
this.botPurchased = false,
|
||||
this.sellerKavaAddress,
|
||||
|
|
@ -127,6 +131,8 @@ class C2cOrderModel {
|
|||
confirmDeadline: json['confirmDeadline'] != null
|
||||
? DateTime.parse(json['confirmDeadline'])
|
||||
: null,
|
||||
// 付款水单
|
||||
paymentProofUrl: json['paymentProofUrl'],
|
||||
// Bot 自动购买相关
|
||||
botPurchased: json['botPurchased'] ?? false,
|
||||
sellerKavaAddress: json['sellerKavaAddress'],
|
||||
|
|
|
|||
|
|
@ -602,7 +602,8 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
|
|||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
final local = dateTime.toLocal();
|
||||
return '${local.month}/${local.day} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// #13 + #15: 接单对话框(支持部分成交数量输入 + BUY单收款信息输入)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../../data/models/c2c_order_model.dart';
|
||||
import '../../providers/c2c_providers.dart';
|
||||
|
|
@ -133,6 +134,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
// 交易双方信息
|
||||
_buildParticipantsCard(order, isMaker, isTaker),
|
||||
|
||||
// 付款水单
|
||||
if ((order.isMatched || order.isPaid || order.isCompleted) && !order.isBotPurchased)
|
||||
...[
|
||||
const SizedBox(height: 16),
|
||||
_buildPaymentProofCard(order, isBuyer),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作说明
|
||||
|
|
@ -756,7 +764,8 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
label: order.isBuy ? '买方 (发布者)' : '卖方 (发布者)',
|
||||
name: order.makerNickname?.isNotEmpty == true
|
||||
? order.makerNickname!
|
||||
: (order.makerPhone?.isNotEmpty == true ? _maskPhone(order.makerPhone!) : '未知用户'),
|
||||
: (order.makerPhone?.isNotEmpty == true ? order.makerPhone! : '未知用户'),
|
||||
phone: order.makerPhone,
|
||||
isMe: isMaker,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
|
|
@ -766,7 +775,8 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
label: order.isBuy ? '卖方 (接单者)' : '买方 (接单者)',
|
||||
name: order.takerNickname?.isNotEmpty == true
|
||||
? order.takerNickname!
|
||||
: (order.takerPhone?.isNotEmpty == true ? _maskPhone(order.takerPhone!) : '未知用户'),
|
||||
: (order.takerPhone?.isNotEmpty == true ? order.takerPhone! : '未知用户'),
|
||||
phone: order.takerPhone,
|
||||
isMe: isTaker,
|
||||
)
|
||||
else
|
||||
|
|
@ -774,6 +784,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
'等待接单...',
|
||||
style: TextStyle(fontSize: 14, color: _grayText),
|
||||
),
|
||||
// 对方收款信息
|
||||
if (order.paymentAccount != null && order.takerAccountSequence != null) ...[
|
||||
const Divider(height: 24),
|
||||
_buildInfoRow('收款账号', order.paymentAccount!, canCopy: true),
|
||||
if (order.paymentRealName != null)
|
||||
_buildInfoRow('收款人', order.paymentRealName!),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -783,6 +800,7 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
required String label,
|
||||
required String name,
|
||||
required bool isMe,
|
||||
String? phone,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
|
|
@ -844,6 +862,13 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
],
|
||||
],
|
||||
),
|
||||
if (phone != null && phone.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
phone,
|
||||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -851,6 +876,169 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentProofCard(C2cOrderModel order, bool isBuyer) {
|
||||
final hasProof = order.paymentProofUrl != null && order.paymentProofUrl!.isNotEmpty;
|
||||
|
||||
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 Icon(Icons.receipt_long, size: 20, color: _orange),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'付款水单',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _darkText,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isBuyer && (order.isMatched || order.isPaid))
|
||||
GestureDetector(
|
||||
onTap: () => _handleUploadProof(order),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.upload, size: 16, color: _orange),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'上传水单',
|
||||
style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (hasProof)
|
||||
GestureDetector(
|
||||
onTap: () => _showProofFullScreen(order.paymentProofUrl!),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
order.paymentProofUrl!,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
height: 100,
|
||||
color: _bgGray,
|
||||
child: const Center(
|
||||
child: Text('图片加载失败', style: TextStyle(color: _grayText)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 80,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: _bgGray,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300, style: BorderStyle.solid),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.image_not_supported_outlined, size: 28, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isBuyer ? '请上传付款凭证' : '买方暂未上传水单',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleUploadProof(C2cOrderModel order) async {
|
||||
final picker = ImagePicker();
|
||||
final image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (image == null) return;
|
||||
|
||||
final notifier = ref.read(c2cTradingNotifierProvider.notifier);
|
||||
final success = await notifier.uploadPaymentProof(order.orderNo, image.path);
|
||||
|
||||
if (success && mounted) {
|
||||
ref.invalidate(c2cOrderDetailProvider(order.orderNo));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('付款水单上传成功'),
|
||||
backgroundColor: _green,
|
||||
),
|
||||
);
|
||||
} else if (mounted) {
|
||||
final error = ref.read(c2cTradingNotifierProvider).error;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('上传失败: ${error ?? '未知错误'}'),
|
||||
backgroundColor: _red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showProofFullScreen(String url) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: Colors.black,
|
||||
insetPadding: const EdgeInsets.all(0),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
child: Image.network(
|
||||
url,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Text('图片加载失败', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 28),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInstructionsCard(
|
||||
C2cOrderModel order,
|
||||
bool isMaker,
|
||||
|
|
@ -1108,8 +1296,9 @@ class _C2cOrderDetailPageState extends ConsumerState<C2cOrderDetailPage> {
|
|||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
|
||||
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
final local = dateTime.toLocal();
|
||||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} '
|
||||
'${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _handleCancel(C2cOrderModel order) async {
|
||||
|
|
|
|||
|
|
@ -192,9 +192,11 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
}
|
||||
|
||||
Widget _buildBalanceCard(String availableShares, String availableCash) {
|
||||
// C2C交易对象是积分值,BUY和SELL都显示可用积分值
|
||||
const label = '可用积分值';
|
||||
final balance = availableCash;
|
||||
final isSell = _selectedType == 1;
|
||||
|
||||
// 卖单:显示可用积分值(将被冻结)
|
||||
// 买单:不需要显示余额(买方通过外部绿积分支付)
|
||||
if (!isSell) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -206,12 +208,12 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14, color: _grayText),
|
||||
const Text(
|
||||
'可用积分值',
|
||||
style: TextStyle(fontSize: 14, color: _grayText),
|
||||
),
|
||||
Text(
|
||||
formatAmount(balance),
|
||||
formatAmount(availableCash),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -275,8 +277,9 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
String availableCash,
|
||||
String currentPrice,
|
||||
) {
|
||||
// C2C交易的是积分值,BUY和SELL上限都是可用积分值
|
||||
final maxBalance = availableCash;
|
||||
final isSell = _selectedType == 1;
|
||||
// 卖单:上限为可用积分值;买单:无上限(买方通过外部绿积分支付)
|
||||
final maxBalance = isSell ? availableCash : null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -323,22 +326,24 @@ class _C2cPublishPageState extends ConsumerState<C2cPublishPage> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: _orange, width: 2),
|
||||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
_isSyncingInput = true;
|
||||
_quantityController.text = maxBalance;
|
||||
_amountController.text = maxBalance;
|
||||
_isSyncingInput = false;
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text(
|
||||
'全部',
|
||||
style: TextStyle(
|
||||
color: _orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
suffixIcon: maxBalance != null
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
_isSyncingInput = true;
|
||||
_quantityController.text = maxBalance;
|
||||
_amountController.text = maxBalance;
|
||||
_isSyncingInput = false;
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text(
|
||||
'全部',
|
||||
style: TextStyle(
|
||||
color: _orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixText: '积分值',
|
||||
suffixStyle: const TextStyle(color: _grayText, fontSize: 14),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -178,6 +178,19 @@ class C2cTradingNotifier extends StateNotifier<C2cTradingState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 上传付款水单(买方操作)
|
||||
Future<bool> uploadPaymentProof(String orderNo, String filePath) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
try {
|
||||
final order = await _dataSource.uploadC2cPaymentProof(orderNo, filePath);
|
||||
state = state.copyWith(isLoading: false, lastOrder: order);
|
||||
return true;
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue