| | |
| | | } |
| | | |
| | | public Map<String, Object> beginMiniProgramPayment(String orderNo, Long talentId, int priceVersion, String idempotencyKey) { |
| | | // 上一次支付可能已经被通道收下、回调却没到。那笔钱必须先落成本地已支付:否则这里会再发起一次 |
| | | // 支付(同一笔订单付第二次),或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。 |
| | | // 复用优先:本地已经持有这张订单的预支付单的支付参数、订单又还待支付时,直接把它交回客户端。 |
| | | // 这里刻意**不查通道**:查通道只会得到「处理中」这种没有结论的答复,而本地已有可用凭证—— |
| | | // 拿这个没有结论的答复把整笔订单停摆,正是用户报的死锁(见 docs/adr/0003 的 D3)。 |
| | | Map<String, Object> reusable = reusablePrepay(orderNo, talentId); |
| | | if (reusable != null) { |
| | | return pendingResult(string(reusable.get("paymentNo")), string(reusable.get("clientPaymentPayload"))); |
| | | } |
| | | |
| | | // 本地没有可复用的凭证时,通道是唯一知道钱有没有收下的地方。上一次支付可能已经被通道收下、 |
| | | // 回调却没到:那笔钱必须先落成本地已支付,否则这里会再发起一次支付(同一笔订单付第二次), |
| | | // 或者把用户送进「订单已经支付,请勿重复发起支付」的死胡同。 |
| | | PendingPaymentConfirmation confirmation = reconciliationService.confirmPendingPayments(orderNo); |
| | | if (confirmation.collected()) return completedResult(confirmation.paymentNo()); |
| | | if (confirmation.state() == PendingPaymentState.UNCONFIRMED) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "支付结果确认中,请稍后再试"); |
| | | } |
| | | // 通道答不出「钱收没收下」时不再抛错:那个不确定只属于这一次支付尝试,不该把用户堵在一句 |
| | | // 看不懂的报错上。返回可等待状态,页面渲染成「支付处理中,请稍候」。 |
| | | if (confirmation.state() == PendingPaymentState.UNCONFIRMED) return processingResult(confirmation.paymentNo()); |
| | | |
| | | PreparedPayment prepared = transactions.execute(status -> prepare(orderNo, talentId, priceVersion, idempotencyKey)); |
| | | if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "支付初始化失败"); |
| | | if (prepared.completed()) return completedResult(prepared.paymentNo()); |
| | |
| | | String clientPayload = writeJson(clientPayload(response)); |
| | | transactions.executeWithoutResult(status -> markPrepayCreated(prepared.paymentNo(), responseCode(response), responseText(response, ""), clientPayload)); |
| | | return pendingResult(prepared.paymentNo(), clientPayload); |
| | | } |
| | | |
| | | /** |
| | | * 本地是否还持有这张订单的预支付单的支付参数。 |
| | | * |
| | | * <p>四个条件缺一不可:订单仍待支付且未过期、流水属于「本地尚未定局」的状态、支付参数还在本地、 |
| | | * 流水金额与订单当前实付一致。金额必须相等是因为预支付单是按金额建的:改了优惠券之后再拿旧单 |
| | | * 去收款会收错钱。流水上没有记 {@code price_version},金额相等是这里能拿到的最强判据。 |
| | | * |
| | | * <p>{@code CLOSING}(关单中)与 {@code CANCEL_UNCONFIRMED}(关不掉的预支付单)同样在可复用之列: |
| | | * 它们正是「通道侧这张单还可能被支付」的状态,此时新建一张预支付单等于同一笔订单可能被付两次 |
| | | * (D2),所以只能复用。 |
| | | */ |
| | | private Map<String, Object> reusablePrepay(String orderNo, Long talentId) { |
| | | List<Map<String, Object>> rows = rows(""" |
| | | select p.payment_no, p.client_payment_payload |
| | | from payment_transaction p |
| | | join benefit_order o on o.id = p.order_id |
| | | where p.provider = ? and p.order_no = ? and o.talent_id = ? |
| | | and o.deleted = 0 and o.order_status = 0 |
| | | and (o.expires_at is null or o.expires_at > now()) |
| | | and p.status in ('PREPAY_CREATED', 'CLOSING', 'CANCEL_UNCONFIRMED') |
| | | and coalesce(p.client_payment_payload, '') <> '' |
| | | and p.amount = o.pay_amount |
| | | order by p.id desc |
| | | limit 1 |
| | | """, PROVIDER, orderNo, talentId); |
| | | return rows.isEmpty() ? null : rows.get(0); |
| | | } |
| | | |
| | | /** |
| | | * 这张订单上是否还有一张**可能被支付**的预支付单。 |
| | | * |
| | | * <p>新建预支付单的前置不变量:上一张必须已经被证明不可能再被支付(已关闭,或通道明确说失败/ |
| | | * 交易不存在)。{@code CLOSING} 与 {@code CANCEL_UNCONFIRMED} 恰恰是「关不掉的预支付单」——通道 |
| | | * 既没说关掉、也没说钱收下了,它照样能被付进来。此时新建等于给同一笔订单留下两张可被支付的 |
| | | * 预支付单,通道确认两笔都成功时没有多收款退回流程(审计 §9 已复现过两次预授权)。 |
| | | */ |
| | | private String blockingAttempt(long orderId) { |
| | | List<Map<String, Object>> rows = rows(""" |
| | | select payment_no from payment_transaction |
| | | where order_id = ? and provider = ? |
| | | and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED') |
| | | order by id desc |
| | | limit 1 |
| | | """, orderId, PROVIDER); |
| | | return rows.isEmpty() ? "" : string(rows.get(0).get("paymentNo")); |
| | | } |
| | | |
| | | private PreparedPayment prepare(String orderNo, Long talentId, int priceVersion, String idempotencyKey) { |
| | |
| | | String paymentNo = string(existing.get("paymentNo")); |
| | | if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false, false); |
| | | if ("PREPAY_CREATED".equals(status)) { |
| | | // 支付参数没留在本地就没法把它复用给客户端,而这张预支付单可能已经可以被支付: |
| | | // 既不新建也不报错,交给对账通道确认后再给结论。 |
| | | if (string(existing.get("clientPaymentPayload")).isBlank()) { |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false, false); |
| | | } |
| | | if ("CLOSING".equals(status) || "CANCEL_UNCONFIRMED".equals(status)) { |
| | | // 关单中/关不掉的预支付单:通道还可能为它收钱,绝不能再建一张。 |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) { |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | String blocking = blockingAttempt(number(order.get("id"))); |
| | | if (!blocking.isBlank()) { |
| | | return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | int nextAttempt = (int) number(existing.get("attemptNo")) + 1; |
| | | String retryPaymentNo = BusinessIdGenerator.next("P"); |
| | |
| | | return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order)); |
| | | } |
| | | |
| | | String blocking = blockingAttempt(number(order.get("id"))); |
| | | if (!blocking.isBlank()) { |
| | | return new PreparedPayment(blocking, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | String paymentNo = BusinessIdGenerator.next("P"); |
| | | insertPaymentTransaction(paymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, 1); |
| | | return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order)); |
| | |
| | | case "CLOSED", "CANCEL_UNCONFIRMED" -> "CANCELLED"; |
| | | case "CREATE_FAILED" -> "PAYMENT_CREATE_FAILED"; |
| | | case "CREATED", "PREPAY_CREATED" -> "PAYMENT_PENDING"; |
| | | case "CREATE_UNKNOWN" -> "PAYMENT_CONFIRMING"; |
| | | // 关单中与创建结果未定都是「还在确认」:客户端渲染成可等待的文案,而不是一个报错。 |
| | | case "CLOSING", "CREATE_UNKNOWN" -> "PAYMENT_PROCESSING"; |
| | | default -> "UNKNOWN"; |
| | | }; |
| | | } |