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_PAYMENT_TIMEOUT_MINUTES = 15;
|
||||||
const DEFAULT_CONFIRM_TIMEOUT_MINUTES = 60;
|
const DEFAULT_CONFIRM_TIMEOUT_MINUTES = 60;
|
||||||
|
// PENDING 挂单超时时间(小时)- 超过此时间未被接单的广告自动取消并解冻资产
|
||||||
|
const DEFAULT_PENDING_TIMEOUT_HOURS = 24;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C2C 场外交易服务
|
* C2C 场外交易服务
|
||||||
|
|
@ -678,7 +680,9 @@ export class C2cService {
|
||||||
* 处理超时订单(由定时任务调用)
|
* 处理超时订单(由定时任务调用)
|
||||||
*/
|
*/
|
||||||
async processExpiredOrders(): Promise<number> {
|
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;
|
let processedCount = 0;
|
||||||
|
|
||||||
for (const order of expiredOrders) {
|
for (const order of expiredOrders) {
|
||||||
|
|
@ -710,21 +714,30 @@ export class C2cService {
|
||||||
try {
|
try {
|
||||||
// 重新获取订单,确保状态一致
|
// 重新获取订单,确保状态一致
|
||||||
const freshOrder = await this.c2cOrderRepository.findByOrderNo(order.orderNo);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quantityDecimal = new Decimal(freshOrder.quantity);
|
const quantityDecimal = new Decimal(freshOrder.quantity);
|
||||||
const totalAmountDecimal = new Decimal(freshOrder.totalAmount);
|
|
||||||
|
|
||||||
// 解冻卖方的积分值(C2C交易的是积分值,买方不冻结)
|
// 解冻卖方的积分值(C2C交易的是积分值,买方不冻结)
|
||||||
if (freshOrder.type === C2C_ORDER_TYPE.BUY) {
|
if (freshOrder.status === C2C_ORDER_STATUS.PENDING) {
|
||||||
// BUY订单:maker(买方)未冻结,只解冻taker(卖方)的积分值
|
// 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) {
|
if (freshOrder.takerAccountSequence) {
|
||||||
await this.tradingAccountRepository.unfreezeCash(freshOrder.takerAccountSequence, quantityDecimal);
|
await this.tradingAccountRepository.unfreezeCash(freshOrder.takerAccountSequence, quantityDecimal);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// SELL订单:maker(卖方)冻结了积分值,taker(买方)未冻结
|
// MATCHED/PAID: SELL订单解冻maker(卖方)的积分值
|
||||||
await this.tradingAccountRepository.unfreezeCash(freshOrder.makerAccountSequence, quantityDecimal);
|
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 now = new Date();
|
||||||
const records = await this.prisma.c2cOrder.findMany({
|
const orConditions: any[] = [
|
||||||
where: {
|
// MATCHED状态但付款超时
|
||||||
OR: [
|
{
|
||||||
// MATCHED状态但付款超时
|
status: C2C_ORDER_STATUS.MATCHED as any,
|
||||||
{
|
paymentDeadline: { lt: now },
|
||||||
status: C2C_ORDER_STATUS.MATCHED as any,
|
|
||||||
paymentDeadline: { lt: now },
|
|
||||||
},
|
|
||||||
// PAID状态但确认超时
|
|
||||||
{
|
|
||||||
status: C2C_ORDER_STATUS.PAID as any,
|
|
||||||
confirmDeadline: { 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));
|
return records.map((r: any) => this.toEntity(r));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -618,6 +618,10 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
|
||||||
final paymentAccountController = TextEditingController();
|
final paymentAccountController = TextEditingController();
|
||||||
final paymentRealNameController = TextEditingController();
|
final paymentRealNameController = TextEditingController();
|
||||||
final isTakingBuyOrder = order.isBuy; // 接BUY单 = taker是卖方
|
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'};
|
Set<String> selectedPaymentMethods = {'GREEN_POINTS'};
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
|
|
@ -676,6 +680,14 @@ class _C2cMarketPageState extends ConsumerState<C2cMarketPage>
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('可用数量', style: TextStyle(fontSize: 13, color: AppColors.textSecondaryOf(context))),
|
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))),
|
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时
|
int _selectedTimeRange = 4; // 时间周期选择,默认1时
|
||||||
final _quantityController = TextEditingController();
|
final _quantityController = TextEditingController();
|
||||||
final _priceController = TextEditingController();
|
final _priceController = TextEditingController();
|
||||||
|
final _amountInputController = TextEditingController(); // 金额输入(积分值)
|
||||||
|
bool _isEditingQuantity = false; // 正在编辑数量(防止循环更新)
|
||||||
|
bool _isEditingAmount = false; // 正在编辑金额(防止循环更新)
|
||||||
bool _isFullScreen = false; // K线图全屏状态
|
bool _isFullScreen = false; // K线图全屏状态
|
||||||
|
|
||||||
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '日'];
|
final List<String> _timeRanges = ['1分', '5分', '15分', '30分', '1时', '4时', '日'];
|
||||||
|
|
@ -45,6 +48,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_quantityController.dispose();
|
_quantityController.dispose();
|
||||||
_priceController.dispose();
|
_priceController.dispose();
|
||||||
|
_amountInputController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -641,32 +645,38 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
_selectedTab == 0 ? availableCash : null,
|
_selectedTab == 0 ? availableCash : null,
|
||||||
currentPrice,
|
currentPrice,
|
||||||
),
|
),
|
||||||
|
// 卖出时显示金额输入框(双向联动)
|
||||||
|
if (_selectedTab == 1) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAmountInput(tradingShareBalance, currentPrice),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 预计获得/支出
|
// 买入时显示预计支出(卖出时金额输入框已替代预计获得)
|
||||||
Container(
|
if (_selectedTab == 0)
|
||||||
padding: const EdgeInsets.all(12),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(12),
|
||||||
color: bgGray,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: bgGray,
|
||||||
),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(
|
children: [
|
||||||
_selectedTab == 0 ? '预计支出' : '预计获得',
|
Text(
|
||||||
style: TextStyle(fontSize: 12, color: grayText),
|
'预计支出',
|
||||||
),
|
style: TextStyle(fontSize: 12, color: grayText),
|
||||||
Text(
|
|
||||||
_calculateEstimate(),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _orange,
|
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
],
|
_calculateEstimate(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 交易手续费说明 (卖出时显示)
|
// 交易手续费说明 (卖出时显示)
|
||||||
if (_selectedTab == 1)
|
if (_selectedTab == 1)
|
||||||
|
|
@ -764,7 +774,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
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) {
|
if (availableSharesForSell != null) {
|
||||||
// 卖出时填入全部可用积分股
|
// 卖出时填入全部可用积分股
|
||||||
controller.text = availableSharesForSell;
|
controller.text = availableSharesForSell;
|
||||||
|
// 联动更新金额
|
||||||
|
_onQuantityChanged('');
|
||||||
} else if (availableCashForBuy != null) {
|
} else if (availableCashForBuy != null) {
|
||||||
// 买入时根据可用积分值计算可买数量
|
// 买入时根据可用积分值计算可买数量
|
||||||
final price = double.tryParse(currentPrice) ?? 0;
|
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(
|
Widget _buildInputField(
|
||||||
String label,
|
String label,
|
||||||
TextEditingController controller,
|
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
|
/// 卖出公式:卖出交易额 = (卖出量 + 卖出销毁量) × 价格 × 0.9
|
||||||
/// = 卖出量 × (1 + burnMultiplier) × 价格 × 0.9
|
/// = 卖出量 × (1 + burnMultiplier) × 价格 × 0.9
|
||||||
|
|
@ -1209,6 +1337,7 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
);
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
_quantityController.clear();
|
_quantityController.clear();
|
||||||
|
_amountInputController.clear();
|
||||||
// 交易成功后立即刷新
|
// 交易成功后立即刷新
|
||||||
_refreshAfterTrade();
|
_refreshAfterTrade();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue