13cb08621f
取消订单(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,含推送禁区回归守卫)
301 lines
13 KiB
Java
301 lines
13 KiB
Java
package com.gxwebsoft.hjc.service.impl;
|
||
|
||
import cn.hutool.core.util.StrUtil;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.gxwebsoft.hjc.dto.CreatePurchaseDetails;
|
||
import com.gxwebsoft.hjc.dto.HjcOneStopProjectPush;
|
||
import com.gxwebsoft.hjc.entity.HjcBidProject;
|
||
import com.gxwebsoft.hjc.entity.HjcEnterprise;
|
||
import com.gxwebsoft.hjc.entity.HjcOrder;
|
||
import com.gxwebsoft.hjc.entity.HjcOrderPushLog;
|
||
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.service.HjcOrderPushLogService;
|
||
import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.HttpEntity;
|
||
import org.springframework.http.HttpHeaders;
|
||
import org.springframework.http.MediaType;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.transaction.annotation.Transactional;
|
||
import org.springframework.web.client.RestTemplate;
|
||
|
||
import javax.annotation.Resource;
|
||
import java.time.LocalDateTime;
|
||
import java.time.format.DateTimeFormatter;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* 汇吉采一站式双向对接业务
|
||
*/
|
||
@Service
|
||
public class HjcBizServiceImpl implements HjcBizService {
|
||
|
||
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||
private static final String STATUS_PAID = "PAID";
|
||
private static final String STATUS_REFUNDED = "REFUNDED";
|
||
/** 退款原因为空时的兜底值:一站式要求 REFUNDED 必带退款原因,不能推空串 */
|
||
private static final String DEFAULT_REFUND_REASON = "管理员操作退款";
|
||
/** 退款原因入库/推送长度上限(hjc_order.refund_reason) */
|
||
private static final int REFUND_REASON_MAX = 255;
|
||
|
||
@Value("${hjc.one-stop.base-url:}")
|
||
private String oneStopBaseUrl;
|
||
|
||
@Value("${hjc.one-stop.create-purchase-details-path:/api/biz/createPurchaseDetails}")
|
||
private String createPurchaseDetailsPath;
|
||
|
||
@Resource
|
||
private HjcBidProjectService hjcBidProjectService;
|
||
@Resource
|
||
private HjcEnterpriseService hjcEnterpriseService;
|
||
@Resource
|
||
private HjcOrderService hjcOrderService;
|
||
@Resource
|
||
private HjcOrderPushLogService hjcOrderPushLogService;
|
||
@Resource
|
||
private RestTemplate restTemplate;
|
||
@Resource
|
||
private ObjectMapper objectMapper;
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public HjcBidProject upsertFromPush(HjcOneStopProjectPush push, Integer tenantId) {
|
||
HjcBidProject project = hjcBidProjectService.getByProjectNo(push.getProjectNo());
|
||
boolean isNew = project == null;
|
||
if (isNew) {
|
||
project = new HjcBidProject();
|
||
project.setProjectNo(push.getProjectNo());
|
||
project.setTenantId(tenantId);
|
||
project.setSaleCount(0);
|
||
project.setDataSource("PUSH");
|
||
project.setStatus(1);
|
||
}
|
||
project.setProjectName(push.getProjectName());
|
||
project.setTenderer(push.getCustomerName());
|
||
project.setWinnerSupplier(push.getSupplierName());
|
||
project.setBidAmount(push.getBidAmount());
|
||
project.setBulletinTitle(push.getBulletinName());
|
||
project.setBulletinContent(push.getContent());
|
||
project.setBulletinFileList(push.getFileList());
|
||
project.setTenderPrice(push.getTenderPrice());
|
||
project.setTenderFile(push.getFiles());
|
||
project.setOnsaleTime(parseDateTime(push.getTenderOnsaleTime()));
|
||
project.setOffsaleTime(parseDateTime(push.getTenderOffsaleTime()));
|
||
// 发布时间=开售时间、投标截止=截止时间(同一语义,直接映射)
|
||
project.setPublishTime(parseDateTime(push.getTenderOnsaleTime()));
|
||
project.setDeadlineTime(parseDateTime(push.getTenderOffsaleTime()));
|
||
project.setSellingMethod(push.getSellingMethod());
|
||
project.setNeedSell(push.getNeedSellTender());
|
||
project.setDataSource("PUSH");
|
||
if (isNew) {
|
||
hjcBidProjectService.save(project);
|
||
} else {
|
||
hjcBidProjectService.updateById(project);
|
||
}
|
||
return project;
|
||
}
|
||
|
||
@Override
|
||
public void pushOrderToOneStop(HjcOrder order) {
|
||
if (order == null || StrUtil.isBlank(order.getOrderNo())) {
|
||
return;
|
||
}
|
||
HjcOrderPushLog logEntity = new HjcOrderPushLog();
|
||
logEntity.setOrderId(order.getId());
|
||
logEntity.setOrderNo(order.getOrderNo());
|
||
logEntity.setTenantId(order.getTenantId());
|
||
logEntity.setPushStatus(0);
|
||
logEntity.setAttemptCount(0);
|
||
hjcOrderPushLogService.save(logEntity);
|
||
doPush(order, logEntity);
|
||
}
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public void retryPendingPush() {
|
||
List<HjcOrderPushLog> pending = hjcOrderPushLogService.list(new LambdaQueryWrapper<HjcOrderPushLog>()
|
||
.in(HjcOrderPushLog::getPushStatus, 0, 2)
|
||
.and(w -> w.isNull(HjcOrderPushLog::getNextRetryTime)
|
||
.or().le(HjcOrderPushLog::getNextRetryTime, LocalDateTime.now()))
|
||
.last("limit 50"));
|
||
for (HjcOrderPushLog logEntity : pending) {
|
||
HjcOrder order = hjcOrderService.getByOrderNo(logEntity.getOrderNo());
|
||
if (order != null) {
|
||
doPush(order, logEntity);
|
||
}
|
||
}
|
||
}
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public HjcOrder markPaid(String orderNo) {
|
||
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
|
||
if (order == null) {
|
||
throw new RuntimeException("订单不存在");
|
||
}
|
||
if (order.getPayStatus() != null && order.getPayStatus() == 1) {
|
||
return order;
|
||
}
|
||
order.setPayStatus(1);
|
||
order.setOrderStatus(1);
|
||
order.setPayTime(LocalDateTime.now());
|
||
hjcOrderService.updateById(order);
|
||
// 支付成功后触发一站式推送
|
||
pushOrderToOneStop(order);
|
||
return order;
|
||
}
|
||
|
||
@Override
|
||
@Transactional(rollbackFor = Exception.class)
|
||
public HjcOrder refund(String orderNo, String refundReason) {
|
||
HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
|
||
if (order == null) {
|
||
throw new RuntimeException("订单不存在");
|
||
}
|
||
if (order.getPayStatus() != null && order.getPayStatus() == 3) {
|
||
return order;
|
||
}
|
||
order.setPayStatus(3);
|
||
// 退款时间/原因必须落库:推送失败重试时按订单重建报文,否则重试会丢掉这两个字段
|
||
order.setRefundTime(LocalDateTime.now());
|
||
order.setRefundReason(StrUtil.isBlank(refundReason) ? DEFAULT_REFUND_REASON : limitStr(refundReason.trim(), REFUND_REASON_MAX));
|
||
hjcOrderService.updateById(order);
|
||
// 退款后触发一站式推送(status=REFUNDED + 退款时间/原因)
|
||
pushOrderToOneStop(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) {
|
||
try {
|
||
CreatePurchaseDetails body = buildCreatePurchaseDetails(order);
|
||
String payload = objectMapper.writeValueAsString(body);
|
||
logEntity.setPayload(payload);
|
||
if (StrUtil.isBlank(oneStopBaseUrl)) {
|
||
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
|
||
logEntity.setPushStatus(2);
|
||
logEntity.setErrorMsg("一站式base-url未配置");
|
||
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
|
||
hjcOrderPushLogService.updateById(logEntity);
|
||
return;
|
||
}
|
||
String ts = HjcOneStopAuthUtil.timestamp();
|
||
String sign = HjcOneStopAuthUtil.sign(ts);
|
||
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("appKey", HjcOneStopAuthUtil.APP_KEY);
|
||
headers.set("timestamp", ts);
|
||
headers.set("sign", sign);
|
||
|
||
String url = oneStopBaseUrl + createPurchaseDetailsPath;
|
||
ResponseEntity<String> resp = restTemplate.postForEntity(url, new HttpEntity<>(payload, headers), String.class);
|
||
|
||
logEntity.setPayload(payload);
|
||
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
|
||
logEntity.setHttpCode(resp.getStatusCode().value());
|
||
logEntity.setResponseBody(limitStr(resp.getBody(), 3000));
|
||
if (resp.getStatusCode().is2xxSuccessful()) {
|
||
logEntity.setPushStatus(1);
|
||
logEntity.setErrorMsg(null);
|
||
logEntity.setNextRetryTime(null);
|
||
} else {
|
||
logEntity.setPushStatus(2);
|
||
logEntity.setErrorMsg("HTTP " + resp.getStatusCode().value());
|
||
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
|
||
}
|
||
} catch (Exception e) {
|
||
logEntity.setAttemptCount(logEntity.getAttemptCount() + 1);
|
||
logEntity.setPushStatus(2);
|
||
logEntity.setErrorMsg(limitStr(e.getMessage(), 1000));
|
||
logEntity.setNextRetryTime(LocalDateTime.now().plusMinutes(30));
|
||
}
|
||
hjcOrderPushLogService.updateById(logEntity);
|
||
}
|
||
|
||
private CreatePurchaseDetails buildCreatePurchaseDetails(HjcOrder order) {
|
||
CreatePurchaseDetails body = new CreatePurchaseDetails();
|
||
body.setIdempotencyKey("HJC_ORD_" + order.getOrderNo());
|
||
body.setOrderNo(order.getOrderNo());
|
||
body.setProjectNo(order.getProjectNo());
|
||
body.setProjectName(order.getProjectName());
|
||
body.setTenderPrice(order.getUnitPrice());
|
||
body.setQuantity(order.getQuantity());
|
||
body.setTotalAmount(order.getTotalAmount());
|
||
body.setPaidAt(order.getPayTime() == null ? null : order.getPayTime().format(DT_FMT));
|
||
body.setPayMethod(order.getPayMethod());
|
||
boolean refunded = order.getPayStatus() != null && order.getPayStatus() == 3;
|
||
body.setStatus(refunded ? STATUS_REFUNDED : STATUS_PAID);
|
||
// 一站式要求退款推送必带退款时间与退款原因(非退款单不推这两个字段)
|
||
if (refunded) {
|
||
LocalDateTime refundTime = order.getRefundTime() != null ? order.getRefundTime() : order.getUpdateTime();
|
||
body.setRefundTime(refundTime == null ? null : refundTime.format(DT_FMT));
|
||
body.setRefundReason(StrUtil.isBlank(order.getRefundReason()) ? DEFAULT_REFUND_REASON : order.getRefundReason());
|
||
}
|
||
body.setInvoiceStatus(invoiceStatus(order.getInvoiceStatus()));
|
||
|
||
CreatePurchaseDetails.Buyer buyer = new CreatePurchaseDetails.Buyer();
|
||
buyer.setEnterpriseName(order.getEnterpriseName());
|
||
buyer.setContactName(order.getContactName());
|
||
buyer.setContactPhone(order.getContactPhone());
|
||
buyer.setContactEmail(order.getContactEmail());
|
||
if (order.getEnterpriseId() != null) {
|
||
HjcEnterprise enterprise = hjcEnterpriseService.getById(order.getEnterpriseId());
|
||
if (enterprise != null) {
|
||
buyer.setCreditCode(enterprise.getCreditCode());
|
||
}
|
||
}
|
||
body.setBuyer(buyer);
|
||
return body;
|
||
}
|
||
|
||
private String invoiceStatus(Integer invoiceStatus) {
|
||
if (invoiceStatus == null) {
|
||
return "NONE";
|
||
}
|
||
switch (invoiceStatus) {
|
||
case 1: return "APPLIED";
|
||
case 2: return "ISSUED";
|
||
default: return "NONE";
|
||
}
|
||
}
|
||
|
||
private LocalDateTime parseDateTime(String text) {
|
||
if (StrUtil.isBlank(text)) {
|
||
return null;
|
||
}
|
||
String t = text.trim();
|
||
try {
|
||
if (t.length() == 14) {
|
||
return LocalDateTime.parse(t, DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||
}
|
||
if (t.contains("T")) {
|
||
return LocalDateTime.parse(t);
|
||
}
|
||
return LocalDateTime.parse(t, DT_FMT);
|
||
} catch (Exception e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private String limitStr(String s, int max) {
|
||
if (s == null) {
|
||
return null;
|
||
}
|
||
return s.length() > max ? s.substring(0, max) : s;
|
||
}
|
||
}
|