package com.talentcard.server.business.portal; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.MoneyUtils; import com.talentcard.server.business.payment.PaymentReconciliationService; import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation; import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentState; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class PendingOrderPortalService { private final JdbcTemplate jdbcTemplate; private final PaymentReconciliationService paymentReconciliationService; public PendingOrderPortalService(JdbcTemplate jdbcTemplate, PaymentReconciliationService paymentReconciliationService) { this.jdbcTemplate = jdbcTemplate; this.paymentReconciliationService = paymentReconciliationService; } @Transactional public Map detail(String orderNo, Long talentId) { expireOrder(orderNo, talentId); Map order = find(orderNo, talentId); return view(order); } /** Cancels every unpaid order that has reached its payment deadline. */ @Transactional public int expireDueOrders() { return expireDueOrders(null); } /** * Cancels expired unpaid orders for one talent user. Besides the scheduled * cleanup this is used on the purchase path, so an expired order can never * continue to occupy that user's coupon while the scheduler is waiting for * its next run. * *

订单带着未定局的支付尝试时一律不自动取消:那种「待支付」只说明本地不知道钱有没有收下, * 到期时间不等于没收到钱。这些订单交给对账任务先向通道问清(已收款就落成已支付,通道说失败就 * 把流水置为已失败),下一轮清理自然会收掉它们。到期就自动取消会把已经收下的钱变成一笔没有 * 订单、也无法退款的悬账。 * *

{@code CANCEL_UNCONFIRMED} 与 {@code CLOSING} 一并排除:它们是「关不掉的预支付单」, * 通道既没说关掉也没说钱收下了,所以在通道侧仍然可能被支付。自动取消这样一笔订单,等于把已经 * 收下的钱变成没有出口的悬账。它们由关单补偿给出终止条件(重试上限后落成已取消并保留盯防)。 */ @Transactional public int expireDueOrders(Long talentId) { String sql = """ update benefit_order o set order_status = 2 where o.deleted = 0 and o.order_status = 0 and o.expires_at is not null and o.expires_at <= current_timestamp and not exists ( select 1 from payment_transaction p where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED') ) """; if (talentId == null) return jdbcTemplate.update(sql); return jdbcTemplate.update(sql + " and o.talent_id = ?", talentId); } @Transactional public Map chooseCoupon(String orderNo, PendingOrderCouponRequest request) { Map order = find(orderNo, request.talentId()); ensurePayable(order, request.priceVersion()); Integer prepayCount = jdbcTemplate.queryForObject(""" select count(*) from payment_transaction where order_no = ? and talent_id = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED', 'SUCCEEDED') """, Integer.class, orderNo, request.talentId()); if (prepayCount != null && prepayCount > 0) { throw new BusinessException(ErrorCode.CONFLICT, "支付已发起,不能再修改优惠券"); } Long merchantId = longValue(order.get("merchantId")); Long productId = longValue(order.get("productId")); if (merchantId == null || productId == null) throw new BusinessException(ErrorCode.CONFLICT, "该订单暂不支持选择优惠券"); BigDecimal basePayAmount = yuan(order.get("basePayAmount")); String cardLevel = activeCardLevel(request.talentId()); List> coupons = query(""" select id, coupon_amount from merchant_coupon c where c.id = ? and c.deleted = 0 and c.status = 1 and c.merchant_id = ? and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date) and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?) and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0) and (c.product_scope = 'ALL' or exists (select 1 from merchant_coupon_product cp where cp.coupon_id = c.id and cp.product_id = ?)) and not exists ( select 1 from benefit_order o where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0 and o.order_no <> ? and (o.expires_at is null or o.expires_at > current_timestamp) ) """, request.couponId(), merchantId, basePayAmount, cardLevel, productId, request.talentId(), orderNo); if (coupons.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该优惠券当前不可用,请重新选择"); long couponAmount = Math.min(MoneyUtils.yuanToCent((BigDecimal) coupons.get(0).get("couponAmount")), longNumber(order.get("basePayAmount"))); long payAmount = Math.max(0, longNumber(order.get("basePayAmount")) - couponAmount); long subsidyAmount = Math.max(0, resolveOriginalAmount(order, couponAmount, payAmount) - payAmount); jdbcTemplate.update(""" update benefit_order set coupon_id = ?, coupon_amount = ?, pay_amount = ?, subsidy_amount = ?, price_version = price_version + 1 where order_no = ? and talent_id = ? and order_status = 0 and price_version = ? """, request.couponId(), couponAmount, payAmount, subsidyAmount, orderNo, request.talentId(), request.priceVersion()); return detail(orderNo, request.talentId()); } @Transactional(readOnly = true) public List> coupons(String orderNo, Long talentId) { Map order = find(orderNo, talentId); Long merchantId = longValue(order.get("merchantId")); Long productId = longValue(order.get("productId")); if (merchantId == null || productId == null) return List.of(); BigDecimal basePayAmount = yuan(order.get("basePayAmount")); String cardLevel = activeCardLevel(talentId); return query(""" select c.id, c.coupon_name as name, c.coupon_amount as amount, case when c.condition_type = 'NONE' or c.condition_amount is null then '无门槛使用' else concat('满', c.condition_amount, '元可用') end as scope, c.valid_end as expire_time, 1 as available from merchant_coupon c where c.deleted = 0 and c.status = 1 and c.merchant_id = ? and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date) and (c.condition_type = 'NONE' or c.condition_amount is null or c.condition_amount <= ?) and (coalesce(c.talent_levels, '') = '' or find_in_set(?, replace(c.talent_levels, '、', ',')) > 0) and (c.product_scope = 'ALL' or exists (select 1 from merchant_coupon_product cp where cp.coupon_id = c.id and cp.product_id = ?)) and not exists ( select 1 from benefit_order o where o.coupon_id = c.id and o.talent_id = ? and o.deleted = 0 and o.order_status = 0 and o.order_no <> ? and (o.expires_at is null or o.expires_at > current_timestamp) ) order by c.coupon_amount desc, c.id desc """, merchantId, basePayAmount, cardLevel, productId, talentId, orderNo); } @Transactional public Map pay(String orderNo, PendingOrderPaymentRequest request) { Map order = find(orderNo, request.talentId()); ensurePayable(order, request.priceVersion()); int updated = jdbcTemplate.update(""" update benefit_order set order_status = 1, verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end, price_version = price_version + 1 where order_no = ? and talent_id = ? and order_status = 0 and price_version = ? and (expires_at is null or expires_at > current_timestamp) """, orderNo, request.talentId(), request.priceVersion()); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "订单状态或金额已变化,请重新确认"); Map result = detail(orderNo, request.talentId()); result.put("paymentStatus", "PAID"); return result; } private void ensurePayable(Map order, int priceVersion) { if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可支付"); if (longNumber(order.get("priceVersion")) != priceVersion) throw new BusinessException(ErrorCode.CONFLICT, "订单金额已更新,请重新确认"); Object expiresAt = order.get("expiresAt"); if (expiresAt instanceof LocalDateTime time && !time.isAfter(LocalDateTime.now())) throw new BusinessException(ErrorCode.CONFLICT, "订单已超时,请重新下单"); } private Map find(String orderNo, Long talentId) { List> rows = query(""" select o.order_no, o.talent_id, o.merchant_id, o.order_type, o.product_id, o.order_amount, o.subsidy_amount, o.base_pay_amount, o.pay_amount, o.coupon_amount, o.coupon_id, o.order_status, o.verify_status, o.expires_at, o.price_version, o.merchant_name_snapshot, o.merchant_address_snapshot, o.merchant_mobile_snapshot, o.merchant_longitude_snapshot, o.merchant_latitude_snapshot, o.product_name_snapshot, c.coupon_name, c.coupon_amount as configured_coupon_amount, p.original_price as product_original_price from benefit_order o left join merchant_coupon c on c.id = o.coupon_id left join merchant_product p on p.id = o.product_id and p.deleted = 0 where o.deleted = 0 and o.order_no = ? and o.talent_id = ? """, orderNo, talentId); if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在或已删除"); return rows.get(0); } private Map view(Map order) { Map result = new LinkedHashMap<>(order); long orderStatus = longNumber(order.get("orderStatus")); boolean expired = orderStatus == 0 && order.get("expiresAt") instanceof LocalDateTime time && !time.isAfter(LocalDateTime.now()); result.put("expired", expired); result.put("canPay", orderStatus == 0 && !expired); boolean couponSelectable = longValue(order.get("productId")) != null && !"GROUP_BUY".equals(String.valueOf(order.get("orderType"))) && orderStatus == 0 && !expired; result.put("couponSelectable", couponSelectable); long couponAmount = longNumber(order.get("couponAmount")); if (couponAmount <= 0 && order.get("couponId") != null) { couponAmount = Math.min( MoneyUtils.yuanToCent(decimal(order.get("configuredCouponAmount"))), longNumber(order.get("basePayAmount"))); } result.put("couponLabel", order.get("couponName") == null ? "无" : "减" + yuan(couponAmount).stripTrailingZeros().toPlainString() + "元"); result.put("talentDiscountText", talentDiscountText(order)); long originalAmount = resolveOriginalAmount(order, couponAmount, longNumber(order.get("payAmount"))); result.put("originalAmount", yuan(originalAmount)); // “优惠金额”在待支付页仅表示优惠券抵扣,不应把人才折扣或补贴混入其中。 result.put("discountAmount", yuan(couponAmount)); result.put("payAmount", yuan(order.get("payAmount"))); // 待支付页的补贴金额展示人才折扣与优惠券抵扣合计,即原价减去用户实付。 result.put("subsidyAmount", yuan(Math.max(0, originalAmount - longNumber(order.get("payAmount"))))); return result; } private long resolveOriginalAmount(Map order, long couponAmount, long payAmount) { long originalAmount = longNumber(order.get("orderAmount")); if (originalAmount > 0) return originalAmount; BigDecimal productOriginalPrice = decimal(order.get("productOriginalPrice")); if (productOriginalPrice.signum() > 0) return MoneyUtils.yuanToCent(productOriginalPrice); long basePayAmount = longNumber(order.get("basePayAmount")); if (basePayAmount > 0) return basePayAmount; // Historical orders may have missed both price snapshots. Their selected // coupon and payable amount still represent the amount before coupon use. return Math.max(0, payAmount + couponAmount); } private List> query(String sql, Object... args) { return jdbcTemplate.queryForList(sql, args).stream().map(DatabaseRows::normalize).toList(); } private BigDecimal decimal(Object value) { if (value == null) return BigDecimal.ZERO; if (value instanceof BigDecimal decimal) return decimal; try { return new BigDecimal(String.valueOf(value)); } catch (Exception ignored) { return BigDecimal.ZERO; } } private String activeCardLevel(Long talentId) { List> cards = query("select card_level from talent_card where talent_id=? and deleted=0 and status=1 and card_type='PERSONAL' and (valid_end is null or valid_end >= current_date) order by id desc limit 1", talentId); if (cards.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "当前暂无有效人才卡"); return String.valueOf(cards.get(0).get("cardLevel")); } @Transactional public Map cancel(String orderNo, Long talentId) { Map order = find(orderNo, talentId); if (longNumber(order.get("orderStatus")) == 2) return view(order); if (longNumber(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消"); // 回调没到达时「待支付」只说明本地不知道,钱可能已经被通道收下。取消是不可逆的,所以先问通道: // 已经收下的钱必须落成已支付(人才端才有退款入口);通道说没收款的预支付单还要在通道侧关掉, // 否则用户手上的预支付单仍然能付进来。这一问必须排在本地写状态之前——它在自己的事务里, // 本方法一旦先加锁,它就会等自己的锁。 PendingPaymentConfirmation confirmation = paymentReconciliationService.closePendingPayments(orderNo); if (confirmation.collected()) throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消"); if (confirmation.state() == PendingPaymentState.UNCONFIRMED) { // 关单没有结论(通道答「处理中」或干脆没答上来):这次「支付结果不确定」只属于它所属的 // 那一次支付尝试,不该把整笔订单停摆成一个永久报错。取消的意图先被接受并记录,订单本身 // 不动——后台每分钟重试关单,有关闭或已收款的结论时再落地(关掉了就取消,钱收下了就 // 翻成已支付并告知用户)。页面按「取消处理中」渲染。 return cancellationPending(orderNo, talentId, confirmation.paymentNo()); } // 过了支付时限的订单在这里结束:条件写在 SQL 里,跨实例只有一个赢家。 expireOrder(orderNo, talentId); order = find(orderNo, talentId); if (longNumber(order.get("orderStatus")) == 2) return view(order); // 走到这里,通道侧每笔尝试都已经有了结论(关掉、或明确没收过钱)。这里只剩下取消与确认之间 // 新起的支付尝试:它们没经过通道确认,所以不能记成已关闭,而是留成待对账状态——预支付单可能还 // 能被支付,对账任务会继续盯着并留下问题记录。 jdbcTemplate.update(""" update payment_transaction set status = 'CANCEL_UNCONFIRMED', closed_at = coalesce(closed_at, now()), provider_response_code = 'LOCAL_CANCELLED', provider_response_message = 'Cancelled by talent user' where order_no = ? and talent_id = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED') """, orderNo, talentId); Integer succeededPaymentCount = jdbcTemplate.queryForObject(""" select count(*) from payment_transaction where order_no = ? and talent_id = ? and status = 'SUCCEEDED' """, Integer.class, orderNo, talentId); if (succeededPaymentCount != null && succeededPaymentCount > 0) { throw new BusinessException(ErrorCode.CONFLICT, "订单已支付,不能取消"); } int updated = jdbcTemplate.update(""" update benefit_order set order_status = 2 where order_no = ? and talent_id = ? and deleted = 0 and order_status = 0 """, orderNo, talentId); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可取消"); return view(find(orderNo, talentId)); } /** 取消已受理、等通道关单结论:订单仍是待支付,人才端按「取消处理中」渲染。 */ private Map cancellationPending(String orderNo, Long talentId, String paymentNo) { Map result = view(find(orderNo, talentId)); result.put("cancellationPending", true); result.put("paymentNo", paymentNo); result.put("paymentStatus", "PAYMENT_PROCESSING"); return result; } /** 单个订单的到期结束:条件写在 SQL 里,跨实例只有一个赢家。未定局的支付尝试照上面同样的理由排除。 */ private void expireOrder(String orderNo, Long talentId) { jdbcTemplate.update(""" update benefit_order o set order_status = 2 where o.order_no = ? and o.talent_id = ? and o.deleted = 0 and o.order_status = 0 and o.expires_at is not null and o.expires_at <= current_timestamp and not exists ( select 1 from payment_transaction p where p.order_id = o.id and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED') ) """, orderNo, talentId); } private long longNumber(Object value) { return value instanceof Number number ? number.longValue() : 0; } private Long longValue(Object value) { return value instanceof Number number ? number.longValue() : null; } private BigDecimal yuan(Object cent) { return BigDecimal.valueOf(longNumber(cent), 2); } private String talentDiscountText(Map order) { long originalAmount = longNumber(order.get("orderAmount")); long basePayAmount = longNumber(order.get("basePayAmount")); if (originalAmount <= 0 || basePayAmount >= originalAmount) return "无折扣"; if (basePayAmount <= 0) return "免费"; return BigDecimal.valueOf(basePayAmount).multiply(BigDecimal.TEN) .divide(BigDecimal.valueOf(originalAmount), 1, java.math.RoundingMode.HALF_UP) .stripTrailingZeros().toPlainString() + "折"; } }