feat(order): 核销幂等与退款边界按订单串行化
新增 OrderFundBoundaryService 统一判定并写入订单资金状态:核销、退款和提现锁定都先锁 benefit_order 同一行,因此按订单串行化。四条核销路径(商户订单核销、扫码确认、后台核销、酒店订单核销码)改用 requireVerifiable 守卫,写回改成带 verify_status = 0 条件的更新并校验影响行数;verify_record 增加生成列 active_order_id 与唯一索引,让同一订单只有一条有效核销记录成为数据库层事实。
benefit_order 新增 refund_status / withdrawal_status:退款受理成功即不可核销、不参与提现,本地响应、主动查询和异步回调三条链路统一收敛;回调宣告失败只在本事务真的把该笔退款置失败时归还订单,晚到的失败通知不会重开已成功退款的订单。lockIntoWithdrawal 作为工单 04 日汇总的登记入口,锁定后不能撤销核销或退款,商户端 canRevoke 同步为 false。
顺带修正 revokeVerification 幂等键归属的 Long 引用比较:订单 ID 超过 Long 缓存区间后,重复撤销会被误判为幂等键冲突。
测试:新增 OrderFundBoundaryTests 15 项(含并发核销、核销与退款竞态、退款回调收敛、过期订单),H2 核销用例补退款/提现锁列并放进真实事务;全量 142 项仅剩 3 项既有基线失败。
| | |
| | | |
| | | **Blocked by:** 02 — 支付订单与支付回调闭环 |
| | | |
| | | **Status:** ready-for-agent |
| | | **Status:** ready-for-human |
| | | |
| | | - [ ] 未支付订单不能核销。 |
| | | - [ ] 同一订单重复核销只产生一次有效核销结果。 |
| | | - [ ] 已支付未核销订单可退款,退款后不得核销或提现。 |
| | | - [ ] 核销、退款和提现锁定按订单串行化。 |
| | | - [ ] 订单进入提现记录后不能撤销核销。 |
| | | - [x] 未支付订单不能核销。 |
| | | - [x] 同一订单重复核销只产生一次有效核销结果。 |
| | | - [x] 已支付未核销订单可退款,退款后不得核销或提现。 |
| | | - [x] 核销、退款和提现锁定按订单串行化。 |
| | | - [x] 订单进入提现记录后不能撤销核销。 |
| | | |
| | | ## Comments |
| | | |
| | | 已实现,主要落点: |
| | | |
| | | - 新增 `OrderFundBoundaryService`:核销、退款、提现锁定共用的唯一资金边界。`lock(orderId)` 用 |
| | | `select ... for update` 锁 `benefit_order` 行并返回 `LockedOrder`(支付、核销、退款、提现锁定四个状态), |
| | | 带 `requireVerifiable / requireRefundable / requireRevocable` 三个断言;`markRefundInProgress / |
| | | markRefunded / markRefundFailed / lockIntoWithdrawal` 是这四个状态的唯一写入口。`lock` 标注 |
| | | `@Transactional(MANDATORY)`:脱离事务直接失败,而不是静默失去串行化能力(其余方法是单条条件更新, |
| | | 本身原子)。 |
| | | - 四条核销路径全部接入守卫:`MerchantWorkService.verify`(商户订单核销,并把幂等键复查移到行锁之后, |
| | | 同键并发返回首次结果)、`MerchantVerificationService.confirmOrderCode`(扫码确认)、`OrderService.verify` |
| | | (后台核销)、`BenefitVerificationService.verifyHotelOrderCode`(酒店订单核销码)。写回统一改成 |
| | | `update benefit_order set verify_status = 1 where id = ? and verify_status = 0` 并校验影响行数, |
| | | 条件更新失败即 409。 |
| | | - 核销幂等:`verify_record` 增加生成列 `active_order_id`(`status = 1` 时等于 `order_id`,否则 NULL) |
| | | 与唯一索引 `uk_verify_record_active_order`,同一订单在数据库层不可能有两条有效核销记录;迁移先把 |
| | | 历史上同一订单的多条有效记录标记为已撤销(保留最早一条,撤销后仍可重新核销)。 |
| | | - 退款边界:`HuifuRefundService.prepare` 先 `lock + requireRefundable`(未支付、已退款、退款中、已进入 |
| | | 提现记录的整单拒退,原有「已核销不可退 + STORE_CONSUME 例外」保留),受理成功即置 `refund_status = 1`, |
| | | 查补成功置 2,失败回到 0。退款在途期间订单不可核销、不参与提现汇总;部分退款按人工确认的策略同样 |
| | | 让整单退出可核销与可提现范围。 |
| | | - 退款结果的三条链路都收敛到订单级状态:本地同步响应、`HuifuRefundService.query` 主动查询,以及 |
| | | `HuifuPaymentCallbackService.handleRefund` 异步回调。回调成功无条件把订单置为已退款;回调宣告失败 |
| | | 只在本事务真的把该笔退款置为失败时才把订单退回未退款状态,因此晚到的失败通知不会重新打开一笔已成功 |
| | | 退款的订单。 |
| | | - 提现锁定:`lockIntoWithdrawal(orderId)` 只接受「已支付 + 已核销 + 未退款 + 未进入其他提现记录」的订单, |
| | | 这是工单 04 每日汇总登记订单的唯一入口(04 尚未实现,本工单用真实方法验证了「锁定后不能撤销核销/不能 |
| | | 退款/不能重复汇总」)。 |
| | | - 撤销核销:`MerchantWorkService.revokeVerification` 在业务类型与 3 小时时限检查前先 `requireRevocable`; |
| | | 订单详情新增 `withdrawal_status`,锁定后 `canRevoke = false`、`revokeReason = 订单已进入提现记录`。 |
| | | - 现有商户端与后台提现页的可提金额同时排除 `refund_status <> 0` 与 `withdrawal_status = 1` 的订单 |
| | | (`MerchantSettlementService.withdrawalSummary`、`MerchantSettlementAdminService`)。 |
| | | - 迁移 `V132__order_verification_refund_boundary.sql`:新增 `benefit_order.refund_status / withdrawal_status`, |
| | | 从 `order_refund` 回填历史退款(成功优先于在途),去重历史重复核销记录并建立上述唯一索引。 |
| | | - 顺带修掉一个既有缺陷:`revokeVerification` 的幂等键归属用两个 `Long` 的 `!=` 比较,只在 -128..127 的 |
| | | Long 缓存区间内碰巧正确;订单 ID 超过该区间后重复撤销会被误判为「幂等键已用于其他订单」(本次全量测试 |
| | | 在 id=194 的订单上踩到),改为按值比较。 |
| | | - 文档:`talent-card-server/docs/huifu-payment-integration.md` 新增 "Order fund boundary" 一节,写明四个状态列、 |
| | | 三条规则、迁移内容和 04 必须调用的锁定入口。 |
| | | |
| | | 测试:新增 `OrderFundBoundaryTests` 15 项(MySQL),覆盖未支付不可核销/不可退款/不可锁定提现、过期取消 |
| | | 订单同样三项都被拒、两个线程并发核销只产生一条有效记录、同幂等键重复核销返回首次结果、数据库层唯一约束、 |
| | | 退款在途阻止核销、部分退款整单排除、退款失败回到可核销、已核销不可退款、已进入提现记录不可撤销核销/ |
| | | 不可退款/不可重复汇总且 `canRevoke = false`、核销与退款并发只能一方成功、退款回调成功/失败/晚到失败 |
| | | 三种收敛。H2 用例 `MerchantVerificationOrderCodeTests` 补上退款与提现锁定列、把 confirm 放进真实事务 |
| | | (行锁需要事务)并新增「退款后不能核销」;`MerchantVerificationFreeStayTests` 补构造参数。全量 142 项测试 |
| | | 仅剩 `docs/agents/testing.md` 记录的 3 项基线失败。 |
| | | |
| | | 遗留(不在本 ticket 范围):工单 04 的日汇总查询必须自带 |
| | | `order_status = 1 and verify_status = 1 and refund_status = 0 and withdrawal_status = 0`,并对每条订单调用 |
| | | `lockIntoWithdrawal`;工单 05 若需要「失败顺延解锁订单」,要再加一个显式的解锁方法;部分退款后剩余金额 |
| | | 是否可提现按人工确认的策略整单排除,未做金额级拆分。 |
| | |
| | | back to the platform merchant exactly as before. Migration `V131` therefore backfills only in-flight |
| | | pending orders and never invents a collecting sub-merchant for money that has already moved. |
| | | |
| | | ## Order fund boundary (verification, refund, withdrawal lock) |
| | | |
| | | `benefit_order` carries the money state of one order: `order_status` (paid or not), `verify_status` |
| | | (consumed by the merchant or not), `refund_status` (`0` none, `1` refund in flight, `2` refunded) and |
| | | `withdrawal_status` (`0` not in a withdrawal record, `1` locked into one). `OrderFundBoundaryService` |
| | | owns the rules and the writes for the last two: every verification path, the refund path (request, |
| | | provider query *and* the asynchronous refund callback) and the daily aggregation lock the same |
| | | `benefit_order` row with `SELECT ... FOR UPDATE` first, so verification, refund and withdrawal locking |
| | | are serialized per order instead of racing. |
| | | |
| | | The rules it enforces: |
| | | |
| | | - An order can only be verified when it is paid, not yet verified, has no effective refund, and is not |
| | | in a withdrawal record. `verify_record` additionally has a generated column plus unique key |
| | | (`uk_verify_record_active_order`), so one order can never hold two active verification records. |
| | | - A paid, unverified order can be refunded. Accepting the refund immediately sets `refund_status = 1`, |
| | | which blocks verification until the provider answers; a successful refund (local response, provider |
| | | query or asynchronous callback) sets it to `2`, which excludes the whole order from withdrawal |
| | | aggregation (a partial refund also excludes the order, by the agreed policy). A refund that the |
| | | provider declares failed returns the order to `refund_status = 0`, but a late failure notification |
| | | never reopens an order whose refund already succeeded. |
| | | - `lockIntoWithdrawal` (called by the daily aggregation of ticket 04) only accepts an order that is |
| | | paid, verified, unrefunded and not yet in another withdrawal record. Once locked, the order can |
| | | neither be revoked nor refunded. |
| | | |
| | | Migration `V132` adds the two columns, backfills `refund_status` from `order_refund`, and marks |
| | | duplicate historical verification records as revoked (keeping the earliest one) before creating the |
| | | unique key. |
| | | |
| | | ## Reconciliation and exception handling |
| | | |
| | | The server records verified callback audit events, then uses the provider query API as a second confirmation path for payments and refunds that remain pending. It never converts an order to paid from a client request or a local timer result alone. |
| | |
| | | } |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> withdrawals(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { |
| | | String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))"; |
| | | String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.refund_status = 0 and o.withdrawal_status = 0 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))"; |
| | | String select = "select w.*, c.bank_name, c.card_masked, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name, " + available + " as available_amount_cents from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id"; |
| | | String count = "select count(*) from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id"; |
| | | return page(select, count, "w", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate); |
| | |
| | | @Transactional(readOnly = true) |
| | | public Map<String, Object> withdrawalSummary(MerchantPrincipal principal) { |
| | | store(principal); |
| | | Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and deleted = 0", Long.class, principal.merchantId(), principal.storeId()); |
| | | Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and refund_status = 0 and withdrawal_status = 0 and deleted = 0", Long.class, principal.merchantId(), principal.storeId()); |
| | | Long held = jdbc.queryForObject("select coalesce(sum(amount_cents), 0) from merchant_withdrawal where merchant_id = ? and store_id = ? and status in ('pending','processing','paid')", Long.class, principal.merchantId(), principal.storeId()); |
| | | long available = Math.max(0, (verified == null ? 0 : verified) - (held == null ? 0 : held)); |
| | | return Map.of("availableAmountCents", available, "frozenAmountCents", held == null ? 0 : held); |
| | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.dao.DataAccessException; |
| | |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) { |
| | | public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary) { |
| | | this.jdbc = jdbc; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | /** |
| | |
| | | private Map<String, Object> confirmOrderCode(MerchantPrincipal principal, Map<String, Object> attempt) { |
| | | List<Map<String, Object>> codes = rows(""" |
| | | select c.id order_verification_code_id, c.expires_at, o.id order_id, o.talent_id, o.card_id, o.benefit_id, |
| | | o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, |
| | | o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount, |
| | | o.merchant_id, o.store_id, tc.card_level |
| | | from order_dynamic_verification_code c |
| | | join benefit_order o on o.id = c.order_id and o.deleted = 0 |
| | |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单核销码已被使用或已失效"); |
| | | } |
| | | Map<String, Object> code = codes.get(0); |
| | | if (!belongsToPrincipal(code, principal) || number(code.get("orderStatus")) != 1 || number(code.get("verifyStatus")) != 0) { |
| | | if (!belongsToPrincipal(code, principal)) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销"); |
| | | } |
| | | // 锁定订单行后再判断资金状态:退款、核销和提现锁定都排队在这一行上。 |
| | | orderFundBoundary.lock(number(code.get("orderId"))).requireVerifiable(); |
| | | int codeUpdated = jdbc.update(""" |
| | | update order_dynamic_verification_code set status = 1 |
| | | where id = ? and status in (0, 2) |
| | |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.portal.ProductPriceCalculator; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | |
| | | |
| | | private final JdbcTemplate jdbcTemplate; |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService) { |
| | | public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | o.merchant_id, m.merchant_name, m.merchant_rights_config, |
| | | o.product_id, o.product_name_snapshot, p.rights_config, |
| | | o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.created_at, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.withdrawal_status, o.created_at, |
| | | vr.verify_time, vr.status as verify_record_status |
| | | from benefit_order o |
| | | left join talent_user tu on tu.id = o.talent_id |
| | |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); |
| | | } |
| | | Map<String, Object> order = order(orderNo, principal.merchantId(), principal.storeId()); |
| | | // 先锁订单行,再复查幂等键和资金状态:并发核销(同键或不同键)都排队在这把锁上, |
| | | // 后来者要么拿到首次结果,要么看到订单已核销。 |
| | | OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id"))); |
| | | List<Map<String, Object>> repeated = query(""" |
| | | select order_id from verify_record where idempotency_key = ? and merchant_id = ? limit 1 |
| | | """, idempotencyKey.trim(), principal.merchantId()); |
| | |
| | | } |
| | | return order(orderNo, principal.merchantId(), principal.storeId()); |
| | | } |
| | | if (((Number) order.get("orderStatus")).intValue() != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销"); |
| | | } |
| | | if (((Number) order.get("verifyStatus")).intValue() == 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | locked.requireVerifiable(); |
| | | |
| | | String verifyNo = BusinessIdGenerator.next("V"); |
| | | jdbcTemplate.update(""" |
| | |
| | | order.get("payAmount"), |
| | | order.get("subsidyAmount"), |
| | | request.remark(), idempotencyKey.trim()); |
| | | jdbcTemplate.update("update benefit_order set verify_status = 1 where order_no = ? and verify_status = 0", orderNo); |
| | | int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", locked.orderId()); |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | return order(orderNo, principal.merchantId(), principal.storeId()); |
| | | } |
| | | |
| | |
| | | List<Map<String, Object>> keyOwner = query(""" |
| | | select order_id from verify_record where revoke_idempotency_key = ? limit 1 |
| | | """, normalizedIdempotencyKey); |
| | | if (!keyOwner.isEmpty() && number(keyOwner.get(0).get("orderId")) != orderId) { |
| | | // 幂等键归属必须按值比较:两个 Long 用 != 只在 -128..127 的缓存区间内碰巧相等, |
| | | // 订单 ID 一旦超过这个区间,重复撤销就会被误判成「幂等键已用于其他订单」。 |
| | | if (!keyOwner.isEmpty() && !Objects.equals(number(keyOwner.get(0).get("orderId")), orderId)) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "幂等键已用于其他订单"); |
| | | } |
| | | List<Map<String, Object>> repeated = query(""" |
| | |
| | | from benefit_order o |
| | | left join verify_record vr on vr.order_id = o.id and vr.status = 1 |
| | | where o.id = ? and o.deleted = 0 and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) |
| | | for update |
| | | """, orderId, principal.merchantId(), principal.storeId()); |
| | | if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); |
| | | Map<String, Object> order = orders.get(0); |
| | | // 撤销核销与核销、退款、提现锁定共用同一把订单行锁;锁内读到的核销记录才是稳定的。 |
| | | orderFundBoundary.lock(orderId).requireRevocable(); |
| | | if (!"GROUP_BUY".equals(String.valueOf(order.get("orderType")))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "当前业务类型不支持撤销核销"); |
| | | } |
| | |
| | | result.put("orderStatusText", "待核销"); |
| | | result.put("revokedAt", (revokedAt == null ? LocalDateTime.now() : revokedAt).toString()); |
| | | return result; |
| | | } |
| | | |
| | | private String revokeReason(String orderType, boolean withdrawalLocked) { |
| | | if (withdrawalLocked) return "订单已进入提现记录"; |
| | | return "GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销"; |
| | | } |
| | | |
| | | private Map<String, Object> enrichOrder(Map<String, Object> order) { |
| | |
| | | result.put("couponAmount", couponAmount); |
| | | // 免费入住没有 benefit_order 金额,补贴来自核销记录中的后台配置,不能按原价减实付覆盖为 0。 |
| | | result.put("subsidyAmount", "FREE_STAY".equals(orderType) ? configuredSubsidyAmount : totalDiscount); |
| | | boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1; |
| | | // 已进入提现记录的订单不能再撤销核销,商户端不值得展示一个必然失败的按钮。 |
| | | Long withdrawalStatus = number(order.get("withdrawalStatus")); |
| | | boolean withdrawalLocked = withdrawalStatus != null && withdrawalStatus == 1; |
| | | boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1 && !withdrawalLocked; |
| | | result.put("canRevoke", canRevoke); |
| | | result.put("revokeDeadline", verifyTime == null ? null : verifyTime.plusHours(REVOCATION_HOURS).toString()); |
| | | result.put("revokeReason", canRevoke ? "" : ("GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销")); |
| | | result.put("revokeReason", canRevoke ? "" : revokeReason(orderType, withdrawalLocked)); |
| | | return result; |
| | | } |
| | | |
| New file |
| | |
| | | package com.talentcard.server.business.order; |
| | | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | 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.Propagation; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | /** |
| | | * 订单资金边界。 |
| | | * |
| | | * <p>一个订单的核销、退款和提现锁定都会先锁定 {@code benefit_order} 的同一行,因此它们按订单串行化: |
| | | * 不会出现「退了款又核销」「核销后被退款」「已经进了提现记录又撤销核销」。 |
| | | * |
| | | * <p>{@code refund_status} 由本服务的退款三个方法维护;{@code withdrawal_status} 只有一个写入方 |
| | | * {@link #lockIntoWithdrawal(long)}(工单 04 的每日汇总把订单登记进提现记录时调用)。核销、退款、 |
| | | * 提现锁定的业务规则都收敛在这里,调用方只负责在拿到 {@link LockedOrder} 后调用对应断言。 |
| | | * |
| | | * <p>{@link #lock(long)} 要求已经存在数据库事务:行锁只在事务内有效,脱离事务调用会直接失败, |
| | | * 而不是静默地失去串行化能力。其它方法都是单条条件更新,本身就是原子的。 |
| | | */ |
| | | @Service |
| | | public class OrderFundBoundaryService { |
| | | |
| | | /** 订单退款状态:没有生效中的退款。 */ |
| | | public static final long REFUND_NONE = 0; |
| | | /** 订单退款状态:退款已受理、结果未定。 */ |
| | | public static final long REFUND_IN_PROGRESS = 1; |
| | | /** 订单退款状态:退款成功(部分退款也算整单退出结算)。 */ |
| | | public static final long REFUNDED = 2; |
| | | |
| | | /** 提现锁定状态:未进入提现记录。 */ |
| | | public static final long WITHDRAWAL_NONE = 0; |
| | | /** 提现锁定状态:已进入提现记录,不能再撤销核销或退款。 */ |
| | | public static final long WITHDRAWAL_LOCKED = 1; |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | |
| | | public OrderFundBoundaryService(JdbcTemplate jdbc) { |
| | | this.jdbc = jdbc; |
| | | } |
| | | |
| | | /** |
| | | * 锁定订单行并返回它的资金状态。拿到的状态在整个事务内不会被子资金状态变更改动, |
| | | * 因此所有判断都必须基于本方法的返回值。 |
| | | */ |
| | | @Transactional(propagation = Propagation.MANDATORY) |
| | | public LockedOrder lock(long orderId) { |
| | | List<Map<String, Object>> rows = jdbc.queryForList(""" |
| | | select id, order_status, verify_status, refund_status, withdrawal_status |
| | | from benefit_order |
| | | where id = ? and deleted = 0 |
| | | limit 1 for update |
| | | """, orderId); |
| | | if (rows.isEmpty()) { |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); |
| | | } |
| | | return LockedOrder.fromRow(rows.get(0)); |
| | | } |
| | | |
| | | /** 退款受理成功:本笔退款结清前订单不可核销,也不参与提现汇总。 */ |
| | | public void markRefundInProgress(long orderId) { |
| | | int updated = jdbc.update(""" |
| | | update benefit_order set refund_status = 1 |
| | | where id = ? and deleted = 0 and refund_status = 0 |
| | | """, orderId); |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已提交退款申请"); |
| | | } |
| | | } |
| | | |
| | | /** 退款成功:整单退出可核销与可提现范围,部分退款同样按整单处理。 */ |
| | | public void markRefunded(long orderId) { |
| | | jdbc.update("update benefit_order set refund_status = 2 where id = ? and deleted = 0 and refund_status <> 2", orderId); |
| | | } |
| | | |
| | | /** 退款失败:没有资金变动,订单回到未退款状态。已有成功退款的订单不会被退回。 */ |
| | | public void markRefundFailed(long orderId) { |
| | | jdbc.update(""" |
| | | update benefit_order o |
| | | set o.refund_status = 0 |
| | | where o.id = ? and o.deleted = 0 and o.refund_status = 1 |
| | | and not exists (select 1 from order_refund r |
| | | where r.order_id = o.id and r.deleted = 0 and r.refund_status = 2) |
| | | """, orderId); |
| | | } |
| | | |
| | | /** |
| | | * 提现锁定:把已核销订单登记进某条提现记录后调用(工单 04 的每日汇总入口)。 |
| | | * |
| | | * <p>只有「已支付、已核销、未退款、未进入其他提现记录」的订单才能被锁定,因此同一条订单 |
| | | * 不会被两条提现记录重复汇总。锁定后订单不能再撤销核销或退款。 |
| | | */ |
| | | public void lockIntoWithdrawal(long orderId) { |
| | | int updated = jdbc.update(""" |
| | | update benefit_order |
| | | set withdrawal_status = 1 |
| | | where id = ? and deleted = 0 and withdrawal_status = 0 |
| | | and order_status = 1 and verify_status = 1 and refund_status = 0 |
| | | """, orderId); |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单不满足进入提现记录的条件"); |
| | | } |
| | | } |
| | | |
| | | /** 一条已被行锁固定的订单资金状态。 */ |
| | | public record LockedOrder(long orderId, long orderStatus, long verifyStatus, long refundStatus, long withdrawalStatus) { |
| | | |
| | | static LockedOrder fromRow(Map<String, Object> row) { |
| | | return new LockedOrder( |
| | | number(row.get("id")), |
| | | number(row.get("order_status")), |
| | | number(row.get("verify_status")), |
| | | number(row.get("refund_status")), |
| | | number(row.get("withdrawal_status"))); |
| | | } |
| | | |
| | | public boolean withdrawalLocked() { |
| | | return withdrawalStatus == WITHDRAWAL_LOCKED; |
| | | } |
| | | |
| | | /** 未支付、已核销、已退款或退款中、已进入提现记录的订单都不能再核销。 */ |
| | | public void requireVerifiable() { |
| | | if (orderStatus != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销"); |
| | | } |
| | | if (verifyStatus == 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | if (refundStatus != REFUND_NONE) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已退款或退款中,不能核销"); |
| | | } |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能核销"); |
| | | } |
| | | } |
| | | |
| | | /** 未支付、已退款或退款中、已进入提现记录的订单都不能再退款。 */ |
| | | public void requireRefundable() { |
| | | if (orderStatus != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单当前状态不可退款"); |
| | | } |
| | | if (refundStatus == REFUND_IN_PROGRESS) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单退款处理中,不能重复退款"); |
| | | } |
| | | if (refundStatus == REFUNDED) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已退款"); |
| | | } |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能退款"); |
| | | } |
| | | } |
| | | |
| | | /** 进入提现记录的订单不能再撤销核销。 */ |
| | | public void requireRevocable() { |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能撤销核销"); |
| | | } |
| | | } |
| | | |
| | | private static long number(Object value) { |
| | | return value instanceof Number number ? number.longValue() : 0L; |
| | | } |
| | | } |
| | | } |
| | |
| | | private final JdbcTemplate jdbcTemplate; |
| | | private final HuifuRefundService refundService; |
| | | private final MerchantPaymentReadinessService merchantPaymentReadinessService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService) { |
| | | public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.refundService = refundService; |
| | | this.merchantPaymentReadinessService = merchantPaymentReadinessService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | @Transactional |
| | | public Map<String, Object> verify(String orderNo, OrderVerifyRequest request) { |
| | | Map<String, Object> order = detail(orderNo); |
| | | if (((Number) order.get("orderStatus")).intValue() != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销"); |
| | | } |
| | | if (((Number) order.get("verifyStatus")).intValue() == 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | Long orderId = ((Number) order.get("id")).longValue(); |
| | | // 后台核销与商户核销、退款共用同一把订单行锁,重复核销只会产生一次有效结果。 |
| | | OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(orderId); |
| | | locked.requireVerifiable(); |
| | | Long orderMerchantId = order.get("merchantId") == null ? null : ((Number) order.get("merchantId")).longValue(); |
| | | if (request.merchantId() != null && !request.merchantId().equals(orderMerchantId)) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "订单不属于该商户"); |
| | |
| | | order.get("payAmount"), |
| | | order.get("subsidyAmount"), |
| | | request.remark()); |
| | | jdbcTemplate.update("update benefit_order set verify_status = 1 where order_no = ?", orderNo); |
| | | int updated = jdbcTemplate.update("update benefit_order set verify_status = 1 where id = ? and verify_status = 0", orderId); |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | return detail(orderNo); |
| | | } |
| | | |
| | |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.payment.HuifuPaymentPayload.TransStatus; |
| | | import com.talentcard.server.business.payment.PaymentSettlementService.ProviderPaymentFact; |
| | | import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome; |
| | |
| | | private final JdbcTemplate jdbc; |
| | | private final HuifuSignatureService signatureService; |
| | | private final PaymentSettlementService settlementService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public HuifuPaymentCallbackService(JdbcTemplate jdbc, HuifuSignatureService signatureService, PaymentSettlementService settlementService) { |
| | | public HuifuPaymentCallbackService(JdbcTemplate jdbc, HuifuSignatureService signatureService, PaymentSettlementService settlementService, OrderFundBoundaryService orderFundBoundary) { |
| | | this.jdbc = jdbc; |
| | | this.signatureService = signatureService; |
| | | this.settlementService = settlementService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | if (refunds.isEmpty()) return false; |
| | | |
| | | Map<String, Object> refund = refunds.get(0); |
| | | long orderId = ((Number) refund.get("orderId")).longValue(); |
| | | String transStatus = HuifuPaymentPayload.value(data, "trans_stat", "transStat"); |
| | | String code = HuifuPaymentPayload.code(data); |
| | | if (HuifuPaymentPayload.paid(transStatus, code)) { |
| | |
| | | provider_response_code = ?, provider_response_message = ?, refund_time = coalesce(refund_time, now()) |
| | | where id = ? and refund_status = 1 |
| | | """, providerTransactionNo(data), code, HuifuPaymentPayload.message(data), refund.get("id")); |
| | | // 通道确认退款成功是最终事实:订单级退款状态无条件收敛为已退款。 |
| | | orderFundBoundary.markRefunded(orderId); |
| | | jdbc.update(""" |
| | | update benefit_order o |
| | | set o.order_status = 2 |
| | |
| | | """, refund.get("orderId")); |
| | | updateEvent(refundNo, "PROCESSED"); |
| | | } else if (HuifuPaymentPayload.terminalFailure(transStatus, code)) { |
| | | jdbc.update(""" |
| | | int failed = jdbc.update(""" |
| | | update order_refund |
| | | set provider_response_code = ?, provider_response_message = ?, refund_status = 3 |
| | | where id = ? and refund_status = 1 |
| | | """, code, HuifuPaymentPayload.message(data), refund.get("id")); |
| | | // 只有本事务真的把退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。 |
| | | if (failed == 1) orderFundBoundary.markRefundFailed(orderId); |
| | | updateEvent(refundNo, "REFUND_FAILED"); |
| | | } else { |
| | | jdbc.update(""" |
| | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.MoneyUtils; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.order.OrderRefundRequest; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | |
| | | private final TransactionTemplate transactions; |
| | | private final HuifuRefundGateway gateway; |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties) { |
| | | public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties, OrderFundBoundaryService orderFundBoundary) { |
| | | this.jdbc = jdbc; |
| | | this.transactions = transactions; |
| | | this.gateway = gateway; |
| | | this.huifuPaymentProperties = huifuPaymentProperties; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | public Map<String, Object> request(String orderNo, OrderRefundRequest request) { |
| | |
| | | prepared.originalRequestDate(), prepared.refundAmount())); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | if (!exception.isOutcomeUnknown()) { |
| | | markFailed(prepared.refundNo(), "GATEWAY_REJECTED", exception.getMessage()); |
| | | markFailed(prepared.refundNo(), prepared.orderId(), "GATEWAY_REJECTED", exception.getMessage()); |
| | | throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected"); |
| | | } |
| | | return detail(prepared.refundNo()); |
| | | } catch (BusinessException exception) { |
| | | markFailed(prepared.refundNo(), "LOCAL_ERROR", exception.getMessage()); |
| | | markFailed(prepared.refundNo(), prepared.orderId(), "LOCAL_ERROR", exception.getMessage()); |
| | | throw exception; |
| | | } catch (RuntimeException exception) { |
| | | return detail(prepared.refundNo()); |
| | |
| | | if (result.success()) { |
| | | markSucceeded(prepared, result); |
| | | } else if (result.failed()) { |
| | | markFailed(prepared.refundNo(), result.code(), result.message()); |
| | | markFailed(prepared.refundNo(), prepared.orderId(), result.code(), result.message()); |
| | | throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected"); |
| | | } else { |
| | | updateProviderResult(prepared.refundNo(), result); |
| | |
| | | try { |
| | | ProviderResult result = ProviderResult.from(gateway.queryRefund(command, text(refund.get("providerRefundNo")))); |
| | | if (result.success()) markSucceeded(new PreparedRefund(refundNo, number(refund.get("orderId")), number(refund.get("refundAmount"))), result); |
| | | else if (result.failed()) markFailed(refundNo, result.code(), result.message()); |
| | | else if (result.failed()) markFailed(refundNo, number(refund.get("orderId")), result.code(), result.message()); |
| | | else updateProviderResult(refundNo, result); |
| | | } catch (HuifuPaymentGatewayException ignored) { |
| | | // Keep the refund pending when the provider outcome is unknown. |
| | |
| | | """, PROVIDER, orderNo); |
| | | if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Order not found"); |
| | | Map<String, Object> order = orders.get(0); |
| | | if (number(order.get("orderStatus")) != 1) throw new BusinessException(ErrorCode.CONFLICT, "Order cannot be refunded in its current state"); |
| | | if (number(order.get("verifyStatus")) == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) { |
| | | // 支付、核销、退款、提现锁定都按订单串行化:锁住订单行之后才判断能不能退款。 |
| | | OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id"))); |
| | | locked.requireRefundable(); |
| | | if (locked.verifyStatus() == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "Verified orders cannot be refunded"); |
| | | } |
| | | |
| | |
| | | refundNo, order.get("id"), order.get("orderNo"), order.get("talentId"), order.get("cardId"), |
| | | order.get("benefitId"), order.get("merchantId"), order.get("orderType"), paidAmount, refundAmount, |
| | | refundableAmount - refundAmount, PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName()); |
| | | // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。 |
| | | orderFundBoundary.markRefundInProgress(number(order.get("id"))); |
| | | return new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount); |
| | | } |
| | | |
| | |
| | | where o.id = ? and o.order_status = 1 |
| | | and o.pay_amount <= (select coalesce(sum(r.refund_amount), 0) from order_refund r where r.order_id = o.id and r.refund_status = 2 and r.deleted = 0) |
| | | """, prepared.orderId()); |
| | | orderFundBoundary.markRefunded(prepared.orderId()); |
| | | }); |
| | | } |
| | | |
| | | private void markFailed(String refundNo, String code, String message) { |
| | | jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo); |
| | | /** 退款失败没有资金变动,订单回到未退款状态(可再次核销或再次退款)。 */ |
| | | private void markFailed(String refundNo, long orderId, String code, String message) { |
| | | transactions.executeWithoutResult(status -> { |
| | | int failed = jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo); |
| | | // 只有本事务真的把这笔退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。 |
| | | if (failed == 1) orderFundBoundary.markRefundFailed(orderId); |
| | | }); |
| | | } |
| | | |
| | | private void updateProviderResult(String refundNo, ProviderResult result) { |
| | |
| | | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.request.BenefitVerificationRequest; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.common.BusinessException; |
| | |
| | | private final TicketPackageClaimMapper ticketPackageClaimMapper; |
| | | private final TicketPackageCodeMapper ticketPackageCodeMapper; |
| | | private final TicketPackagePortalService ticketPackagePortalService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | |
| | | public BenefitVerificationService( |
| | | BenefitQuotaService quotaService, |
| | |
| | | BenefitMerchantProductMapper productMapper, |
| | | TicketPackageClaimMapper ticketPackageClaimMapper, |
| | | TicketPackageCodeMapper ticketPackageCodeMapper, |
| | | TicketPackagePortalService ticketPackagePortalService |
| | | TicketPackagePortalService ticketPackagePortalService, |
| | | OrderFundBoundaryService orderFundBoundary |
| | | ) { |
| | | this.quotaService = quotaService; |
| | | this.verifyRecordMapper = verifyRecordMapper; |
| | |
| | | this.ticketPackageClaimMapper = ticketPackageClaimMapper; |
| | | this.ticketPackageCodeMapper = ticketPackageCodeMapper; |
| | | this.ticketPackagePortalService = ticketPackagePortalService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | } |
| | | BenefitOrderDynamicVerificationCode code = context.code(); |
| | | BenefitOrder order = context.order(); |
| | | if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt()) |
| | | || intValue(order.getOrderStatus()) != 1 || intValue(order.getVerifyStatus()) != 0) { |
| | | if (intValue(code.getStatus()) != 0 || expiredDateTime(code.getExpiresAt())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "酒店订单当前不可核销"); |
| | | } |
| | | // 与商户端核销、退款共用同一把订单行锁:未支付、已退款或已进入提现记录的订单都不能核销。 |
| | | orderFundBoundary.lock(order.getId()).requireVerifiable(); |
| | | int codeUpdated = orderCodeMapper.update(null, new UpdateWrapper<BenefitOrderDynamicVerificationCode>() |
| | | .set("status", 1) |
| | | .eq("id", code.getId()) |
| New file |
| | |
| | | -- 核销幂等与退款边界:把「能不能核销 / 能不能退款 / 能不能进提现」三个判断收敛到 benefit_order 的同一行, |
| | | -- 让核销、退款和提现锁定在数据库行锁上按订单串行化。 |
| | | |
| | | -- 订单级退款状态。任何一笔退款在途或成功,整单都退出可核销与可提现范围; |
| | | -- 退款失败会回到 0,因此这一列只表示「当前生效的退款」。 |
| | | alter table benefit_order |
| | | add column refund_status tinyint not null default 0 comment '订单退款状态:0未退款 1退款中 2已退款'; |
| | | |
| | | -- 提现锁定。订单被每日提现汇总登记进提现记录后置 1,之后不能再撤销核销或退款, |
| | | -- 也不会再被其它提现记录汇总。 |
| | | alter table benefit_order |
| | | add column withdrawal_status tinyint not null default 0 comment '提现锁定状态:0未进入提现记录 1已进入提现记录'; |
| | | |
| | | -- 回填历史退款:order_refund 是既有事实来源,成功优先于在途。 |
| | | update benefit_order o |
| | | join (select order_id, max(case when refund_status = 2 then 1 else 0 end) refunded |
| | | from order_refund |
| | | where deleted = 0 and refund_status in (1, 2) |
| | | group by order_id) r on r.order_id = o.id |
| | | set o.refund_status = case when r.refunded = 1 then 2 else 1 end |
| | | where o.refund_status = 0; |
| | | |
| | | -- 同一订单只允许一条有效核销记录。并发核销在旧实现里可能留下多条 status = 1 的记录, |
| | | -- 迁移只保留最早一条,其余标记为已撤销(不是删除,核销历史仍然可查)。 |
| | | update verify_record v |
| | | join (select order_id, min(id) active_id |
| | | from verify_record |
| | | where status = 1 and order_id is not null |
| | | group by order_id |
| | | having count(*) > 1) duplicated on duplicated.order_id = v.order_id |
| | | set v.status = 2, |
| | | v.revoked_at = coalesce(v.revoked_at, now()), |
| | | v.revoke_reason = coalesce(v.revoke_reason, 'V132 迁移:同一订单存在多条有效核销记录,仅保留最早一条') |
| | | where v.status = 1 and v.id <> duplicated.active_id; |
| | | |
| | | -- 生成列让「有效核销记录」在数据库层唯一:status = 1 时等于 order_id,撤销后为 NULL |
| | | -- (唯一索引允许多个 NULL),因此撤销后重新核销仍然合法,但同一订单不可能同时有两条有效核销。 |
| | | alter table verify_record |
| | | add column active_order_id bigint generated always as (case when status = 1 then order_id else null end) virtual |
| | | comment '有效核销记录对应的订单ID,status <> 1 时为 NULL,用于订单级唯一约束'; |
| | | |
| | | alter table verify_record |
| | | add unique key uk_verify_record_active_order (active_order_id); |
| | |
| | | jdbc.execute("create table admin_setting (setting_key varchar(64) primary key, setting_value varchar(1000))"); |
| | | jdbc.update("insert into admin_setting(setting_key, setting_value) values ('hotelSubsidy', '268.50')"); |
| | | jdbc.execute("create table payment_transaction (id bigint primary key)"); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null)); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc)); |
| | | principal = new MerchantPrincipal(9L, 88L, 7L, "operator"); |
| | | } |
| | | |
| | |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.jdbc.datasource.DataSourceTransactionManager; |
| | | import org.springframework.jdbc.datasource.DriverManagerDataSource; |
| | | import org.springframework.transaction.support.TransactionTemplate; |
| | | |
| | | class MerchantVerificationOrderCodeTests { |
| | | |
| | | private JdbcTemplate jdbc; |
| | | private TransactionTemplate transactions; |
| | | private MerchantVerificationService service; |
| | | private MerchantPrincipal principal; |
| | | |
| | |
| | | "jdbc:h2:mem:order-code-verification-" + System.nanoTime() |
| | | + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); |
| | | jdbc = new JdbcTemplate(dataSource); |
| | | transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); |
| | | jdbc.execute(""" |
| | | create table merchant_verification_attempt ( |
| | | id bigint auto_increment primary key, |
| | |
| | | pay_amount bigint not null, |
| | | order_status tinyint not null, |
| | | verify_status tinyint not null, |
| | | refund_status tinyint not null default 0, |
| | | withdrawal_status tinyint not null default 0, |
| | | merchant_id bigint not null, |
| | | store_id bigint, |
| | | deleted tinyint not null default 0 |
| | |
| | | remark varchar(255) |
| | | ) |
| | | """); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null)); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), |
| | | new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc)); |
| | | principal = new MerchantPrincipal(9L, 88L, 7L, "operator"); |
| | | } |
| | | |
| | |
| | | void confirmsGroupOrderAfterScannedCodeExpiresAndRefreshes() { |
| | | insertOrder("expired-group-order", 1); |
| | | |
| | | Map<String, Object> confirmed = service.confirm(principal, |
| | | new VerificationConfirmRequest("expired-group-order"), "order-confirm-expired-group-order"); |
| | | Map<String, Object> confirmed = confirm("expired-group-order"); |
| | | |
| | | assertThat(confirmed.get("verified")).isEqualTo(true); |
| | | assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where id = 30", Integer.class)).isEqualTo(1); |
| | |
| | | void stillRejectsUnpaidGroupOrder() { |
| | | insertOrder("unpaid-group-order", 0); |
| | | |
| | | assertThatThrownBy(() -> service.confirm(principal, new VerificationConfirmRequest("unpaid-group-order"), |
| | | "order-confirm-unpaid-group-order")) |
| | | assertThatThrownBy(() -> confirm("unpaid-group-order")) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("当前不可核销"); |
| | | .hasMessageContaining("未支付"); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsGroupOrderThatWasRefundedWhileTheCodeWasOpen() { |
| | | insertOrder("refunded-group-order", 1); |
| | | jdbc.update("update benefit_order set refund_status = 2 where id = 20"); |
| | | |
| | | assertThatThrownBy(() -> confirm("refunded-group-order")) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("退款"); |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = 20", Integer.class)).isZero(); |
| | | } |
| | | |
| | | /** 核销确认按订单行锁串行化,所以测试也必须在一个真实事务里调用它。 */ |
| | | private Map<String, Object> confirm(String verificationId) { |
| | | return transactions.execute(status -> service.confirm(principal, |
| | | new VerificationConfirmRequest(verificationId), "order-confirm-" + verificationId)); |
| | | } |
| | | |
| | | private void insertOrder(String verificationId, int orderStatus) { |
| New file |
| | |
| | | package com.talentcard.server.business.order; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.concurrent.Callable; |
| | | import java.util.concurrent.CountDownLatch; |
| | | import java.util.concurrent.ExecutorService; |
| | | import java.util.concurrent.Executors; |
| | | import java.util.concurrent.Future; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | import com.talentcard.server.business.merchant.MerchantRevokeVerificationRequest; |
| | | import com.talentcard.server.business.merchant.MerchantWorkService; |
| | | import com.talentcard.server.business.merchant.VerifyRequest; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.payment.HuifuPaymentCallbackService; |
| | | import com.talentcard.server.business.payment.HuifuRefundService; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuSignatureService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.dao.DataIntegrityViolationException; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * 核销幂等与退款边界 seam:未支付订单不能核销;同一订单重复核销只产生一次有效结果; |
| | | * 已支付未核销订单可退款,退款后不得核销或提现;核销、退款和提现锁定按订单串行化; |
| | | * 订单进入提现记录后不能撤销核销。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class OrderFundBoundaryTests { |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantWorkService merchantWorkService; |
| | | |
| | | @Autowired |
| | | private OrderService orderService; |
| | | |
| | | @Autowired |
| | | private OrderFundBoundaryService orderFundBoundaryService; |
| | | |
| | | @Autowired |
| | | private HuifuRefundService refundService; |
| | | |
| | | @Autowired |
| | | private HuifuPaymentCallbackService callbackService; |
| | | |
| | | @MockitoBean |
| | | private HuifuRefundGateway refundGateway; |
| | | |
| | | @MockitoBean |
| | | private HuifuSignatureService signatureService; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long talentId; |
| | | private MerchantPrincipal principal; |
| | | private String suffix; |
| | | |
| | | @BeforeEach |
| | | void prepareMerchant() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)", "Fund boundary merchant " + suffix); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Fund boundary store " + suffix); |
| | | storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", "fund-boundary-" + suffix, "资金边界用户", "13900000002"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | | principal = new MerchantPrincipal(9601L, merchantId, storeId, "operator"); |
| | | when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true); |
| | | } |
| | | |
| | | @Test |
| | | void anUnpaidOrderCannotBeVerified() { |
| | | String orderNo = insertOrder("GROUP_BUY", 0, 0); |
| | | |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "unpaid"), "unpaid-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("未支付"); |
| | | assertThatThrownBy(() -> orderService.verify(orderNo, new OrderVerifyRequest(merchantId, 1L, "GROUP_BUY", "unpaid"))) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("未支付"); |
| | | assertThat(activeVerifyRecords(orderId(orderNo))).isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void anUnpaidOrderCannotBeRefundedOrLockedForWithdrawal() { |
| | | String orderNo = insertOrder("GROUP_BUY", 0, 0); |
| | | Long orderId = orderId(orderNo); |
| | | |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "unpaid", "tester"))) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | } |
| | | |
| | | @Test |
| | | void anExpiredOrderCannotBeVerifiedRefundedOrLockedForWithdrawal() { |
| | | // 超时未支付订单会被取消为 order_status = 2(与已退款共用同一个终态)。 |
| | | String orderNo = insertOrder("GROUP_BUY", 2, 0); |
| | | Long orderId = orderId(orderNo); |
| | | |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "expired"), "expired-" + suffix)) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "expired", "tester"))) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThat(activeVerifyRecords(orderId)).isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundFailureCallbackReturnsTheOrderToTheVerifiableState() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | String refundNo = pendingRefund(orderNo).get("refundNo").toString(); |
| | | |
| | | callbackService.handle(refundCallback(refundNo, "F", "20000000"), "signature"); |
| | | |
| | | assertThat(jdbc.queryForMap("select refund_status from order_refund where refund_no = ?", refundNo)).containsEntry("refund_status", 3); |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isZero(); |
| | | assertThat(merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after failed callback"), "callback-failed-" + suffix)) |
| | | .containsEntry("status", "completed"); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundSuccessCallbackExcludesTheOrderFromVerificationAndPayout() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | String refundNo = pendingRefund(orderNo).get("refundNo").toString(); |
| | | |
| | | callbackService.handle(refundCallback(refundNo, "S", "00000000"), "signature"); |
| | | |
| | | assertThat(jdbc.queryForMap("select refund_status from order_refund where refund_no = ?", refundNo)).containsEntry("refund_status", 2); |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(2); |
| | | // 全额退款同时把订单置为已退款状态,核销被拒;无论命中哪条规则,都不允许核销。 |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after callback"), "callback-succeeded-" + suffix)) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class); |
| | | } |
| | | |
| | | @Test |
| | | void aLateFailureCallbackNeverReopensARefundedOrder() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | String refundNo = pendingRefund(orderNo).get("refundNo").toString(); |
| | | callbackService.handle(refundCallback(refundNo, "S", "00000000"), "signature"); |
| | | |
| | | callbackService.handle(refundCallback(refundNo, "F", "20000000"), "signature"); |
| | | |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(2); |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "late failure"), "late-failure-" + suffix)) |
| | | .isInstanceOf(BusinessException.class); |
| | | } |
| | | |
| | | @Test |
| | | void concurrentVerificationOfOneOrderProducesOneEffectiveResult() throws Exception { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0); |
| | | Long orderId = orderId(orderNo); |
| | | CountDownLatch start = new CountDownLatch(1); |
| | | ExecutorService pool = Executors.newFixedThreadPool(2); |
| | | try { |
| | | Future<String> first = pool.submit(verificationAttempt(orderNo, start, "concurrent-verify-a-" + suffix)); |
| | | Future<String> second = pool.submit(verificationAttempt(orderNo, start, "concurrent-verify-b-" + suffix)); |
| | | start.countDown(); |
| | | List<String> outcomes = List.of(first.get(30, TimeUnit.SECONDS), second.get(30, TimeUnit.SECONDS)); |
| | | |
| | | assertThat(outcomes).containsExactlyInAnyOrder("verified", "conflict"); |
| | | } finally { |
| | | pool.shutdownNow(); |
| | | } |
| | | assertThat(activeVerifyRecords(orderId)).isEqualTo(1); |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void repeatingOneVerificationWithTheSameIdempotencyKeyReturnsTheFirstResult() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0); |
| | | Long orderId = orderId(orderNo); |
| | | String key = "repeat-verify-" + suffix; |
| | | |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), key); |
| | | Map<String, Object> repeated = merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), key); |
| | | |
| | | assertThat(repeated).containsEntry("status", "completed"); |
| | | assertThat(activeVerifyRecords(orderId)).isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void theDatabaseAllowsOnlyOneEffectiveVerificationRecordPerOrder() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0); |
| | | Long orderId = orderId(orderNo); |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "first"), "db-guard-" + suffix); |
| | | |
| | | assertThatThrownBy(() -> jdbc.update(""" |
| | | insert into verify_record(verify_no, order_id, talent_id, merchant_id, verify_type, status) |
| | | values (?, ?, ?, ?, 'ORDER_CODE', 1) |
| | | """, "V-DUP-" + suffix, orderId, talentId, merchantId)) |
| | | .isInstanceOf(DataIntegrityViolationException.class); |
| | | } |
| | | |
| | | @Test |
| | | void aRefundInProgressBlocksVerification() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-FUND-P-" + suffix))); |
| | | |
| | | Map<String, Object> refund = refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "processing", "tester")); |
| | | |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | assertThat(refund).containsEntry("refundStatus", 1); |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after refund"), "in-progress-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("退款"); |
| | | assertThat(activeVerifyRecords(orderId)).isZero(); |
| | | } |
| | | |
| | | @Test |
| | | void aPartialRefundStillExcludesTheWholeOrderFromVerificationAndWithdrawal() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "hf_seq_id", "HF-FUND-S-" + suffix))); |
| | | |
| | | refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("30.00"), "partial", "tester")); |
| | | |
| | | assertThat(jdbc.queryForMap("select order_status, refund_status from benefit_order where id = ?", orderId)) |
| | | .as("部分退款不改变订单支付状态,但整单退出可核销与可提现范围") |
| | | .containsEntry("order_status", 1) |
| | | .containsEntry("refund_status", 2); |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after partial refund"), "partial-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("退款"); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | } |
| | | |
| | | @Test |
| | | void aFailedRefundReturnsTheOrderToTheVerifiableState() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "resp_code", "20000000", "sub_resp_desc", "refund rejected"))); |
| | | |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "rejected", "tester"))) |
| | | .isInstanceOf(BusinessException.class); |
| | | |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where id = ?", Integer.class, orderId)).isZero(); |
| | | Map<String, Object> verified = merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after failed refund"), "failed-refund-" + suffix); |
| | | assertThat(verified).containsEntry("status", "completed"); |
| | | } |
| | | |
| | | @Test |
| | | void aVerifiedOrderCannotBeRefunded() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "verified"), "verified-refund-" + suffix); |
| | | |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester"))) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("refunded"); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderInAWithdrawalRecordCannotBeRevokedOrRefunded() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "verified"), "locked-" + suffix); |
| | | |
| | | orderFundBoundaryService.lockIntoWithdrawal(orderId); |
| | | |
| | | assertThat(jdbc.queryForObject("select withdrawal_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal, |
| | | new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "locked-revoke-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester"))) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .as("同一订单不能被两条提现记录重复汇总") |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThat(merchantWorkService.order(orderNo, merchantId, storeId)) |
| | | .containsEntry("canRevoke", false) |
| | | .containsEntry("revokeReason", "订单已进入提现记录"); |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void aVerificationAndARefundOfTheSameOrderCannotBothWin() throws Exception { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "hf_seq_id", "HF-RACE-" + suffix))); |
| | | CountDownLatch start = new CountDownLatch(1); |
| | | ExecutorService pool = Executors.newFixedThreadPool(2); |
| | | List<String> outcomes; |
| | | try { |
| | | Future<String> verification = pool.submit(() -> { |
| | | start.await(10, TimeUnit.SECONDS); |
| | | try { |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "race"), "race-verify-" + suffix); |
| | | return "verified"; |
| | | } catch (BusinessException exception) { |
| | | return "verification-rejected"; |
| | | } |
| | | }); |
| | | Future<String> refund = pool.submit(() -> { |
| | | start.await(10, TimeUnit.SECONDS); |
| | | try { |
| | | refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "race", "tester")); |
| | | return "refunded"; |
| | | } catch (BusinessException exception) { |
| | | return "refund-rejected"; |
| | | } |
| | | }); |
| | | start.countDown(); |
| | | outcomes = List.of(verification.get(30, TimeUnit.SECONDS), refund.get(30, TimeUnit.SECONDS)); |
| | | } finally { |
| | | pool.shutdownNow(); |
| | | } |
| | | |
| | | Map<String, Object> order = jdbc.queryForMap("select order_status, verify_status, refund_status from benefit_order where id = ?", orderId); |
| | | if (outcomes.get(1).equals("refunded")) { |
| | | assertThat(outcomes).containsExactly("verification-rejected", "refunded"); |
| | | assertThat(order).containsEntry("verify_status", 0).containsEntry("refund_status", 2); |
| | | assertThat(activeVerifyRecords(orderId)).isZero(); |
| | | } else { |
| | | assertThat(outcomes).containsExactly("verified", "refund-rejected"); |
| | | assertThat(order).containsEntry("verify_status", 1).containsEntry("refund_status", 0); |
| | | assertThat(activeVerifyRecords(orderId)).isEqualTo(1); |
| | | } |
| | | } |
| | | |
| | | private Callable<String> verificationAttempt(String orderNo, CountDownLatch start, String key) { |
| | | return () -> { |
| | | start.await(10, TimeUnit.SECONDS); |
| | | try { |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "concurrent"), key); |
| | | return "verified"; |
| | | } catch (BusinessException exception) { |
| | | return "conflict"; |
| | | } |
| | | }; |
| | | } |
| | | |
| | | private String insertOrder(String type, int orderStatus, int verifyStatus) { |
| | | String orderNo = "FUND-" + System.nanoTime(); |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount, |
| | | subsidy_amount, pay_amount, order_status, verify_status, deleted) |
| | | values (?, ?, ?, ?, ?, 10000, 1000, 9000, ?, ?, 0) |
| | | """, orderNo, talentId, merchantId, storeId, type, orderStatus, verifyStatus); |
| | | return orderNo; |
| | | } |
| | | |
| | | /** 已支付订单加上一笔成功支付流水,退款才能找到收款方和原交易号。 */ |
| | | private String insertPaidOrderWithSucceededPayment() { |
| | | String orderNo = insertOrder("GROUP_BUY", 1, 0); |
| | | Long orderId = orderId(orderNo); |
| | | jdbc.update(""" |
| | | insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, |
| | | provider_huifu_id, provider_transaction_no, amount, status, idempotency_key, paid_at) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, 9000, 'SUCCEEDED', ?, now()) |
| | | """, "P-FUND-" + System.nanoTime(), orderId, orderNo, talentId, merchantId, |
| | | "H" + suffix.substring(Math.max(0, suffix.length() - 8)), "HF-TX-" + suffix, "payment-fund-" + suffix + "-" + orderId); |
| | | return orderNo; |
| | | } |
| | | |
| | | private Long orderId(String orderNo) { |
| | | return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); |
| | | } |
| | | |
| | | /** 提交一笔结果未定的退款,返回退款记录;通道回调留给测试自己发送。 */ |
| | | private Map<String, Object> pendingRefund(String orderNo) { |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-PENDING-" + System.nanoTime()))); |
| | | return refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "pending", "tester")); |
| | | } |
| | | |
| | | private String refundCallback(String refundNo, String transStatus, String code) { |
| | | return com.alibaba.fastjson.JSON.toJSONString(Map.of( |
| | | "resp_code", code, |
| | | "resp_desc", "退款回调", |
| | | "trans_stat", transStatus, |
| | | "req_seq_id", refundNo)); |
| | | } |
| | | |
| | | private Integer activeVerifyRecords(Long orderId) { |
| | | return jdbc.queryForObject("select count(*) from verify_record where order_id = ? and status = 1", Integer.class, orderId); |
| | | } |
| | | |
| | | private Map<String, Object> providerResponse(Map<String, Object> data) { |
| | | return Map.of("data", data); |
| | | } |
| | | } |