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.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 = "标记订单已支付(支付成功后调用,幂等),并触发一站式推送")
|
||||
@PutMapping("/mark-paid")
|
||||
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) {
|
||||
return a != null && !a.trim().isEmpty() ? a : b;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 购买人数(**非表字段**):由订单表实时统计出的「已付款订单数」。
|
||||
*
|
||||
* <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")
|
||||
private Integer tenantId;
|
||||
|
||||
|
||||
@@ -20,4 +20,24 @@ public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
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
|
||||
</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>
|
||||
|
||||
@@ -29,6 +29,21 @@ public interface HjcBizService {
|
||||
*/
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -31,4 +31,17 @@ public class HjcOrderServiceImpl extends ServiceImpl<HjcOrderMapper, HjcOrder> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user