feat(hjc): 买家可取消待支付订单;标书详情「购买人数」改实时统计

取消订单(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 <update>——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,含推送禁区回归守卫)
This commit is contained in:
2026-09-17 03:39:53 +08:00
parent fdff54a6cf
commit 13cb08621f
12 changed files with 794 additions and 1 deletions
@@ -6,6 +6,7 @@ import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -23,6 +24,8 @@ public class HjcBidProjectController extends BaseController {
@Resource @Resource
private HjcBidProjectService hjcBidProjectService; private HjcBidProjectService hjcBidProjectService;
@Resource
private HjcOrderService hjcOrderService;
@Operation(summary = "分页查询(后台)") @Operation(summary = "分页查询(后台)")
@GetMapping("/page") @GetMapping("/page")
@@ -45,6 +48,9 @@ public class HjcBidProjectController extends BaseController {
if (project == null) { if (project == null) {
return fail("标书项目不存在"); return fail("标书项目不存在");
} }
// 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退,
// 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。
project.setBuyerCount(hjcOrderService.countPaidOrders(project.getId()));
return success(project); return success(project);
} }
@@ -17,11 +17,13 @@ import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcBizService; import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService; import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.util.HjcOrderCancelUtil;
import com.gxwebsoft.payment.dto.PaymentRequest; import com.gxwebsoft.payment.dto.PaymentRequest;
import com.gxwebsoft.payment.dto.PaymentResponse; import com.gxwebsoft.payment.dto.PaymentResponse;
import com.gxwebsoft.payment.enums.PaymentStatus; import com.gxwebsoft.payment.enums.PaymentStatus;
import com.gxwebsoft.payment.enums.PaymentType; import com.gxwebsoft.payment.enums.PaymentType;
import com.gxwebsoft.payment.service.PaymentService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -155,10 +157,91 @@ public class HjcOrderController extends BaseController {
PaymentResponse resp = paymentService.createPayment(request); PaymentResponse resp = paymentService.createPayment(request);
return success("发起支付成功", resp); return success("发起支付成功", resp);
} catch (com.gxwebsoft.payment.exception.PaymentException e) { } catch (com.gxwebsoft.payment.exception.PaymentException e) {
return fail("发起支付失败:" + e.getMessage()); // 带上**微信原始错误码**(如 OUT_TRADE_NO_USED / APPID_MCHID_NOT_MATCH),前端据此给可行动的中文提示。
// 为什么需要它:同一个订单号不能换支付类型重复下单——微信内(h5 → 小程序,JSAPI)与 PCNative
// 各发起一次时必现 OUT_TRADE_NO_USED,而"从订单列表回到旧单支付"这条路径正是本轮新开的。
Map<String, Object> data = new HashMap<>(2);
data.put("wechatCode", wechatCodeOf(e));
return fail("发起支付失败:" + e.getMessage(), data);
} }
} }
@Operation(summary = "买家取消自己的待支付订单(幂等;微信已收款则拒绝取消并自愈)")
@PostMapping("/cancel")
public ApiResult<?> cancel(@RequestBody Map<String, Object> 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 = "标记订单已支付(支付成功后调用,幂等),并触发一站式推送") @Operation(summary = "标记订单已支付(支付成功后调用,幂等),并触发一站式推送")
@PutMapping("/mark-paid") @PutMapping("/mark-paid")
public ApiResult<?> markPaid(@RequestBody Map<String, Object> body) { public ApiResult<?> markPaid(@RequestBody Map<String, Object> body) {
@@ -343,6 +426,78 @@ public class HjcOrderController extends BaseController {
} }
} }
/**
* 向微信查单,取状态名;**查不到结论时返回 null**(网络/配置/业务失败都算"没核对上"),不抛异常。
*
* <p><b>为什么查单失败也放行取消</b>:微信侧是唯一权威(ADR 0009),但权威**不可达**时如果拒绝取消,
* 这个功能在商户配置修好之前等于不存在(租户 10626 的商户号本来就没绑对小程序 appId)。
* 代价是「其实已付款、只是本地没落」的单会被标成已取消——所以响应里带 {@code verified=false}
* 把不确定性如实告诉用户,而不是替他下结论。</p>
*/
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;
}
}
/**
* 尽力关闭微信侧订单(让那张支付链接失效,避免"已取消却仍能付款")。
*
* <p><b>失败只记日志、不回滚取消</b>:微信单可能已处于不可关状态(已付款 / 已过期),
* 因外部系统抖动把用户卡在他自己无法解决的失败态上,是更差的结果。</p>
*
* <p>也**不做**"是否发起过支付"的前置判断:本地没有可靠依据,而"微信侧未下单"与"关单失败"
* 在错误码上难以区分,不值得为省一次调用引入一个会猜错的判断。</p>
*/
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<String, Object> cancelOutcome(HjcOrder order, boolean cancelled, boolean paid, boolean verified) {
Map<String, Object> 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})。
*
* <p>支付层把微信 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}。</p>
*
* @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) { private String firstNotBlank(String a, String b) {
return a != null && !a.trim().isEmpty() ? a : b; return a != null && !a.trim().isEmpty() ? a : b;
} }
@@ -1,6 +1,7 @@
package com.gxwebsoft.hjc.entity; package com.gxwebsoft.hjc.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
@@ -91,6 +92,20 @@ public class HjcBidProject implements Serializable {
@Schema(description = "已售数量") @Schema(description = "已售数量")
private Integer saleCount; private Integer saleCount;
/**
* 购买人数(**非表字段**):由订单表实时统计出的「已付款订单数」。
*
* <p>为什么不复用 {@code saleCount}:它在**下单时**就 +quantity 且从不回退,
* 含未付款单、也已含被取消的单,还可能被后台 {@code PUT /api/hjc/bid-project} 任意改写——
* 拿它当「购买人数」展示一直是个假数。本字段只在下单/付款之外被**读**,不落库。</p>
*
* <p>口径:{@code COUNT(*) FROM hjc_order WHERE project_id=? AND pay_status=1 AND deleted=0}。
* 口径与「人数」字面有偏差(同一企业买 2 单计 2),这是明确拍定的取值,不要"顺手修正"。</p>
*/
@Schema(description = "购买人数(实时统计的已付款订单数,非表字段)")
@TableField(exist = false)
private Integer buyerCount;
@Schema(description = "租户ID") @Schema(description = "租户ID")
private Integer tenantId; private Integer tenantId;
@@ -20,4 +20,24 @@ public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
*/ */
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
HjcOrder getByOrderNo(@Param("orderNo") String orderNo); HjcOrder getByOrderNo(@Param("orderNo") String orderNo);
/**
* 把「未支付且未取消」的订单置为已取消(order_status=2)。
*
* <p>条件更新是取消动作**幂等的唯一保证**:只有把状态从 0 翻到 2 的那一次调用会返回 1。
* 因此它必须返回**影响行数**——{@code updateById(entity)} 那条路拿不到行数,故单独写一条 XML update。</p>
*
* <p>{@code pay_status = 0} 与 {@code order_status = 0} 同时作为条件,避免把已支付或已取消的单改掉。</p>
*
* @return 实际影响行数(0 或 1
*/
int cancelPending(@Param("orderNo") String orderNo);
/**
* 统计某标书项目**已付款**的订单数(用于标书详情展示「购买人数」)。
*
* <p>口径:{@code COUNT(*) WHERE project_id=? AND pay_status=1 AND deleted=0}。
* 退款单(pay_status=3)与已取消单(pay_status 仍为 0)都不计。</p>
*/
int countPaidOrders(@Param("projectId") Integer projectId);
} }
@@ -61,4 +61,33 @@
LIMIT 1 LIMIT 1
</select> </select>
<!--
取消待支付订单:条件更新,返回影响行数。
`pay_status = 0 AND order_status = 0` 是幂等与安全的双重保证:
- 重复调用时第二次影响 0 行,不会重复产生副作用;
- 已支付(pay_status=1/3)与已取消(order_status=2)的单都不会被改到。
这里**不加** `@InterceptorIgnore(tenantLine)`:调用方是已登录买家、且已按 enterpriseId 校验过归属,
租户条件留着多一层保护,没有代价。
-->
<update id="cancelPending">
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
</update>
<!-- 标书详情「购买人数」:已付款订单数(退款单与已取消单都不计) -->
<select id="countPaidOrders" resultType="int">
SELECT COUNT(*)
FROM hjc_order
WHERE project_id = #{projectId}
AND pay_status = 1
AND deleted = 0
</select>
</mapper> </mapper>
@@ -29,6 +29,21 @@ public interface HjcBizService {
*/ */
HjcOrder refund(String orderNo, String refundReason); HjcOrder refund(String orderNo, String refundReason);
/**
* 取消未支付订单:条件更新把 {@code order_status} 置为 2(幂等)。
*
* <p><b>只做 DB 变更</b>:向微信查单与关单都是外部 IO(可能几十秒超时),
* 放进事务会长时间占着数据库连接与行锁,因此由调用方在事务外编排。</p>
*
* <p><b>绝不推送一站式</b>:出向状态只有 {@code PAID}/{@code REFUNDED},而
* {@code buildCreatePurchaseDetails} 对非退款单**恒发** {@code PAID}——
* 在取消路径调用 {@code pushOrderToOneStop} 会把一张一分钱没付的单推成「已支付」采购记录。
* 未支付订单从未被推送过(推送只在 markPaid / refund 触发),所以取消本来也不需要推送。</p>
*
* @return 本次是否真的翻转(true = 把状态从 0 改成了 2)。重复调用返回 false。
*/
boolean cancelUnpaidOrder(String orderNo);
/** /**
* 重试失败的推送(幂等) * 重试失败的推送(幂等)
*/ */
@@ -14,4 +14,16 @@ public interface HjcOrderService extends IService<HjcOrder> {
List<HjcOrder> listRel(HjcOrderParam param); List<HjcOrder> listRel(HjcOrderParam param);
HjcOrder getByOrderNo(String orderNo); HjcOrder getByOrderNo(String orderNo);
/**
* 把「未支付且未取消」的订单置为已取消(条件更新,幂等)。
*
* @return 实际影响行数(0 或 1);只有把状态从 0 翻到 2 的那一次会返回 1
*/
int cancelPending(String orderNo);
/**
* 统计某标书项目**已付款**的订单数(标书详情的「购买人数」)。
*/
int countPaidOrders(Integer projectId);
} }
@@ -170,6 +170,15 @@ public class HjcBizServiceImpl implements HjcBizService {
return order; 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) { private void doPush(HjcOrder order, HjcOrderPushLog logEntity) {
try { try {
CreatePurchaseDetails body = buildCreatePurchaseDetails(order); CreatePurchaseDetails body = buildCreatePurchaseDetails(order);
@@ -31,4 +31,17 @@ public class HjcOrderServiceImpl extends ServiceImpl<HjcOrderMapper, HjcOrder> i
public HjcOrder getByOrderNo(String orderNo) { public HjcOrder getByOrderNo(String orderNo) {
return baseMapper.getByOrderNo(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);
}
} }
@@ -0,0 +1,91 @@
package com.gxwebsoft.hjc.util;
import java.util.Objects;
/**
* 取消订单的判定逻辑(纯函数,不依赖 Spring)。
*
* <p><b>为什么单独抽出来</b>:本功能里最危险的一支是「其实已付款、却被买家点了取消」。
* 要挡住它,唯一的时机是**取消之前向微信查单**;而微信说 SUCCESS 这一支在 dev 环境里
* <b>造不出来</b>(要真付一笔钱),只能靠单测钉住判定本身。所以判定必须是不依赖 Spring 的静态方法。</p>
*
* <p><b>为什么放两个"已支付"出口</b>:本地 {@code pay_status == 1} 是已落库的事实,
* 不必也不应再去打微信;微信侧 SUCCESS 是"本地还没落、但钱已经收了",除了拒绝取消,
* 调用方还要顺手调 {@code markPaid} 把本地补齐(幂等)。两者结论相同、后续动作不同,
* 所以调用方拿到的是同一个 {@link Decision#ALREADY_PAID},由它自己按本地状态决定要不要自愈。</p>
*/
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
}
/**
* 判定这一次取消请求该怎么走。
*
* <p>调用方应当调用它<b>两次</b>:第一次传 {@code wechatStatus = null}(还没查单),
* 若结论不是 {@link Decision#PROCEED} 就直接返回,从而避免一次无谓的微信调用;
* 查单之后再调用一次,用微信侧的结论定案。</p>
*
* @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;
}
/**
* 本次取消是否「与微信核对过」。
*
* <p>拿到状态名才算核对过:查单抛异常、或微信返回体里没有状态,都只能算**没核对上**。
* 未核对上时仍然放行取消(否则在商户配置修好之前,这个功能等于不存在),
* 但响应里要如实带上 {@code verified=false},让前端把不确定性告诉用户,
* 而不是替微信下结论(与 ADR 0009「以微信侧为准」同源)。</p>
*/
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);
}
}
@@ -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})。
*
* <p><b>为什么需要它</b>:这个接口最要紧的几条分支在真实环境里验不了——
* 需要登录态(我没有可用账号)、需要真付一笔钱(微信说 SUCCESS 那一支)、
* 需要两个企业账号(越权那一支,dev 库只有一个企业)。
* 用 mock 把 service 换掉,就能把"先查单、查到了怎么办、关单失败怎么办、
* 以及**绝不推送一站式**"这些编排断言钉住。</p>
*
* <p>{@code getLoginUserId()} 读的是 {@code SecurityContextHolder},所以登录态可以用
* 真实的 {@link User} 放进 SecurityContext 来模拟,不必绕过鉴权。</p>
*/
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<String, Object> body() {
Map<String, Object> b = new HashMap<>(2);
b.put("orderNo", ORDER_NO);
return b;
}
@SuppressWarnings("unchecked")
private Map<String, Object> dataOf(ApiResult<?> res) {
return (Map<String, Object>) 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());
}
}
@@ -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;
/**
* 取消订单判定逻辑的单测。
*
* <p>这几条断言在**端到端里造不出来**:要验「微信说 SUCCESS 时拒绝取消」就得真付一笔钱;
* 而这一支恰恰是本功能里最危险的一支("其实已付款、却被买家点了取消")。
* 所以它是本轮唯一可靠的防线,不要因为"逻辑简单"就删掉。</p>
*/
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));
}
}