From b8f883151675d2327b7ed15b6098d293120b4157 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 31 Jan 2026 22:15:47 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20PENDING=E8=AE=A2=E5=8D=95=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=BF=87=E6=9C=9F=E3=80=81=E6=8E=A5=E5=8D=95=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=BD=99=E9=A2=9D=E3=80=81=E7=A7=AF=E5=88=86=E8=82=A1?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. C2C PENDING订单24小时自动过期并解冻冻结资产 - 新增 DEFAULT_PENDING_TIMEOUT_HOURS 常量 - findExpiredOrders 支持 PENDING 状态查询 - expireOrder 处理 PENDING 卖单解冻 2. C2C接单对话框显示卖方可用余额 - 确认出售时显示用户账户可用积分值(非订单数量) - 新增订单数量行,方便对比 3. 积分股兑换页面新增金额输入 - 卖出时显示金额输入框(积分值),与数量双向联动 - 输入数量自动计算金额,输入金额自动反算数量 - 全部按钮同步更新两个字段 Co-Authored-By: Claude Opus 4.5 --- .../src/application/services/c2c.service.ts | 25 ++- .../repositories/c2c-order.repository.ts | 39 ++-- .../pages/c2c/c2c_market_page.dart | 12 ++ .../pages/trading/trading_page.dart | 177 +++++++++++++++--- 4 files changed, 208 insertions(+), 45 deletions(-) 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 3f3516fa..bb7bb1af 100644 --- a/backend/services/trading-service/src/application/services/c2c.service.ts +++ b/backend/services/trading-service/src/application/services/c2c.service.ts @@ -37,6 +37,8 @@ const C2C_PAYMENT_METHOD = { // 默认超时时间配置(分钟) const DEFAULT_PAYMENT_TIMEOUT_MINUTES = 15; const DEFAULT_CONFIRM_TIMEOUT_MINUTES = 60; +// PENDING 挂单超时时间(小时)- 超过此时间未被接单的广告自动取消并解冻资产 +const DEFAULT_PENDING_TIMEOUT_HOURS = 24; /** * C2C 场外交易服务 @@ -678,7 +680,9 @@ export class C2cService { * 处理超时订单(由定时任务调用) */ async processExpiredOrders(): Promise { - const expiredOrders = await this.c2cOrderRepository.findExpiredOrders(); + // 计算 PENDING 订单的过期截止时间 + const pendingCutoff = new Date(Date.now() - DEFAULT_PENDING_TIMEOUT_HOURS * 60 * 60 * 1000); + const expiredOrders = await this.c2cOrderRepository.findExpiredOrders(pendingCutoff); let processedCount = 0; for (const order of expiredOrders) { @@ -710,21 +714,30 @@ export class C2cService { try { // 重新获取订单,确保状态一致 const freshOrder = await this.c2cOrderRepository.findByOrderNo(order.orderNo); - if (!freshOrder || (freshOrder.status !== C2C_ORDER_STATUS.MATCHED && freshOrder.status !== C2C_ORDER_STATUS.PAID)) { + if (!freshOrder || ( + freshOrder.status !== C2C_ORDER_STATUS.PENDING && + 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); // 解冻卖方的积分值(C2C交易的是积分值,买方不冻结) - if (freshOrder.type === C2C_ORDER_TYPE.BUY) { - // BUY订单:maker(买方)未冻结,只解冻taker(卖方)的积分值 + if (freshOrder.status === C2C_ORDER_STATUS.PENDING) { + // PENDING 状态:只有 SELL 订单冻结了 maker 的积分值 + if (freshOrder.type === C2C_ORDER_TYPE.SELL) { + await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, quantityDecimal); + } + // BUY 订单 PENDING 状态没有冻结资产,直接过期即可 + } else if (freshOrder.type === C2C_ORDER_TYPE.BUY) { + // MATCHED/PAID: BUY订单解冻taker(卖方)的积分值 if (freshOrder.takerAccountSequence) { await this.tradingAccountRepository.unfreezeCash(freshOrder.takerAccountSequence, quantityDecimal); } } else { - // SELL订单:maker(卖方)冻结了积分值,taker(买方)未冻结 + // MATCHED/PAID: SELL订单解冻maker(卖方)的积分值 await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, quantityDecimal); } 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 875a552c..5fa45100 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 @@ -180,24 +180,33 @@ export class C2cOrderRepository { /** * 查询超时订单(用于定时任务) + * @param pendingCutoff 可选,PENDING 订单的过期截止时间(早于此时间的 PENDING 订单视为超时) */ - async findExpiredOrders(): Promise { + async findExpiredOrders(pendingCutoff?: Date): Promise { const now = new Date(); - const records = await this.prisma.c2cOrder.findMany({ - where: { - OR: [ - // MATCHED状态但付款超时 - { - status: C2C_ORDER_STATUS.MATCHED as any, - paymentDeadline: { lt: now }, - }, - // PAID状态但确认超时 - { - status: C2C_ORDER_STATUS.PAID as any, - confirmDeadline: { lt: now }, - }, - ], + const orConditions: any[] = [ + // MATCHED状态但付款超时 + { + status: C2C_ORDER_STATUS.MATCHED as any, + paymentDeadline: { lt: now }, }, + // PAID状态但确认超时 + { + status: C2C_ORDER_STATUS.PAID as any, + confirmDeadline: { lt: now }, + }, + ]; + + // PENDING状态超时(挂单超时未被接单,自动取消) + if (pendingCutoff) { + orConditions.push({ + status: C2C_ORDER_STATUS.PENDING as any, + createdAt: { lt: pendingCutoff }, + }); + } + + const records = await this.prisma.c2cOrder.findMany({ + where: { OR: orConditions }, }); return records.map((r: any) => this.toEntity(r)); } 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 01fe64b8..8b03f5c6 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 @@ -618,6 +618,10 @@ class _C2cMarketPageState extends ConsumerState final paymentAccountController = TextEditingController(); final paymentRealNameController = TextEditingController(); final isTakingBuyOrder = order.isBuy; // 接BUY单 = taker是卖方 + // 卖方接单时获取自己的可用积分值余额 + final user = ref.read(userNotifierProvider); + final assetAsync = ref.read(accountAssetProvider(user.accountSequence ?? '')); + final myAvailableCash = assetAsync.valueOrNull?.availableCash ?? '0'; Set selectedPaymentMethods = {'GREEN_POINTS'}; showModalBottomSheet( @@ -676,6 +680,14 @@ class _C2cMarketPageState extends ConsumerState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('可用数量', style: TextStyle(fontSize: 13, color: AppColors.textSecondaryOf(context))), + Text('${formatCompact(isTakingBuyOrder ? myAvailableCash : order.quantity)} 积分值', style: TextStyle(fontSize: 13, color: AppColors.textPrimaryOf(context))), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('订单数量', style: TextStyle(fontSize: 13, color: AppColors.textSecondaryOf(context))), Text('${formatCompact(order.quantity)} 积分值', style: TextStyle(fontSize: 13, color: AppColors.textPrimaryOf(context))), ], ), diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index 817bc0dd..7f42e065 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -37,6 +37,9 @@ class _TradingPageState extends ConsumerState { int _selectedTimeRange = 4; // 时间周期选择,默认1时 final _quantityController = TextEditingController(); final _priceController = TextEditingController(); + final _amountInputController = TextEditingController(); // 金额输入(积分值) + bool _isEditingQuantity = false; // 正在编辑数量(防止循环更新) + bool _isEditingAmount = false; // 正在编辑金额(防止循环更新) bool _isFullScreen = false; // K线图全屏状态 final List _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '日']; @@ -45,6 +48,7 @@ class _TradingPageState extends ConsumerState { void dispose() { _quantityController.dispose(); _priceController.dispose(); + _amountInputController.dispose(); super.dispose(); } @@ -641,32 +645,38 @@ class _TradingPageState extends ConsumerState { _selectedTab == 0 ? availableCash : null, currentPrice, ), + // 卖出时显示金额输入框(双向联动) + if (_selectedTab == 1) ...[ + const SizedBox(height: 16), + _buildAmountInput(tradingShareBalance, currentPrice), + ], const SizedBox(height: 16), - // 预计获得/支出 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: bgGray, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _selectedTab == 0 ? '预计支出' : '预计获得', - style: TextStyle(fontSize: 12, color: grayText), - ), - Text( - _calculateEstimate(), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: _orange, + // 买入时显示预计支出(卖出时金额输入框已替代预计获得) + if (_selectedTab == 0) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bgGray, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '预计支出', + style: TextStyle(fontSize: 12, color: grayText), ), - ), - ], + Text( + _calculateEstimate(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: _orange, + ), + ), + ], + ), ), - ), const SizedBox(height: 16), // 交易手续费说明 (卖出时显示) if (_selectedTab == 1) @@ -764,7 +774,7 @@ class _TradingPageState extends ConsumerState { border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(horizontal: 16), ), - onChanged: (_) => setState(() {}), + onChanged: _selectedTab == 1 ? _onQuantityChanged : (_) => setState(() {}), ), ), // 全部按钮 @@ -773,6 +783,8 @@ class _TradingPageState extends ConsumerState { if (availableSharesForSell != null) { // 卖出时填入全部可用积分股 controller.text = availableSharesForSell; + // 联动更新金额 + _onQuantityChanged(''); } else if (availableCashForBuy != null) { // 买入时根据可用积分值计算可买数量 final price = double.tryParse(currentPrice) ?? 0; @@ -810,6 +822,80 @@ class _TradingPageState extends ConsumerState { ); } + /// 金额输入框(卖出时显示,与数量双向联动) + Widget _buildAmountInput(String tradingShareBalance, String currentPrice) { + final grayText = AppColors.textSecondaryOf(context); + final bgGray = AppColors.backgroundOf(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '金额', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: grayText, + ), + ), + const SizedBox(height: 8), + Container( + height: 44, + decoration: BoxDecoration( + color: bgGray, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _amountInputController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + hintText: '请输入金额', + hintStyle: TextStyle( + fontSize: 14, + color: grayText, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + onChanged: _onAmountChanged, + ), + ), + // 全部按钮 + GestureDetector( + onTap: () { + // 用全部可用积分股计算最大金额 + _quantityController.text = tradingShareBalance; + _onQuantityChanged(''); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: _orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + '全部', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: _orange, + ), + ), + ), + ), + Text('积分值', style: TextStyle(fontSize: 12, color: grayText)), + const SizedBox(width: 12), + ], + ), + ), + ], + ); + } + Widget _buildInputField( String label, TextEditingController controller, @@ -871,6 +957,48 @@ class _TradingPageState extends ConsumerState { ); } + /// 获取卖出系数:(1 + burnMultiplier) × price × 0.9 + double _getSellFactor() { + final price = double.tryParse(_priceController.text) ?? 0; + final marketAsync = ref.read(marketOverviewProvider); + final burnMultiplier = double.tryParse( + marketAsync.valueOrNull?.burnMultiplier ?? '0', + ) ?? 0; + return (1 + burnMultiplier) * price * 0.9; + } + + /// 数量变化 → 联动更新金额 + void _onQuantityChanged(String _) { + if (_isEditingAmount) return; + _isEditingQuantity = true; + final quantity = double.tryParse(_quantityController.text) ?? 0; + final factor = _getSellFactor(); + if (quantity > 0 && factor > 0) { + final amount = quantity * factor; + _amountInputController.text = amount.toStringAsFixed(2); + } else { + _amountInputController.text = ''; + } + _isEditingQuantity = false; + setState(() {}); + } + + /// 金额变化 → 联动反算数量 + void _onAmountChanged(String _) { + if (_isEditingQuantity) return; + _isEditingAmount = true; + final amount = double.tryParse(_amountInputController.text) ?? 0; + final factor = _getSellFactor(); + if (amount > 0 && factor > 0) { + final quantity = amount / factor; + _quantityController.text = quantity.toStringAsFixed(4); + } else { + _quantityController.text = ''; + } + _isEditingAmount = false; + setState(() {}); + } + /// 计算预估获得/支出 /// 卖出公式:卖出交易额 = (卖出量 + 卖出销毁量) × 价格 × 0.9 /// = 卖出量 × (1 + burnMultiplier) × 价格 × 0.9 @@ -1209,6 +1337,7 @@ class _TradingPageState extends ConsumerState { ); if (success) { _quantityController.clear(); + _amountInputController.clear(); // 交易成功后立即刷新 _refreshAfterTrade(); }