From 13cb08621f3710587d2d413aecd452b103fc1196 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 17 Sep 2026 03:39:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(hjc):=20=E4=B9=B0=E5=AE=B6=E5=8F=AF?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=BE=85=E6=94=AF=E4=BB=98=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=EF=BC=9B=E6=A0=87=E4=B9=A6=E8=AF=A6=E6=83=85=E3=80=8C=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E4=BA=BA=E6=95=B0=E3=80=8D=E6=94=B9=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取消订单(POST /api/hjc/order/cancel,幂等): - 先向微信查单。SUCCESS 则**拒绝取消**并就地 markPaid 自愈(ADR 0009 的「以微信侧为准」); 这是唯一能挡住「其实已付款、只是前端没送达」的时机 - 否则条件更新 order_status=2: UPDATE ... WHERE order_no=? AND pay_status=0 AND order_status=0 靠**影响行数**保证幂等与安全,故新增 XML ——updateById 那条路拿不到行数 - 再尽力 closeOrder 关掉微信侧那张支付单,避免「已取消却仍能付款」;失败只记日志、不回滚 - 查单异常时仍放行取消,但响应带 verified=false,如实告知「未与微信核对」,不替微信下结论 - 响应恒 code=0 + 结构化 data{cancelled,paid,verified,payStatus,orderStatus}: 「其实已经付了」不是错误,不该让前端靠 message 字符串判定 - 结果以**重新读到的状态**为准,不以条件更新返回值为准(并发重复取消 / 期间被支付) - **不推送一站式**。出向状态只有 PAID/REFUNDED,且 buildCreatePurchaseDetails 对非退款单 恒发 PAID——在取消路径误调会造出一条假的「已支付」采购记录。未支付订单从未被推送过 标书详情新增 buyerCount(非表字段,实时统计的已付款订单数): 原「购买人数」取自 saleCount——下单即加、从不回退,dev 实测项目 4 的 16 里 11 张根本没付款。 saleCount 字段与下单累加未动(已无展示用途),**零 DDL**。 /order/pay 失败响应带 data.wechatCode:从 PaymentException 的 cause 链取 ServiceException.getErrorCode()(SDK 原样挂在 cause 上,中途各级 catch 全是 throw e), 供前端识别跨端 OUT_TRADE_NO_USED。**公共支付层与 core 一行未改。** 测试:HjcOrderCancelUtilTest(9) + HjcOrderControllerCancelTest(12,含推送禁区回归守卫) --- .../controller/HjcBidProjectController.java | 6 + .../hjc/controller/HjcOrderController.java | 157 ++++++++- .../gxwebsoft/hjc/entity/HjcBidProject.java | 15 + .../gxwebsoft/hjc/mapper/HjcOrderMapper.java | 20 ++ .../hjc/mapper/xml/HjcOrderMapper.xml | 29 ++ .../gxwebsoft/hjc/service/HjcBizService.java | 15 + .../hjc/service/HjcOrderService.java | 12 + .../hjc/service/impl/HjcBizServiceImpl.java | 9 + .../hjc/service/impl/HjcOrderServiceImpl.java | 13 + .../hjc/util/HjcOrderCancelUtil.java | 91 +++++ .../HjcOrderControllerCancelTest.java | 314 ++++++++++++++++++ .../hjc/util/HjcOrderCancelUtilTest.java | 114 +++++++ 12 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtil.java create mode 100644 src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerCancelTest.java create mode 100644 src/test/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtilTest.java diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java index c4f221c..cb3ead2 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java @@ -6,6 +6,7 @@ import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.service.HjcBidProjectService; +import com.gxwebsoft.hjc.service.HjcOrderService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; @@ -23,6 +24,8 @@ public class HjcBidProjectController extends BaseController { @Resource private HjcBidProjectService hjcBidProjectService; + @Resource + private HjcOrderService hjcOrderService; @Operation(summary = "分页查询(后台)") @GetMapping("/page") @@ -45,6 +48,9 @@ public class HjcBidProjectController extends BaseController { if (project == null) { return fail("标书项目不存在"); } + // 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退, + // 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。 + project.setBuyerCount(hjcOrderService.countPaidOrders(project.getId())); return success(project); } diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java index 20dab66..8dc55c6 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java @@ -17,11 +17,13 @@ import com.gxwebsoft.hjc.service.HjcBidProjectService; import com.gxwebsoft.hjc.service.HjcBizService; import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcOrderService; +import com.gxwebsoft.hjc.util.HjcOrderCancelUtil; import com.gxwebsoft.payment.dto.PaymentRequest; import com.gxwebsoft.payment.dto.PaymentResponse; import com.gxwebsoft.payment.enums.PaymentStatus; import com.gxwebsoft.payment.enums.PaymentType; import com.gxwebsoft.payment.service.PaymentService; +import com.wechat.pay.java.core.exception.ServiceException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; @@ -155,10 +157,91 @@ public class HjcOrderController extends BaseController { PaymentResponse resp = paymentService.createPayment(request); return success("发起支付成功", resp); } catch (com.gxwebsoft.payment.exception.PaymentException e) { - return fail("发起支付失败:" + e.getMessage()); + // 带上**微信原始错误码**(如 OUT_TRADE_NO_USED / APPID_MCHID_NOT_MATCH),前端据此给可行动的中文提示。 + // 为什么需要它:同一个订单号不能换支付类型重复下单——微信内(h5 → 小程序,JSAPI)与 PC(Native) + // 各发起一次时必现 OUT_TRADE_NO_USED,而"从订单列表回到旧单支付"这条路径正是本轮新开的。 + Map data = new HashMap<>(2); + data.put("wechatCode", wechatCodeOf(e)); + return fail("发起支付失败:" + e.getMessage(), data); } } + @Operation(summary = "买家取消自己的待支付订单(幂等;微信已收款则拒绝取消并自愈)") + @PostMapping("/cancel") + public ApiResult cancel(@RequestBody Map body) { + Integer userId = getLoginUserId(); + if (userId == null) { + return HjcAuthResponses.unauthorized(); + } + String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo")); + if (orderNo == null) { + return fail("订单号不能为空"); + } + HjcOrder order = hjcOrderService.getByOrderNo(orderNo); + if (order == null) { + return fail("订单不存在"); + } + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); + if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) { + return HjcAuthResponses.forbidden("无权操作该订单"); + } + + // 第一判:本地状态就能定论时,不必也不应去打微信(已取消是幂等出口,一个后续动作都不做) + HjcOrderCancelUtil.Decision decision = + HjcOrderCancelUtil.decide(order.getPayStatus(), order.getOrderStatus(), null); + if (decision == HjcOrderCancelUtil.Decision.ALREADY_CANCELLED) { + return success("已取消", cancelOutcome(order, true, false, true)); + } + if (decision == HjcOrderCancelUtil.Decision.ALREADY_PAID) { + return success("该订单已支付", cancelOutcome(order, false, true, true)); + } + + // 第二判:向微信查单。「其实已付款、只是本地还没落」只有这一步能挡住。 + String wechatStatus = queryWechatStatusQuietly(order); + decision = HjcOrderCancelUtil.decide(order.getPayStatus(), order.getOrderStatus(), wechatStatus); + if (decision == HjcOrderCancelUtil.Decision.ALREADY_PAID) { + // 微信说收款了,先把本地补齐(幂等,且会触发一站式推送——这才是**正确**的推送时机) + if (HjcOrderCancelUtil.needsSelfHeal(order.getPayStatus(), wechatStatus)) { + try { + hjcBizService.markPaid(orderNo); + } catch (Exception e) { + // 补写失败不改变结论:本次取消仍必须被拒绝 + log.warn("HjcOrder: 取消时发现微信已支付,补写本地状态失败 orderNo={}", orderNo, e); + } + } + HjcOrder latest = hjcOrderService.getByOrderNo(orderNo); + return success("该订单已支付", + cancelOutcome(latest == null ? order : latest, false, true, true)); + } + + // 事务内条件更新。失败一律按「取消失败」回复,不猜结果。 + try { + hjcBizService.cancelUnpaidOrder(orderNo); + } catch (Exception e) { + log.error("HjcOrder: 取消订单失败 orderNo={}", orderNo, e); + return fail("取消失败,请稍后重试"); + } + + // 事务外尽力关微信单:让那张支付链接失效,避免"已取消却仍能付款" + closeWechatOrderQuietly(order); + + // **以重新读到的状态为准**,而不是以条件更新的返回值或"我刚刚发了 UPDATE"为准: + // 条件更新影响 0 行可能是并发重复取消(此时已经是已取消,回复 cancelled=true 才对), + // 也可能是查单之后、更新之前有一笔支付落了库(此时应回复 paid=true)。 + HjcOrder latest = hjcOrderService.getByOrderNo(orderNo); + HjcOrder current = latest == null ? order : latest; + boolean cancelled = HjcOrderCancelUtil.isCancelled(current.getOrderStatus()); + boolean paid = HjcOrderCancelUtil.isLocallyPaid(current.getPayStatus()); + if (!cancelled && !paid) { + // 既没取消也没支付(例如期间被后台退款):如实回报,不替它下结论 + log.warn("HjcOrder: 取消未生效 orderNo={}, payStatus={}, orderStatus={}", + orderNo, current.getPayStatus(), current.getOrderStatus()); + return fail("取消未生效,请刷新后查看订单状态"); + } + return success(cancelled ? "已取消" : "该订单已支付", + cancelOutcome(current, cancelled, paid, HjcOrderCancelUtil.verified(wechatStatus))); + } + @Operation(summary = "标记订单已支付(支付成功后调用,幂等),并触发一站式推送") @PutMapping("/mark-paid") public ApiResult markPaid(@RequestBody Map body) { @@ -343,6 +426,78 @@ public class HjcOrderController extends BaseController { } } + /** + * 向微信查单,取状态名;**查不到结论时返回 null**(网络/配置/业务失败都算"没核对上"),不抛异常。 + * + *

为什么查单失败也放行取消:微信侧是唯一权威(ADR 0009),但权威**不可达**时如果拒绝取消, + * 这个功能在商户配置修好之前等于不存在(租户 10626 的商户号本来就没绑对小程序 appId)。 + * 代价是「其实已付款、只是本地没落」的单会被标成已取消——所以响应里带 {@code verified=false}, + * 把不确定性如实告诉用户,而不是替他下结论。

+ */ + private String queryWechatStatusQuietly(HjcOrder order) { + try { + PaymentResponse resp = paymentService.queryPayment( + order.getOrderNo(), PaymentType.WECHAT, order.getTenantId()); + return resp == null || resp.getPaymentStatus() == null ? null : resp.getPaymentStatus().name(); + } catch (Exception e) { + log.warn("HjcOrder: 取消前查单失败,本次按「未与微信核对」处理 orderNo={}", order.getOrderNo(), e); + return null; + } + } + + /** + * 尽力关闭微信侧订单(让那张支付链接失效,避免"已取消却仍能付款")。 + * + *

失败只记日志、不回滚取消:微信单可能已处于不可关状态(已付款 / 已过期), + * 因外部系统抖动把用户卡在他自己无法解决的失败态上,是更差的结果。

+ * + *

也**不做**"是否发起过支付"的前置判断:本地没有可靠依据,而"微信侧未下单"与"关单失败" + * 在错误码上难以区分,不值得为省一次调用引入一个会猜错的判断。

+ */ + private void closeWechatOrderQuietly(HjcOrder order) { + try { + paymentService.closeOrder(order.getOrderNo(), PaymentType.WECHAT, order.getTenantId()); + } catch (Exception e) { + log.warn("HjcOrder: 取消订单后关闭微信支付单失败(不影响取消结果)orderNo={}", order.getOrderNo(), e); + } + } + + /** 取消接口的结构化返回体(前端按字段判定,不解析 message) */ + private Map cancelOutcome(HjcOrder order, boolean cancelled, boolean paid, boolean verified) { + Map data = new HashMap<>(8); + data.put("orderNo", order.getOrderNo()); + data.put("cancelled", cancelled); + data.put("paid", paid); + data.put("verified", verified); + data.put("payStatus", order.getPayStatus()); + data.put("orderStatus", order.getOrderStatus()); + return data; + } + + /** + * 从支付异常里取**微信原始错误码**(如 {@code OUT_TRADE_NO_USED})。 + * + *

支付层把微信 SDK 的 {@code ServiceException} 原样挂在 cause 上—— + * {@code WechatNativeStrategy.java:467} 与 {@code WechatJsapiStrategy.java:115} 都把它传给了 + * 带 cause 的 {@code PaymentException} 构造器,而中途 {@code WechatNativeStrategy.java:141/:174}、 + * {@code PaymentServiceImpl.java:111/:182} 全是 {@code throw e}(原对象,未重新包装)。 + * 所以顺着 cause 链取即可:**不必**去 parse 异常 message 里的 JSON,也**不必**改动公共支付层 + * (AGENTS.md 要求改动收敛在 hjc 包内)。仓库内已有同款先例: + * {@code shop/service/impl/ShopOrderServiceImpl.java:414-419}。

+ * + * @return 取不到时返回 null(如 {@code paramError} 这类没有 cause 的异常) + */ + private String wechatCodeOf(Throwable e) { + Throwable cause = e; + while (cause != null) { + if (cause instanceof ServiceException) { + return ((ServiceException) cause).getErrorCode(); + } + cause = cause.getCause(); + } + return null; + } + private String firstNotBlank(String a, String b) { return a != null && !a.trim().isEmpty() ? a : b; } diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java index 476944d..b12dce6 100644 --- a/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java +++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java @@ -1,6 +1,7 @@ package com.gxwebsoft.hjc.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.fasterxml.jackson.annotation.JsonFormat; @@ -91,6 +92,20 @@ public class HjcBidProject implements Serializable { @Schema(description = "已售数量") private Integer saleCount; + /** + * 购买人数(**非表字段**):由订单表实时统计出的「已付款订单数」。 + * + *

为什么不复用 {@code saleCount}:它在**下单时**就 +quantity 且从不回退, + * 含未付款单、也已含被取消的单,还可能被后台 {@code PUT /api/hjc/bid-project} 任意改写—— + * 拿它当「购买人数」展示一直是个假数。本字段只在下单/付款之外被**读**,不落库。

+ * + *

口径:{@code COUNT(*) FROM hjc_order WHERE project_id=? AND pay_status=1 AND deleted=0}。 + * 口径与「人数」字面有偏差(同一企业买 2 单计 2),这是明确拍定的取值,不要"顺手修正"。

+ */ + @Schema(description = "购买人数(实时统计的已付款订单数,非表字段)") + @TableField(exist = false) + private Integer buyerCount; + @Schema(description = "租户ID") private Integer tenantId; diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java b/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java index 669e66e..23ab276 100644 --- a/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java +++ b/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java @@ -20,4 +20,24 @@ public interface HjcOrderMapper extends BaseMapper { */ @InterceptorIgnore(tenantLine = "true") HjcOrder getByOrderNo(@Param("orderNo") String orderNo); + + /** + * 把「未支付且未取消」的订单置为已取消(order_status=2)。 + * + *

条件更新是取消动作**幂等的唯一保证**:只有把状态从 0 翻到 2 的那一次调用会返回 1。 + * 因此它必须返回**影响行数**——{@code updateById(entity)} 那条路拿不到行数,故单独写一条 XML update。

+ * + *

{@code pay_status = 0} 与 {@code order_status = 0} 同时作为条件,避免把已支付或已取消的单改掉。

+ * + * @return 实际影响行数(0 或 1) + */ + int cancelPending(@Param("orderNo") String orderNo); + + /** + * 统计某标书项目**已付款**的订单数(用于标书详情展示「购买人数」)。 + * + *

口径:{@code COUNT(*) WHERE project_id=? AND pay_status=1 AND deleted=0}。 + * 退款单(pay_status=3)与已取消单(pay_status 仍为 0)都不计。

+ */ + int countPaidOrders(@Param("projectId") Integer projectId); } diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcOrderMapper.xml b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcOrderMapper.xml index f46b86b..e22b7bd 100644 --- a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcOrderMapper.xml +++ b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcOrderMapper.xml @@ -61,4 +61,33 @@ LIMIT 1 + + + UPDATE hjc_order + SET order_status = 2, + update_time = NOW() + WHERE order_no = #{orderNo} + AND pay_status = 0 + AND order_status = 0 + AND deleted = 0 + + + + + diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java index 778961c..ba91a94 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcBizService.java @@ -29,6 +29,21 @@ public interface HjcBizService { */ HjcOrder refund(String orderNo, String refundReason); + /** + * 取消未支付订单:条件更新把 {@code order_status} 置为 2(幂等)。 + * + *

只做 DB 变更:向微信查单与关单都是外部 IO(可能几十秒超时), + * 放进事务会长时间占着数据库连接与行锁,因此由调用方在事务外编排。

+ * + *

绝不推送一站式:出向状态只有 {@code PAID}/{@code REFUNDED},而 + * {@code buildCreatePurchaseDetails} 对非退款单**恒发** {@code PAID}—— + * 在取消路径调用 {@code pushOrderToOneStop} 会把一张一分钱没付的单推成「已支付」采购记录。 + * 未支付订单从未被推送过(推送只在 markPaid / refund 触发),所以取消本来也不需要推送。

+ * + * @return 本次是否真的翻转(true = 把状态从 0 改成了 2)。重复调用返回 false。 + */ + boolean cancelUnpaidOrder(String orderNo); + /** * 重试失败的推送(幂等) */ diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java index 39d520c..ec8d5b0 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java @@ -14,4 +14,16 @@ public interface HjcOrderService extends IService { List listRel(HjcOrderParam param); HjcOrder getByOrderNo(String orderNo); + + /** + * 把「未支付且未取消」的订单置为已取消(条件更新,幂等)。 + * + * @return 实际影响行数(0 或 1);只有把状态从 0 翻到 2 的那一次会返回 1 + */ + int cancelPending(String orderNo); + + /** + * 统计某标书项目**已付款**的订单数(标书详情的「购买人数」)。 + */ + int countPaidOrders(Integer projectId); } diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java index 9649b45..8cdaca0 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java @@ -170,6 +170,15 @@ public class HjcBizServiceImpl implements HjcBizService { return order; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean cancelUnpaidOrder(String orderNo) { + // 条件更新自带幂等与安全:只有把 order_status 从 0 翻到 2 的那一次会返回 1 行; + // 已支付(pay_status=1/3)与已取消(order_status=2)的单都不会被改到。 + // 这里**不推送一站式**,理由见 HjcBizService#cancelUnpaidOrder 的接口注释。 + return hjcOrderService.cancelPending(orderNo) > 0; + } + private void doPush(HjcOrder order, HjcOrderPushLog logEntity) { try { CreatePurchaseDetails body = buildCreatePurchaseDetails(order); diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java index c4fac80..3a412ad 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java @@ -31,4 +31,17 @@ public class HjcOrderServiceImpl extends ServiceImpl i public HjcOrder getByOrderNo(String orderNo) { return baseMapper.getByOrderNo(orderNo); } + + @Override + public int cancelPending(String orderNo) { + return baseMapper.cancelPending(orderNo); + } + + @Override + public int countPaidOrders(Integer projectId) { + if (projectId == null) { + return 0; + } + return baseMapper.countPaidOrders(projectId); + } } diff --git a/src/main/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtil.java b/src/main/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtil.java new file mode 100644 index 0000000..9675d82 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtil.java @@ -0,0 +1,91 @@ +package com.gxwebsoft.hjc.util; + +import java.util.Objects; + +/** + * 取消订单的判定逻辑(纯函数,不依赖 Spring)。 + * + *

为什么单独抽出来:本功能里最危险的一支是「其实已付款、却被买家点了取消」。 + * 要挡住它,唯一的时机是**取消之前向微信查单**;而微信说 SUCCESS 这一支在 dev 环境里 + * 造不出来(要真付一笔钱),只能靠单测钉住判定本身。所以判定必须是不依赖 Spring 的静态方法。

+ * + *

为什么放两个"已支付"出口:本地 {@code pay_status == 1} 是已落库的事实, + * 不必也不应再去打微信;微信侧 SUCCESS 是"本地还没落、但钱已经收了",除了拒绝取消, + * 调用方还要顺手调 {@code markPaid} 把本地补齐(幂等)。两者结论相同、后续动作不同, + * 所以调用方拿到的是同一个 {@link Decision#ALREADY_PAID},由它自己按本地状态决定要不要自愈。

+ */ +public final class HjcOrderCancelUtil { + + private HjcOrderCancelUtil() { + } + + /** 微信侧「已收款」的状态名({@code PaymentStatus.SUCCESS})。 */ + public static final String WECHAT_STATUS_SUCCESS = "SUCCESS"; + + /** 订单状态:2 = 已取消(见 {@code hjc_order.order_status} 的列注释)。 */ + private static final int ORDER_STATUS_CANCELLED = 2; + + /** 支付状态:1 = 支付成功(见 {@code hjc_order.pay_status} 的列注释)。 */ + private static final int PAY_STATUS_PAID = 1; + + /** 取消请求的判定结论。 */ + public enum Decision { + /** 已经是已取消:幂等出口,调用方不得再做任何后续动作(尤其不要再查单、再关单) */ + ALREADY_CANCELLED, + /** 已支付(本地已落库,或微信侧已确认收款):拒绝取消 */ + ALREADY_PAID, + /** 放行取消 */ + PROCEED + } + + /** + * 判定这一次取消请求该怎么走。 + * + *

调用方应当调用它两次:第一次传 {@code wechatStatus = null}(还没查单), + * 若结论不是 {@link Decision#PROCEED} 就直接返回,从而避免一次无谓的微信调用; + * 查单之后再调用一次,用微信侧的结论定案。

+ * + * @param payStatus 本地支付状态,可为 null + * @param orderStatus 本地订单状态,可为 null + * @param wechatStatus 微信侧状态名({@code PaymentStatus} 的 name);**没查到结论时传 null** + * @return 判定结论 + */ + public static Decision decide(Integer payStatus, Integer orderStatus, String wechatStatus) { + // 已取消优先于一切:重复取消必须走幂等出口,不能因为"本地还没支付"就重新走一遍查单/关单 + if (isCancelled(orderStatus)) { + return Decision.ALREADY_CANCELLED; + } + if (isLocallyPaid(payStatus)) { + return Decision.ALREADY_PAID; + } + if (WECHAT_STATUS_SUCCESS.equals(wechatStatus)) { + return Decision.ALREADY_PAID; + } + return Decision.PROCEED; + } + + /** + * 本次取消是否「与微信核对过」。 + * + *

拿到状态名才算核对过:查单抛异常、或微信返回体里没有状态,都只能算**没核对上**。 + * 未核对上时仍然放行取消(否则在商户配置修好之前,这个功能等于不存在), + * 但响应里要如实带上 {@code verified=false},让前端把不确定性告诉用户, + * 而不是替微信下结论(与 ADR 0009「以微信侧为准」同源)。

+ */ + public static boolean verified(String wechatStatus) { + return wechatStatus != null && !wechatStatus.trim().isEmpty(); + } + + /** 判定调用方是否需要顺手把本地订单补成已支付(微信已收款、但本地还没落库)。 */ + public static boolean needsSelfHeal(Integer payStatus, String wechatStatus) { + return !isLocallyPaid(payStatus) && WECHAT_STATUS_SUCCESS.equals(wechatStatus); + } + + public static boolean isCancelled(Integer orderStatus) { + return Objects.equals(orderStatus, ORDER_STATUS_CANCELLED); + } + + public static boolean isLocallyPaid(Integer payStatus) { + return Objects.equals(payStatus, PAY_STATUS_PAID); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerCancelTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerCancelTest.java new file mode 100644 index 0000000..95e6d15 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerCancelTest.java @@ -0,0 +1,314 @@ +package com.gxwebsoft.hjc.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.hjc.entity.HjcEnterprise; +import com.gxwebsoft.hjc.entity.HjcOrder; +import com.gxwebsoft.hjc.service.HjcBizService; +import com.gxwebsoft.hjc.service.HjcEnterpriseService; +import com.gxwebsoft.hjc.service.HjcOrderService; +import com.gxwebsoft.payment.dto.PaymentResponse; +import com.gxwebsoft.payment.enums.PaymentStatus; +import com.gxwebsoft.payment.enums.PaymentType; +import com.gxwebsoft.payment.exception.PaymentException; +import com.gxwebsoft.payment.service.PaymentService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 取消订单接口的**编排**测试(不是纯函数测试,那些在 {@code HjcOrderCancelUtilTest})。 + * + *

为什么需要它:这个接口最要紧的几条分支在真实环境里验不了—— + * 需要登录态(我没有可用账号)、需要真付一笔钱(微信说 SUCCESS 那一支)、 + * 需要两个企业账号(越权那一支,dev 库只有一个企业)。 + * 用 mock 把 service 换掉,就能把"先查单、查到了怎么办、关单失败怎么办、 + * 以及**绝不推送一站式**"这些编排断言钉住。

+ * + *

{@code getLoginUserId()} 读的是 {@code SecurityContextHolder},所以登录态可以用 + * 真实的 {@link User} 放进 SecurityContext 来模拟,不必绕过鉴权。

+ */ +class HjcOrderControllerCancelTest { + + private static final String ORDER_NO = "HJC202609121846255270"; + private static final int BUYER_USER_ID = 9001; + private static final int BUYER_ENTERPRISE_ID = 7001; + + private HjcOrderController controller; + private HjcOrderService hjcOrderService; + private HjcBizService hjcBizService; + private HjcEnterpriseService hjcEnterpriseService; + private PaymentService paymentService; + + @BeforeEach + void setUp() throws Exception { + controller = new HjcOrderController(); + hjcOrderService = mock(HjcOrderService.class); + hjcBizService = mock(HjcBizService.class); + hjcEnterpriseService = mock(HjcEnterpriseService.class); + paymentService = mock(PaymentService.class); + + inject("hjcOrderService", hjcOrderService); + inject("hjcBizService", hjcBizService); + inject("hjcEnterpriseService", hjcEnterpriseService); + inject("paymentService", paymentService); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void inject(String field, Object value) throws Exception { + Field f = HjcOrderController.class.getDeclaredField(field); + f.setAccessible(true); + f.set(controller, value); + } + + /** 把「已登录的买家」放进 SecurityContext——getLoginUserId() 就是从这儿取的 */ + private void loginAsBuyer() { + User u = new User(); + u.setUserId(BUYER_USER_ID); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(u, null, Collections.emptyList())); + } + + private void stubOwnEnterprise() { + HjcEnterprise e = new HjcEnterprise(); + e.setId(BUYER_ENTERPRISE_ID); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e); + } + + private HjcOrder order(Integer payStatus, Integer orderStatus) { + HjcOrder o = new HjcOrder(); + o.setOrderNo(ORDER_NO); + o.setEnterpriseId(BUYER_ENTERPRISE_ID); + o.setPayStatus(payStatus); + o.setOrderStatus(orderStatus); + o.setTotalAmount(java.math.BigDecimal.TEN); + return o; + } + + /** 让后端读回一个指定状态的订单(用于模拟"条件更新之后状态又变了") */ + private void stubReadBack(HjcOrder... sequence) { + when(hjcOrderService.getByOrderNo(ORDER_NO)).thenReturn(sequence[0], + java.util.Arrays.copyOfRange(sequence, 1, sequence.length)); + } + + private Map body() { + Map b = new HashMap<>(2); + b.put("orderNo", ORDER_NO); + return b; + } + + @SuppressWarnings("unchecked") + private Map dataOf(ApiResult res) { + return (Map) res.getData(); + } + + // ---------- 鉴权与归属 ---------- + + @Test + void anonymousShouldGet401() { + ApiResult res = controller.cancel(body()); + + assertEquals(401, res.getCode(), "未登录必须是 401(前端据此清凭据跳登录)"); + verify(hjcOrderService, never()).getByOrderNo(anyString()); + } + + @Test + void missingOrderNoShouldFail() { + loginAsBuyer(); + + assertEquals(1, controller.cancel(new HashMap<>()).getCode()); + } + + @Test + void notFoundShouldFail() { + loginAsBuyer(); + when(hjcOrderService.getByOrderNo(ORDER_NO)).thenReturn((HjcOrder) null); + + ApiResult res = controller.cancel(body()); + + assertEquals(1, res.getCode()); + assertEquals("订单不存在", res.getMessage()); + } + + @Test + void otherEnterpriseOrderShouldGet403() { + loginAsBuyer(); + when(hjcOrderService.getByOrderNo(ORDER_NO)).thenReturn(order(0, 0)); + HjcEnterprise other = new HjcEnterprise(); + other.setId(9999); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(other); + + ApiResult res = controller.cancel(body()); + + assertEquals(403, res.getCode(), "越权是 403(不清凭据),不是 401"); + verify(hjcBizService, never()).cancelUnpaidOrder(anyString()); + } + + // ---------- 本地状态即可定论:一次微信调用都不该发生 ---------- + + @Test + void alreadyCancelledShouldBeIdempotentAndTouchNothing() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + when(hjcOrderService.getByOrderNo(ORDER_NO)).thenReturn(order(0, 2)); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.TRUE, dataOf(res).get("cancelled")); + verify(hjcBizService, never()).cancelUnpaidOrder(anyString()); + verify(paymentService, never()).queryPayment(anyString(), any(), any()); + } + + @Test + void locallyPaidShouldRefuseWithoutQueryingWechat() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + when(hjcOrderService.getByOrderNo(ORDER_NO)).thenReturn(order(1, 1)); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.FALSE, dataOf(res).get("cancelled")); + assertEquals(Boolean.TRUE, dataOf(res).get("paid")); + verify(paymentService, never()).queryPayment(anyString(), any(), any()); + verify(hjcBizService, never()).cancelUnpaidOrder(anyString()); + } + + // ---------- 微信侧结论 ---------- + + @Test + void wechatSuccessShouldRefuseCancelAndSelfHeal() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + // 第一次读:本地还是待支付;第二次读(自愈之后):已支付 + stubReadBack(order(0, 0), order(1, 1)); + PaymentResponse wechat = PaymentResponse.success(ORDER_NO, PaymentType.WECHAT); + wechat.setPaymentStatus(PaymentStatus.SUCCESS); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)).thenReturn(wechat); + when(hjcBizService.markPaid(ORDER_NO)).thenReturn(order(1, 1)); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.FALSE, dataOf(res).get("cancelled"), "微信已收款就不能取消"); + assertEquals(Boolean.TRUE, dataOf(res).get("paid")); + verify(hjcBizService, times(1)).markPaid(ORDER_NO); + verify(hjcBizService, never()).cancelUnpaidOrder(anyString()); + // 微信已收款的单不该去关单(关单是"让待付的码失效",这里已经没有待付的码了) + verify(paymentService, never()).closeOrder(anyString(), any(), any()); + // **推送禁区**:markPaid 自己会推送(那是正确时机),但取消路径绝不许再推一次 + verify(hjcBizService, never()).pushOrderToOneStop(any()); + } + + @Test + void wechatPendingShouldProceedAndMarkVerified() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + stubReadBack(order(0, 0), order(0, 2)); + PaymentResponse wechat = PaymentResponse.success(ORDER_NO, PaymentType.WECHAT); + wechat.setPaymentStatus(PaymentStatus.PENDING); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)).thenReturn(wechat); + when(hjcBizService.cancelUnpaidOrder(ORDER_NO)).thenReturn(true); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.TRUE, dataOf(res).get("cancelled")); + assertEquals(Boolean.TRUE, dataOf(res).get("verified"), "查到了结论就是核对过"); + assertEquals(2, dataOf(res).get("orderStatus")); + verify(paymentService, times(1)).closeOrder(ORDER_NO, PaymentType.WECHAT, null); + verify(hjcBizService, never()).markPaid(anyString()); + // 取消路径不许推送:buildCreatePurchaseDetails 对非退款单恒发 PAID,推了就是假的已支付记录 + verify(hjcBizService, never()).pushOrderToOneStop(any()); + } + + @Test + void queryFailureShouldStillCancelButReportUnverified() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + stubReadBack(order(0, 0), order(0, 2)); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)) + .thenThrow(new PaymentException("微信不可达")); + when(hjcBizService.cancelUnpaidOrder(ORDER_NO)).thenReturn(true); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode(), "查单失败不能阻断取消,否则功能在配置修好前不存在"); + assertEquals(Boolean.TRUE, dataOf(res).get("cancelled")); + assertEquals(Boolean.FALSE, dataOf(res).get("verified"), "没核对上就必须如实说没核对上"); + } + + @Test + void closeOrderFailureShouldNotBreakTheCancelResult() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + stubReadBack(order(0, 0), order(0, 2)); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)) + .thenReturn((PaymentResponse) null); + when(hjcBizService.cancelUnpaidOrder(ORDER_NO)).thenReturn(true); + when(paymentService.closeOrder(ORDER_NO, PaymentType.WECHAT, null)) + .thenThrow(new PaymentException("关单失败")); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode(), "关单失败不回滚取消"); + assertEquals(Boolean.TRUE, dataOf(res).get("cancelled")); + assertEquals(Boolean.FALSE, dataOf(res).get("verified")); + } + + // ---------- 以重新读到的状态为准 ---------- + + @Test + void resultFollowsTheReReadStateNotTheUpdateReturnValue() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + // 条件更新说成功,但读回来已是已取消 —— 并发重复取消,回复 cancelled=true 才对 + stubReadBack(order(0, 0), order(0, 2)); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)).thenReturn((PaymentResponse) null); + when(hjcBizService.cancelUnpaidOrder(ORDER_NO)).thenReturn(false); + + ApiResult res = controller.cancel(body()); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.TRUE, dataOf(res).get("cancelled")); + } + + @Test + void ifStateIsNeitherCancelledNorPaidAfterUpdateReportFailure() throws Exception { + loginAsBuyer(); + stubOwnEnterprise(); + // 极端情况:查单之后、更新之前被后台退款了(pay_status=3) + stubReadBack(order(0, 0), order(3, 0)); + when(paymentService.queryPayment(ORDER_NO, PaymentType.WECHAT, null)).thenReturn((PaymentResponse) null); + when(hjcBizService.cancelUnpaidOrder(ORDER_NO)).thenReturn(false); + + ApiResult res = controller.cancel(body()); + + assertEquals(1, res.getCode(), "既没取消也没支付时不许替它下结论"); + assertNotNull(res.getMessage()); + assertTrue(res.getMessage().contains("取消未生效"), res.getMessage()); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtilTest.java b/src/test/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtilTest.java new file mode 100644 index 0000000..5ecdb98 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/util/HjcOrderCancelUtilTest.java @@ -0,0 +1,114 @@ +package com.gxwebsoft.hjc.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 取消订单判定逻辑的单测。 + * + *

这几条断言在**端到端里造不出来**:要验「微信说 SUCCESS 时拒绝取消」就得真付一笔钱; + * 而这一支恰恰是本功能里最危险的一支("其实已付款、却被买家点了取消")。 + * 所以它是本轮唯一可靠的防线,不要因为"逻辑简单"就删掉。

+ */ +class HjcOrderCancelUtilTest { + + private static final String SUCCESS = "SUCCESS"; + + // ---------- 决策:本地状态即可定论 ---------- + + @Test + void alreadyCancelledShouldWinOverEverythingElse() { + // 已取消 + 已支付 这种矛盾组合也要走幂等出口:重复取消不得再触发任何后续动作 + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_CANCELLED, + HjcOrderCancelUtil.decide(0, 2, null)); + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_CANCELLED, + HjcOrderCancelUtil.decide(1, 2, null), + "已取消优先于已支付:幂等出口不能被别的条件抢走"); + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_CANCELLED, + HjcOrderCancelUtil.decide(3, 2, SUCCESS)); + } + + @Test + void locallyPaidShouldRefuseCancel() { + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_PAID, + HjcOrderCancelUtil.decide(1, 0, null)); + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_PAID, + HjcOrderCancelUtil.decide(1, 0, SUCCESS)); + } + + // ---------- 决策:微信侧结论 ---------- + + @Test + void wechatSuccessShouldRefuseCancel() { + // 本地还没落库、但微信侧已收款 —— 这一步是唯一的拦截点 + assertEquals(HjcOrderCancelUtil.Decision.ALREADY_PAID, + HjcOrderCancelUtil.decide(0, 0, SUCCESS)); + assertTrue(HjcOrderCancelUtil.needsSelfHeal(0, SUCCESS), + "微信已收款而本地未落库时,调用方要顺手把本地补齐"); + } + + @Test + void wechatNonSuccessStatusesShouldProceed() { + for (String status : new String[]{"PENDING", "PROCESSING", "FAILED", "CANCELLED", "TIMEOUT", "REFUND_FAILED"}) { + assertEquals(HjcOrderCancelUtil.Decision.PROCEED, + HjcOrderCancelUtil.decide(0, 0, status), "微信侧 " + status + " 应放行取消"); + assertTrue(HjcOrderCancelUtil.verified(status), status + " 是核对过的结论"); + } + } + + @Test + void queryFailureShouldStillProceedButBeMarkedUnverified() { + // 查单抛异常 → 调用方传 null + assertEquals(HjcOrderCancelUtil.Decision.PROCEED, HjcOrderCancelUtil.decide(0, 0, null), + "微信不可达时必须放行,否则这个功能在商户配置修好前等于不存在"); + assertFalse(HjcOrderCancelUtil.verified(null), + "没核对上就要如实说没核对上,前端据此提示用户,而不是替他下结论"); + } + + @Test + void nullLocalStatusesShouldBeTreatedAsPending() { + // 理论上不会出现 null(建单即写 0/0),但历史数据/人工改库都可能有; + // 此时按"待支付"处理并放行,是与其他分支一致的兜底 + assertEquals(HjcOrderCancelUtil.Decision.PROCEED, HjcOrderCancelUtil.decide(null, null, null)); + assertEquals(HjcOrderCancelUtil.Decision.PROCEED, HjcOrderCancelUtil.decide(null, 0, null)); + } + + // ---------- verified ---------- + + @Test + void verifiedShouldTreatBlankAsUnverified() { + assertTrue(HjcOrderCancelUtil.verified(SUCCESS)); + assertFalse(HjcOrderCancelUtil.verified(null)); + assertFalse(HjcOrderCancelUtil.verified("")); + assertFalse(HjcOrderCancelUtil.verified(" ")); + } + + // ---------- 自愈判定 ---------- + + @Test + void needsSelfHealOnlyWhenWechatSaysSuccessAndLocalIsNotPaid() { + assertTrue(HjcOrderCancelUtil.needsSelfHeal(0, SUCCESS)); + assertTrue(HjcOrderCancelUtil.needsSelfHeal(null, SUCCESS)); + assertFalse(HjcOrderCancelUtil.needsSelfHeal(1, SUCCESS), "本地已是已支付,无需再自愈"); + assertFalse(HjcOrderCancelUtil.needsSelfHeal(0, "PENDING")); + assertFalse(HjcOrderCancelUtil.needsSelfHeal(0, null), "没核对上就谈不上自愈"); + } + + // ---------- 状态判定 ---------- + + @Test + void cancelledAndPaidPredicatesShouldOnlyAcceptTheirOwnCode() { + assertTrue(HjcOrderCancelUtil.isCancelled(2)); + assertFalse(HjcOrderCancelUtil.isCancelled(0)); + assertFalse(HjcOrderCancelUtil.isCancelled(1)); + assertFalse(HjcOrderCancelUtil.isCancelled(null)); + + assertTrue(HjcOrderCancelUtil.isLocallyPaid(1)); + assertFalse(HjcOrderCancelUtil.isLocallyPaid(0)); + assertFalse(HjcOrderCancelUtil.isLocallyPaid(3), "已退款不是「已支付」"); + assertFalse(HjcOrderCancelUtil.isLocallyPaid(null)); + } +}