diff --git a/backend/services/docker-compose.2.0.yml b/backend/services/docker-compose.2.0.yml index 2d6931ad..59739d0c 100644 --- a/backend/services/docker-compose.2.0.yml +++ b/backend/services/docker-compose.2.0.yml @@ -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: diff --git a/backend/services/trading-service/prisma/migrations/0006_add_missing_tables_and_fields/migration.sql b/backend/services/trading-service/prisma/migrations/0006_add_missing_tables_and_fields/migration.sql new file mode 100644 index 00000000..7c7c4045 --- /dev/null +++ b/backend/services/trading-service/prisma/migrations/0006_add_missing_tables_and_fields/migration.sql @@ -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; diff --git a/backend/services/trading-service/prisma/schema.prisma b/backend/services/trading-service/prisma/schema.prisma index f4dc1440..2dfccaf9 100644 --- a/backend/services/trading-service/prisma/schema.prisma +++ b/backend/services/trading-service/prisma/schema.prisma @@ -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") diff --git a/backend/services/trading-service/src/api/controllers/c2c.controller.ts b/backend/services/trading-service/src/api/controllers/c2c.controller.ts index 819bcc52..86ab4817 100644 --- a/backend/services/trading-service/src/api/controllers/c2c.controller.ts +++ b/backend/services/trading-service/src/api/controllers/c2c.controller.ts @@ -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 { + 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 { + // 防止路径遍历攻击 + const safeName = path.basename(filename); + const filePath = path.resolve('./uploads/c2c-proofs', safeName); + if (!fs.existsSync(filePath)) { + throw new NotFoundException('文件不存在'); + } + res.sendFile(filePath); + } } diff --git a/backend/services/trading-service/src/api/dto/c2c.dto.ts b/backend/services/trading-service/src/api/dto/c2c.dto.ts index ae4cb546..9fd463c6 100644 --- a/backend/services/trading-service/src/api/dto/c2c.dto.ts +++ b/backend/services/trading-service/src/api/dto/c2c.dto.ts @@ -169,6 +169,8 @@ export class C2cOrderResponseDto { paymentAccount?: string; paymentQrCode?: string; paymentRealName?: string; + // 付款水单 + paymentProofUrl?: string; // 卖家 Kava 地址(绿积分转账地址) sellerKavaAddress?: string; // 超时信息 diff --git a/backend/services/trading-service/src/application/services/c2c.service.ts b/backend/services/trading-service/src/application/services/c2c.service.ts index 9f3efd84..4f0f69ef 100644 --- a/backend/services/trading-service/src/application/services/c2c.service.ts +++ b/backend/services/trading-service/src/application/services/c2c.service.ts @@ -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 { + 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!; + } + /** * 处理超时订单(由定时任务调用) */ diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts index 5725b76e..875a552c 100644 --- a/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/c2c-order.repository.ts @@ -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 { + 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, diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 9afbb3a9..01da6dcc 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -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'; diff --git a/frontend/mining-app/lib/core/utils/date_utils.dart b/frontend/mining-app/lib/core/utils/date_utils.dart index a949ca34..9e6121f0 100644 --- a/frontend/mining-app/lib/core/utils/date_utils.dart +++ b/frontend/mining-app/lib/core/utils/date_utils.dart @@ -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) { diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index 3cfa2f7a..2f6d07f3 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -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 confirmC2cReceived(String orderNo); + + /// 上传付款水单(买方操作) + Future uploadC2cPaymentProof(String orderNo, String filePath); } class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { @@ -581,4 +585,20 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future 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()); + } + } } diff --git a/frontend/mining-app/lib/data/models/c2c_order_model.dart b/frontend/mining-app/lib/data/models/c2c_order_model.dart index 1d8a9c4f..c7f71764 100644 --- a/frontend/mining-app/lib/data/models/c2c_order_model.dart +++ b/frontend/mining-app/lib/data/models/c2c_order_model.dart @@ -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'], diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart index 9a0cc6ee..c0578243 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_market_page.dart @@ -602,7 +602,8 @@ class _C2cMarketPageState extends ConsumerState } 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单收款信息输入) diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart index 87ebc1bd..fc20ba48 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_order_detail_page.dart @@ -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 { // 交易双方信息 _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 { 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 { 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 { '等待接单...', 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 { required String label, required String name, required bool isMe, + String? phone, }) { return Row( children: [ @@ -844,6 +862,13 @@ class _C2cOrderDetailPageState extends ConsumerState { ], ], ), + 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 { ); } + 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 _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 { } 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 _handleCancel(C2cOrderModel order) async { diff --git a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart index 3236b63c..930a1907 100644 --- a/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart +++ b/frontend/mining-app/lib/presentation/pages/c2c/c2c_publish_page.dart @@ -192,9 +192,11 @@ class _C2cPublishPageState extends ConsumerState { } 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 { 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 { 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 { 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), ), diff --git a/frontend/mining-app/lib/presentation/providers/c2c_providers.dart b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart index 1d422f8e..8ae36ddb 100644 --- a/frontend/mining-app/lib/presentation/providers/c2c_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/c2c_providers.dart @@ -178,6 +178,19 @@ class C2cTradingNotifier extends StateNotifier { } } + /// 上传付款水单(买方操作) + Future 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); }