Files
mp-java/src/main/java/com/gxwebsoft/hjc/service/impl/HjcBizServiceImpl.java
T
weicw1996 13cb08621f 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,含推送禁区回归守卫)
2026-09-17 03:39:53 +08:00

301 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}