feat: PENDING订单自动过期、接单显示余额、积分股金额输入

1. C2C PENDING订单24小时自动过期并解冻冻结资产
   - 新增 DEFAULT_PENDING_TIMEOUT_HOURS 常量
   - findExpiredOrders 支持 PENDING 状态查询
   - expireOrder 处理 PENDING 卖单解冻

2. C2C接单对话框显示卖方可用余额
   - 确认出售时显示用户账户可用积分值(非订单数量)
   - 新增订单数量行,方便对比

3. 积分股兑换页面新增金额输入
   - 卖出时显示金额输入框(积分值),与数量双向联动
   - 输入数量自动计算金额,输入金额自动反算数量
   - 全部按钮同步更新两个字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-31 22:15:47 -08:00
parent 83c29f8540
commit b8f8831516
4 changed files with 208 additions and 45 deletions

View File

@ -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<number> {
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);
}

View File

@ -180,24 +180,33 @@ export class C2cOrderRepository {
/**
*
* @param pendingCutoff PENDING PENDING
*/
async findExpiredOrders(): Promise<C2cOrderEntity[]> {
async findExpiredOrders(pendingCutoff?: Date): 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 },
},
],
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));
}

View File

@ -618,6 +618,10 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
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<String> selectedPaymentMethods = {'GREEN_POINTS'};
showModalBottomSheet(
@ -676,6 +680,14 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
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))),
],
),

View File

@ -37,6 +37,9 @@ class _TradingPageState extends ConsumerState<TradingPage> {
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<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', ''];
@ -45,6 +48,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
void dispose() {
_quantityController.dispose();
_priceController.dispose();
_amountInputController.dispose();
super.dispose();
}
@ -641,32 +645,38 @@ class _TradingPageState extends ConsumerState<TradingPage> {
_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<TradingPage> {
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: (_) => setState(() {}),
onChanged: _selectedTab == 1 ? _onQuantityChanged : (_) => setState(() {}),
),
),
//
@ -773,6 +783,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
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<TradingPage> {
);
}
///
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<TradingPage> {
);
}
/// (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<TradingPage> {
);
if (success) {
_quantityController.clear();
_amountInputController.clear();
//
_refreshAfterTrade();
}