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:
parent
83c29f8540
commit
b8f8831516
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue