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:
@@ -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)与 PC(Native)
|
||||||
|
// 各发起一次时必现 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user