feat: 添加支付和退款相关功能及接口

- 新增支付回调处理逻辑,支持订单状态更新
- 新增退款接口,支持订单退款操作
- 新增退款回调处理逻辑,确保退款状态同步
- 新增支付和退款相关的DTO、Service及工具类
- 优化订单状态管理,支持支付成功和退款成功状态更新
This commit is contained in:
dzq 2025-04-08 15:48:47 +08:00
parent e01ddf0b9c
commit abd8f7aa60
24 changed files with 870 additions and 137 deletions

View File

@ -5,6 +5,7 @@ import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode;
import com.agileboot.domain.shop.approval.ReturnApprovalApplicationService;
import com.agileboot.domain.shop.approval.command.AddReturnApprovalCommand;
import com.agileboot.domain.shop.approval.db.ReturnApprovalEntity;
import com.agileboot.domain.shop.approval.model.ReturnApprovalModel;
import com.agileboot.domain.shop.order.OrderApplicationService;
import com.agileboot.domain.shop.order.db.ShopOrderGoodsEntity;
@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.Date;
/**
* 审批请求控制器
@ -51,13 +53,9 @@ public class ApprovalApiController {
*/
@PostMapping("/submit")
@ApiOperation(value = "提交退货审批")
public ResponseDTO<ReturnApprovalModel> submitApproval(@Valid @RequestBody AddReturnApprovalCommand command) {
try {
if (null == command.getOrderId()) {
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "订单ID不能为空"));
}
if (null == command.getGoodsId()) {
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "商品ID不能为空"));
public ResponseDTO<ReturnApprovalEntity> submitApproval(@Valid @RequestBody AddReturnApprovalCommand command) {
if (null == command.getOrderGoodsId()) {
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "订单商品ID不能为空"));
}
if (null == command.getReturnQuantity()) {
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "归还数量不能为空"));
@ -67,21 +65,13 @@ public class ApprovalApiController {
}
// 查询订单商品信息
ShopOrderGoodsEntity orderGoods = orderApplicationService.getOrderGoodsByOrderIdAndGoodsId(command.getOrderId(), command.getGoodsId());
ShopOrderGoodsEntity orderGoods = orderApplicationService.getOrderGoodsById(command.getOrderGoodsId());
if (null == orderGoods) {
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "订单商品不存在"));
}
// 设置商品价格并初始化审批状态
command.setGoodsPrice(orderGoods.getPrice());
command.setStatus(1);
// 执行业务逻辑
ReturnApprovalModel returnApprovalModel = approvalApplicationService.addApproval(command);
return ResponseDTO.ok(returnApprovalModel);
} catch (Exception e) {
log.error("提交审批失败", e);
return ResponseDTO.fail(new ApiException(ErrorCode.Internal.INTERNAL_ERROR, "提交审批失败"));
}
ReturnApprovalEntity returnApproval = approvalApplicationService.submitApproval(command, orderGoods);
return ResponseDTO.ok(returnApproval);
}
}

View File

@ -1,31 +1,37 @@
package com.agileboot.api.controller;
import com.agileboot.common.constant.PayApiConstants;
import com.agileboot.common.core.base.BaseController;
import com.agileboot.domain.shop.order.dto.CreateOrderResult;
import com.agileboot.domain.shop.order.dto.GetOrdersByOpenIdDTO;
import com.agileboot.domain.shop.order.model.OrderGoodsModelFactory;
import com.agileboot.domain.shop.order.model.OrderModel;
import com.agileboot.domain.shop.payment.PaymentApplicationService;
import com.agileboot.domain.shop.payment.dto.RefundVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import com.agileboot.common.core.dto.ResponseDTO;
import com.agileboot.domain.shop.order.OrderApplicationService;
import com.agileboot.domain.shop.order.command.SubmitOrderCommand;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 调度日志操作处理
*
* @author valarchie
*/
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/order")
public class OrderController extends BaseController {
private final OrderApplicationService orderApplicationService;
private final PaymentApplicationService paymentApplicationService;
// 新增提交订单接口
@PostMapping("/submit")
@ -45,4 +51,30 @@ public class OrderController extends BaseController {
GetOrdersByOpenIdDTO result = orderApplicationService.getOrdersByOpenId(openid);
return ResponseDTO.ok(result);
}
@PostMapping("/refund/{orderId}")
public ResponseDTO<RefundVO> refundOrder(@PathVariable Long orderId, @RequestParam int money) {
OrderModel orderModel = orderApplicationService.loadById(orderId);
try {
// 退款金额对比, 退款金额不能大于订单金额
// 金额转换元转分并四舍五入
BigDecimal amountInFen = orderModel.getTotalAmount()
.multiply(new BigDecimal("100"))
.setScale(0, RoundingMode.HALF_UP);
if (money < 0) {
throw new IllegalArgumentException("退款金额不能为负数");
}
BigDecimal moneyDecimal = new BigDecimal(money);
if (moneyDecimal.compareTo(amountInFen) > 0) {
throw new IllegalArgumentException("退款金额不能超过订单总额");
}
RefundVO refundVO = paymentApplicationService.refund(PayApiConstants.biz_id, PayApiConstants.appkey, orderModel.getBizOrderId(), orderModel.getUcid(), "退还", money);
log.info("退款结果:{}", refundVO);
return ResponseDTO.ok(refundVO);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@ -22,6 +22,7 @@ import com.agileboot.domain.qywx.user.QyUserApplicationService;
import com.agileboot.domain.qywx.user.db.QyUserEntity;
import com.agileboot.domain.qywx.userQySys.SysUserQyUserApplicationService;
import com.agileboot.domain.shop.order.OrderApplicationService;
import com.agileboot.domain.shop.payment.PaymentApplicationService;
import com.agileboot.domain.shop.payment.dto.PaymentCallbackRequest;
import java.nio.charset.StandardCharsets;
import java.util.*;
@ -56,12 +57,12 @@ import org.springframework.web.client.RestTemplate;
@RequiredArgsConstructor
@RequestMapping("/api/payment")
public class PaymentController {
private final OrderApplicationService orderApplicationService;
private final AccessTokenApplicationService accessTokenApplicationService;
private final QyUserApplicationService qyUserApplicationService;
private final AuthCorpInfoApplicationService authCorpInfoApplicationService;
private final SysUserQyUserApplicationService sysUserQyUserApplicationService;
private final MenuApplicationService menuApplicationService;
private final PaymentApplicationService paymentApplicationService;
// 新增回调接口
/**
@ -78,32 +79,37 @@ public class PaymentController {
public String paymentCallback(HttpServletRequest request, @RequestBody String requestBody) {
log.info("支付回调requestBody{}", requestBody);
try {
// 1. 参数解析
PaymentCallbackRequest callbackReq = parseCallbackRequest(requestBody);
PaymentCallbackRequest.BizContent bizContent = parseBizContent(callbackReq.getBiz_content());
// 2. 签名验证需要根据biz_id获取对应的appKey
String appKey = getAppKeyByBizId(callbackReq.getBiz_id()); // 需要实现根据biz_id获取appKey的逻辑
boolean signValid = OpenSignUtil.checkOpenSign(appKey, callbackReq.getSign(), requestBody);
if (!signValid) {
log.error("支付回调签名验证失败:{}", requestBody);
paymentApplicationService.paymentCallback(requestBody);
return "success";
} catch (Exception e) {
log.error("支付回调处理失败", e);
return "fail";
}
if (bizContent.getTrade_status().equals("SUCCESS")) {
// 3. 业务处理需要实现幂等性校验
handlePaymentSuccess(
bizContent.getBiz_order_id(),
bizContent.getTotal_amount(),
bizContent.getTrade_id(),
bizContent.getTrade_pay_time()
);
} else {
log.error("支付订单失败requestBody{}", requestBody);
}
return "success";
/**
* 微信支付退款回调接口
* @param request HTTP请求对象用于获取请求头信息
* @param requestBody 回调请求体URL编码格式的XML数据
* @return 处理结果"success"表示成功处理并停止通知"fail"表示需要微信重新发起通知
* @throws ApiException 当出现以下情况时抛出
* <ul>
* <li>签名验证失败</li>
* <li>数据解密失败</li>
* <li>退款状态异常</li>
* </ul>
* @apiNote 该接口需要处理以下流程
* 1. 签名验证使用微信支付API密钥
* 2. 解密退款结果如需加密传输
* 3. 退款状态判断成功/失败
* 4. 更新订单退款状态需保证幂等性
* 5. 按微信接口规范返回正确处理结果
*/
@PostMapping("/refund/callback")
public String refundCallback(HttpServletRequest request, @RequestBody String requestBody) {
log.info("退款回调requestBody{}", requestBody);
try {
return paymentApplicationService.refundCallback(requestBody);
} catch (Exception e) {
log.error("支付回调处理失败", e);
return "fail";
@ -255,37 +261,7 @@ public class PaymentController {
return ResponseDTO.ok(response);
}
private PaymentCallbackRequest parseCallbackRequest(String requestBody) {
// 实现将URL参数解析为PaymentCallbackRequest
// 示例实现需要根据实际参数格式调整
Map<String, String> paramMap = new HashMap<>();
for (String param : requestBody.split("&")) {
String[] pair = param.split("=");
if (pair.length == 2) {
paramMap.put(pair[0], URLDecoder.decode(pair[1], StandardCharsets.UTF_8));
}
}
return BeanUtil.toBean(paramMap, PaymentCallbackRequest.class);
}
private PaymentCallbackRequest.BizContent parseBizContent(String bizContent) {
// 实现biz_content的JSON解析
return JSONUtil.toBean(bizContent, PaymentCallbackRequest.BizContent.class);
}
// 需要实现的方法根据业务需求补充
private String getAppKeyByBizId(String bizId) {
// 根据biz_id从数据库或配置获取对应的appKey
return "wxshop202503081132";
}
private void handlePaymentSuccess(String bizOrderId, Integer amount, String tradeId, String tradePayTime) {
// 实现订单状态更新和幂等性校验
if (StringUtils.isNotBlank(bizOrderId) && bizOrderId.startsWith("wxshop-")) {
// 订单号格式为 wxshop-1-time提取中间的订单号
String orderId = bizOrderId.split("-")[1];
orderApplicationService.handlePaymentSuccess(Long.valueOf(orderId), amount, tradeId, tradePayTime);
}
}
}

View File

@ -0,0 +1,7 @@
package com.agileboot.common.constant;
public class PayApiConstants {
public static final String biz_id = "wxshop";
public static final String appkey = "wxshop202503081132";
}

View File

@ -10,6 +10,8 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import com.agileboot.common.constant.PayApiConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.Md5Crypt;
import org.apache.commons.collections4.MapUtils;
@ -82,7 +84,7 @@ public class OpenSignUtil {
+ "2448492%22%2C%22trade_id%22%3A%221063669415%22%2C%22total_amount%22%3A1%2C%22extra%22%3A%22%22%2C%22trade_pay_time%22%3A%222025-03-20+17%3A20%3A55%22%2C%22trade_status%22%3A%22SUCCESS%22%2C%22pay_type%22%3A%22116%22%2C%22callback_content%22%3A%22%3Cxml%3E%3Cappid%3E%3C%21%5BCDATA%5Bwx9922dfbb0d4cd7bb%5D%5D%3E%3C%2Fappid%3E%5Cn%3Cattach%3E%3C%21%5BCDATA%5B%257B%2522biz_id%2522%253A%2522wxshop%2522%252C%2522trade_id%2522%253A%25221063669415%2522%257D%5D%5D%3E%3C%2Fattach%3E%5Cn%3Cbank_type%3E%3C%21%5BCDATA%5BOTHERS%5D%5D%3E%3C%2Fbank_type%3E%5Cn%3Ccash_fee%3E%3C%21%5BCDATA%5B1%5D%5D%3E%3C%2Fcash_fee%3E%5Cn%3Cfee_type%3E%3C%21%5BCDATA%5BCNY%5D%5D%3E%3C%2Ffee_type%3E%5Cn%3Cis_subscribe%3E%3C%21%5BCDATA%5BN%5D%5D%3E%3C%2Fis_subscribe%3E%5Cn%3Cmch_id%3E%3C%21%5BCDATA%5B1625101806%5D%5D%3E%3C%2Fmch_id%3E%5Cn%3Cnonce_str%3E%3C%21%5BCDATA%5B24NffiTHxNYm0ppw3QE9WezmzJQDnJQV%5D%5D%3E%3C%2Fnonce_str%3E%5Cn%3Copenid%3E%3C%21%5BCDATA%5BoMRxw6Eum0DB1IjI_pEX_yrawBHw%5D%5D%3E%3C%2Fopenid%3E%5Cn%3Cout_trade_no%3E%3C%21%5BCDATA"
+ "%5Bwxshop-10-1742462448212%5D%5D%3E%3C%2Fout_trade_no%3E%5Cn%3Cresult_code%3E%3C%21%5BCDATA%5BSUCCESS%5D%5D%3E%3C%2Fresult_code%3E%5Cn%3Creturn_code%3E%3C%21%5BCDATA%5BSUCCESS%5D%5D%3E%3C%2Freturn_code%3E%5Cn%3Csign%3E%3C%21%5BCDATA%5B9CE8A123437E591166DDAF92A750C122%5D%5D%3E%3C%2Fsign%3E%5Cn%3Ctime_end%3E%3C%21%5BCDATA%5B20250320172055%5D%5D%3E%3C%2Ftime_end%3E%5Cn%3Ctotal_fee%3E1%3C%2Ftotal_fee%3E%5Cn%3Ctrade_type%3E%3C%21%5BCDATA%5BJSAPI%5D%5D%3E%3C%2Ftrade_type%3E%5Cn%3Ctransaction_id%3E%3C%21%5BCDATA%5B4200002695202503209706830758%5D%5D%3E%3C%2Ftransaction_id%3E%5Cn%3C%2Fxml%3E%22%2C%22title%22%3A%22%E5%95%86%E5%93%81%E8%AE%A2%E5%8D%95%E6%94%AF%E4%BB%98%22%2C%22biz_order_id%22%3A%22wxshop-10-1742462448212%22%7D&nonce_str=6d80bd6b542a499994adf4ee4c1e89c4&sign=f1ffc9cfc61cf60d8a1acbac0399cd0b&biz_id=wxshop&sign_type=MD5&version=1.0×tamp=1742462460390";
Boolean res = checkOpenSign("wxshop202503081132", "f1ffc9cfc61cf60d8a1acbac0399cd0b", body);
Boolean res = checkOpenSign(PayApiConstants.appkey, "f1ffc9cfc61cf60d8a1acbac0399cd0b", body);
log.info("res:{}", res);
}
}

View File

@ -10,7 +10,12 @@ import com.agileboot.domain.shop.approval.dto.ReturnApprovalDTO;
import com.agileboot.domain.shop.approval.model.ReturnApprovalModel;
import com.agileboot.domain.shop.approval.model.ReturnApprovalModelFactory;
import com.agileboot.domain.shop.approval.query.SearchReturnApprovalQuery;
import com.agileboot.domain.shop.order.db.ShopOrderGoodsEntity;
import com.agileboot.domain.shop.order.model.OrderGoodsModel;
import com.agileboot.domain.shop.order.model.OrderGoodsModelFactory;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@ -24,6 +29,7 @@ public class ReturnApprovalApplicationService {
private final ReturnApprovalService approvalService;
private final ReturnApprovalModelFactory modelFactory;
private final OrderGoodsModelFactory orderGoodsModelFactory;
public PageDTO<ReturnApprovalDTO> getApprovalList(SearchReturnApprovalQuery<ReturnApprovalEntity> query) {
Page<ReturnApprovalEntity> page = approvalService.getApprovalList(query);
@ -52,4 +58,29 @@ public class ReturnApprovalApplicationService {
model.deleteById();
}
}
public ReturnApprovalEntity submitApproval(AddReturnApprovalCommand command, ShopOrderGoodsEntity orderGoods) {
// 设置商品价格并初始化审批状态
command.setGoodsId(orderGoods.getGoodsId());
command.setOrderId(orderGoods.getOrderId());
command.setGoodsPrice(orderGoods.getPrice());
command.setReturnImages(command.getReturnImages());
command.setReturnRemark(command.getReturnRemark());
command.setStatus(1);
command.setCreatorId(0L);
command.setCreateTime(new Date());
command.setUpdaterId(0L);
command.setUpdateTime(new Date());
command.setDeleted(false);
// 执行业务逻辑
ReturnApprovalModel returnApprovalModel = addApproval(command);
// 更新订单商品状态
OrderGoodsModel orderGoodsModel = orderGoodsModelFactory.create(orderGoods);
orderGoodsModel.setStatus(5);
orderGoodsModel.updateById();
return returnApprovalModel.selectById();
}
}

View File

@ -40,6 +40,10 @@ public class ReturnApprovalEntity extends BaseEntity<ReturnApprovalEntity> {
@TableField("goods_id")
private Long goodsId;
@ApiModelProperty("关联订单商品ID")
@TableField("order_goods_id")
private Long orderGoodsId;
@ApiModelProperty("归还数量")
@TableField("return_quantity")
private Integer returnQuantity;

View File

@ -17,7 +17,7 @@ import org.apache.ibatis.annotations.Select;
* @since 2025-04-03
*/
public interface ReturnApprovalMapper extends BaseMapper<ReturnApprovalEntity> {
@Select("SELECT approval_id, order_id, goods_id, return_quantity, goods_price, return_amount, return_images, audit_images, return_remark, audit_remark, status " +
@Select("SELECT * " +
"FROM return_approval " +
"${ew.customSqlSegment}")
Page<ReturnApprovalEntity> getApprovalList(

View File

@ -41,6 +41,9 @@ public class ReturnApprovalDTO {
@ExcelColumn(name = "关联商品ID")
private Long goodsId;
@ExcelColumn(name = "关联订单商品ID")
private Long orderGoodsId;
@ExcelColumn(name = "归还数量")
private Integer returnQuantity;

View File

@ -1,6 +1,7 @@
package com.agileboot.domain.shop.order;
import cn.hutool.core.date.DateUtil;
import com.agileboot.common.constant.PayApiConstants;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode;
import com.agileboot.domain.cabinet.cell.db.CabinetCellEntity;
@ -160,7 +161,7 @@ public class OrderApplicationService {
WxJsApiPreCreateRequest request = new WxJsApiPreCreateRequest();
request.setIp("222.218.10.217");
request.setOpenid(orderModel.getOpenid()); //
request.setBiz_order_id("wxshop-" + orderModel.getOrderId() + "-" + new Date().getTime()); // 使用订单唯一编号
request.setBiz_order_id(orderModel.getBizOrderId()); // 使用订单唯一编号
// 金额转换元转分并四舍五入
BigDecimal amountInFen = orderModel.getTotalAmount()
.multiply(new BigDecimal("100"))
@ -169,7 +170,7 @@ public class OrderApplicationService {
request.setTitle("商品订单支付");
request.setNotify_url("http://wxshop.ab98.cn/shop-api/api/payment/callback");
request.setBiz_id("wxshop");
request.setBiz_id(PayApiConstants.biz_id);
request.setUcid(orderModel.getUcid());
request.setExtra("");
return request;
@ -203,6 +204,7 @@ public class OrderApplicationService {
}
// 更新订单总金额
orderModel.setBizOrderId("wxshop-" + orderModel.getOrderId() + "-" + new Date().getTime());
orderModel.setTotalAmount(totalAmount);
orderModel.updateById();
}
@ -252,31 +254,7 @@ public class OrderApplicationService {
public void handlePaymentSuccess(Long orderId, Integer amount, String tradeId, String tradePayTime) {
OrderModel orderModel = orderModelFactory.loadById(orderId);
// 状态校验
orderModel.validateStatusTransition(2); // 2代表已支付
// 更新订单状态
orderModel.setStatus(2);
orderModel.setPayStatus(2);
orderModel.setTradeId(tradeId);
try {
// if (tradePayTime.contains("+")) {
// tradePayTime = tradePayTime.replace("+", " ");
// }
orderModel.setPayTime(DateUtil.parse(tradePayTime));
} catch (Exception e) {
log.error("支付时间转换失败", e);
}
orderModel.updateById();
// 发送指令
// 客户端手动开柜
// QueryWrapper<ShopOrderGoodsEntity> orderGoodsQueryWrapper = new QueryWrapper<>();
// orderGoodsQueryWrapper.eq("order_id", orderId);
// List<ShopOrderGoodsEntity> orderGoods = orderGoodsService.list(orderGoodsQueryWrapper);
// orderGoods.forEach(g -> {
// openOrderGoodsCabinet(orderId, g.getOrderGoodsId());
// });
orderModel.handlePaymentSuccess(amount, tradeId, tradePayTime);
}
public static void main(String[] args) {
@ -307,4 +285,12 @@ public class OrderApplicationService {
public ShopOrderGoodsEntity getOrderGoodsByOrderIdAndGoodsId(Long orderId, Long goodsId) {
return orderGoodsService.getByOrderIdAndGoodsId(orderId, goodsId);
}
public ShopOrderGoodsEntity getOrderGoodsById(Long orderGoodsId) {
return orderGoodsService.getById(orderGoodsId);
}
public OrderModel loadById(Long orderId) {
return orderModelFactory.loadById(orderId);
}
}

View File

@ -45,6 +45,10 @@ public class ShopOrderEntity extends BaseEntity<ShopOrderEntity> {
@TableField("trade_id")
private String tradeId;
@ApiModelProperty("业务系统订单ID对接外部系统")
@TableField("biz_order_id")
private String bizOrderId;
@ApiModelProperty("订单总金额")
@TableField("total_amount")
private BigDecimal totalAmount;

View File

@ -52,7 +52,7 @@ public class ShopOrderGoodsEntity extends BaseEntity<ShopOrderGoodsEntity> {
@TableField("total_amount")
private BigDecimal totalAmount;
@ApiModelProperty("商品状态1正常 2已退货 3已换货 4已完成")
@ApiModelProperty("商品状态1正常 2已退货 3已换货 4已完成 5审核中 6退货未通过")
@TableField("`status`")
private Integer status;

View File

@ -11,5 +11,4 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @since 2025-03-10
*/
public interface ShopOrderService extends IService<ShopOrderEntity> {
}

View File

@ -1,5 +1,7 @@
package com.agileboot.domain.shop.order.db;
import cn.hutool.core.date.DateUtil;
import com.agileboot.domain.shop.order.model.OrderModel;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@ -13,5 +15,4 @@ import org.springframework.stereotype.Service;
*/
@Service
public class ShopOrderServiceImpl extends ServiceImpl<ShopOrderMapper, ShopOrderEntity> implements ShopOrderService {
}

View File

@ -1,6 +1,7 @@
package com.agileboot.domain.shop.order.model;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DateUtil;
import com.agileboot.common.config.AgileBootConfig;
import com.agileboot.common.exception.ApiException;
import com.agileboot.common.exception.error.ErrorCode;
@ -11,7 +12,9 @@ import java.math.BigDecimal;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@EqualsAndHashCode(callSuper = true)
@Data
public class OrderModel extends ShopOrderEntity {
@ -68,4 +71,25 @@ public class OrderModel extends ShopOrderEntity {
String orderNo = String.valueOf(System.currentTimeMillis() + (int)(Math.random()*1000));
this.setUcid(orderNo);
}
public void handlePaymentSuccess(Integer amount, String tradeId, String tradePayTime) {
// 状态校验
this.validateStatusTransition(2); // 2代表已支付
// 更新订单状态
this.setStatus(2);
this.setPayStatus(2);
this.setTradeId(tradeId);
try {
this.setPayTime(DateUtil.parse(tradePayTime));
} catch (Exception e) {
log.error("支付时间转换失败", e);
}
this.updateById();
}
public void handleRefundSuccess() {
// 更新订单状态
this.setPayStatus(4);
this.updateById();
}
}

View File

@ -1,22 +1,38 @@
package com.agileboot.domain.shop.payment;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.core.net.URLDecoder;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.ContentType;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpStatus;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.agileboot.domain.shop.payment.dto.WxJsApiPreCreateRequest;
import com.agileboot.domain.shop.payment.dto.WxJsApiPreCreateResponse;
import com.agileboot.common.constant.PayApiConstants;
import com.agileboot.common.utils.OpenSignUtil;
import com.agileboot.domain.shop.order.model.OrderModel;
import com.agileboot.domain.shop.order.model.OrderModelFactory;
import com.agileboot.domain.shop.payment.dto.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentApplicationService {
private final OrderModelFactory orderModelFactory;
private static final Object LOCKER = new Object();
public WxJsApiPreCreateResponse callJsApiPreCreate(WxJsApiPreCreateRequest request) {
String gatewayUrl = "http://222.218.10.217:7890/open/trade/wx/jsapi/precreate";
@ -61,13 +77,157 @@ public class PaymentApplicationService {
}
}
// 添加错误响应处理类
@Data
private static class PaymentGatewayError {
private String timestamp;
private Integer status;
private String error;
private String message;
private String path;
public void paymentCallback(String requestBody) {
// 1. 参数解析
PaymentCallbackRequest callbackReq = parseCallbackRequest(requestBody);
PaymentCallbackRequest.BizContent bizContent = parseBizContent(callbackReq.getBiz_content());
// 2. 签名验证需要根据biz_id获取对应的appKey
String appKey = getAppKeyByBizId(callbackReq.getBiz_id()); // 需要实现根据biz_id获取appKey的逻辑
boolean signValid = OpenSignUtil.checkOpenSign(appKey, callbackReq.getSign(), requestBody);
if (!signValid) {
log.error("支付回调签名验证失败:{}", requestBody);
throw new RuntimeException("支付回调签名验证失败");
}
if (bizContent.getTrade_status().equals("SUCCESS")) {
// 3. 业务处理需要实现幂等性校验
handlePaymentSuccess(
bizContent.getBiz_order_id(),
bizContent.getTotal_amount(),
bizContent.getTrade_id(),
bizContent.getTrade_pay_time()
);
} else {
log.error("支付订单失败requestBody{}", requestBody);
}
}
/**
* 退款
* @param bizId 支付网关分配的业务应用ID
* @param appKey 支付网关分配的业务应用秘钥
* @param orderId 支付时的订单ID
* @param uid 用户ID
* @param reason 退款理由
* @param money 退款金额单位
*/
public RefundVO refund(String bizId, String appKey, String orderId, String uid, String reason, int money) throws Exception {
String url = "http://222.218.10.217:7890/open/trade/refund";
JSONObject bizContent = new JSONObject();
bizContent.set("userId", uid);
bizContent.set("bizId", bizId);
bizContent.set("orderId", orderId);
bizContent.set("refundFee", money);
bizContent.set("refundReason", reason);
Map<String, String> params = new HashMap<>();
params.put("client_id", UUID.randomUUID().toString());
params.put("biz_id", bizId);
params.put("sign_type", "md5");
params.put("timestamp", String.valueOf(System.currentTimeMillis()));
params.put("version", "1.0");
params.put("nonce_str", UUID.randomUUID().toString());
params.put("biz_content", URLUtil.encode(JSONUtil.toJsonStr(bizContent)));
params.put("sign", SignUtils.openSign(appKey, params));
StringBuilder sb = new StringBuilder();
for (String key : params.keySet()) {
if (sb.length() > 0) {
sb.append("&");
}
sb.append(key).append("=").append(params.get(key));
}
String result = HttpUtil.post(url, sb.toString());
CommonResponse<RefundVO> res = JSONUtil.toBean(result, new TypeReference<CommonResponse<RefundVO>>(){}, true);
if (res.getCode() != CommonErrorCode.OK.getErrorCode()) {
throw new Exception(res.getMsg());
}
return res.getData();
}
private PaymentCallbackRequest parseCallbackRequest(String requestBody) {
// 实现将URL参数解析为PaymentCallbackRequest
// 示例实现需要根据实际参数格式调整
Map<String, String> paramMap = new HashMap<>();
for (String param : requestBody.split("&")) {
String[] pair = param.split("=");
if (pair.length == 2) {
paramMap.put(pair[0], URLDecoder.decode(pair[1], StandardCharsets.UTF_8));
}
}
return BeanUtil.toBean(paramMap, PaymentCallbackRequest.class);
}
private PaymentCallbackRequest.BizContent parseBizContent(String bizContent) {
// 实现biz_content的JSON解析
return JSONUtil.toBean(bizContent, PaymentCallbackRequest.BizContent.class);
}
// 需要实现的方法根据业务需求补充
private String getAppKeyByBizId(String bizId) {
// 根据biz_id从数据库或配置获取对应的appKey
return PayApiConstants.appkey;
}
private void handlePaymentSuccess(String bizOrderId, Integer amount, String tradeId, String tradePayTime) {
// 实现订单状态更新和幂等性校验
if (StringUtils.isNotBlank(bizOrderId) && bizOrderId.startsWith("wxshop-")) {
// 订单号格式为 wxshop-1-time提取中间的订单号
String orderId = bizOrderId.split("-")[1];
OrderModel orderModel = orderModelFactory.loadById(Long.valueOf(orderId));
orderModel.handlePaymentSuccess(amount, tradeId, tradePayTime);
}
}
public String refundCallback(String reqBody) {
JSONObject res = new JSONObject();
System.out.println("退款回调:" + reqBody);
CommonRequest<RefundVO> notifyRequest = CommonRequest.build(reqBody, RefundVO.class);
if (notifyRequest == null || notifyRequest.getBizContent() == null) {
res.set("callback_code", 1);
res.set("callback_msg", "请求body或bizcontent为空");
System.out.println("退款回调处理失败, 原因请求body或bizcontent为空");
return JSONUtil.toJsonStr(res);
}
MyError error = checkSign(notifyRequest.getBizId(), notifyRequest.getSign(), reqBody);
if (error != null) {
res.set("callback_code", 1);
res.set("callback_msg", error.getCode());
System.out.printf("退款回调处理失败, 原因:%s%n", error.getMsg());
return JSONUtil.toJsonStr(res);
}
RefundVO bizContent = notifyRequest.getBizContent();
String orderNO = bizContent.getBizOrderId();
if (StringUtils.isBlank(orderNO)) {
res.put("callback_code", 1);
res.put("callback_msg", "缺少退款订单ID");
return JSONUtil.toJsonStr(res);
}
synchronized (LOCKER) {
//根据退款订单号 orderNo 做你的业务处理退款回调可能会
//有多次业务应用要做好幂等处理
if (orderNO.startsWith("wxshop-")) {
// 订单号格式为 wxshop-1-time提取中间的订单号
String orderId = orderNO.split("-")[1];
OrderModel orderModel = orderModelFactory.loadById(Long.valueOf(orderId));
orderModel.handleRefundSuccess();
}
}
res.set("callback_code", 0);
res.set("callback_msg", "ok");
return JSONUtil.toJsonStr(res);
}
private MyError checkSign(String bizId, String sign, String reqBody) {
if (StringUtils.isBlank(bizId)) {
return new MyError(CommonErrorCode.BAD_PARAMETER, "bizId is blank");
}
if (!SignUtils.checkOpenSign(getAppKeyByBizId(bizId), sign, reqBody)) {
return new MyError(CommonErrorCode.BAD_PARAMETER, String.format("Invalid sign: %s", sign));
}
return null;
}
}

View File

@ -0,0 +1,122 @@
package com.agileboot.domain.shop.payment;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.util.*;
@Slf4j
public final class SignUtils {
/**
* 珊瑚签名计算
*/
public static String coralSign(String secret, Map<String, Object> params) {
return commonSign(secret, params);
}
/**
* 业务方签名计算
*/
public static String bizSign(String secret, Map<String, Object> params) {
return commonSign(secret, params);
}
/**
* 默认签到计算params根据key进行字典排序然后将对应值直接拼成字符串(null跳过)再将业务预生成的"secret"拼在最后用MD5算法生成签名
* @param secret appKey/appSecret
* @param params 需要签名的参数
* @return 签名
*/
public static String commonSign(String secret, Map<String, Object> params) {
Map<String, Object> sortedParams = new TreeMap<>(params);
StringBuilder plainText = new StringBuilder();
sortedParams.values().stream().filter(Objects::nonNull).forEach(plainText::append);
plainText.append(secret);
try {
return md5(plainText.toString().getBytes("utf-8"));
} catch (Exception e) {
return null;
}
}
/**
* 服务端接口加签校验按照Key升序排列拼接key1=value1&key2=value2(value为null跳过, value需要url encode)在后面拼接上&app_key=xxxmd5生成sign值
* @param appKey
* @param params
* @return
*/
public static String openSign(String appKey, Map<String, String> params) {
if(MapUtils.isEmpty(params)) {
params = new HashMap<String, String>(1);
}
Map<String, String> sortedParams = new TreeMap<>(params);
List<String> kvPairList = getKVList(sortedParams);
String sourceText = StringUtils.join(kvPairList, "&");
sourceText = sourceText + "&app_key=" + appKey;
log.info("sourceText:" + sourceText);
try {
return md5(sourceText.getBytes("utf-8"));
} catch (Exception e) {
return null;
}
}
public static boolean checkOpenSign(String appKey, String sign, String reqBody) {
String[] fields = reqBody.split("&");
Map<String, String> fieldMap = new HashMap<>();
for (String field : fields) {
String[] pair = field.split("=");
if (pair.length != 2) {
continue;
}
// sign字段不参与校验空字段不校验
if (!"sign".equals(pair[0]) && StringUtils.isNotBlank(pair[1])) {
fieldMap.put(pair[0], pair[1]);
}
}
String generateSign = openSign(appKey, fieldMap);
return Objects.equals(generateSign, sign);
}
public static List<String> getKVList(Map<String, String> params) {
if(MapUtils.isEmpty(params)) {
return Collections.emptyList();
}
List<String> kvPairs = new ArrayList<>();
for(String key : params.keySet()) {
String value = params.get(key);
if(value == null) {
continue;
}
String kv = key + "=" + value;
kvPairs.add(kv);
}
return kvPairs;
}
private SignUtils() {}
private static String md5(byte[] textBytes) {
try {
if (textBytes == null) {
return null;
} else {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(textBytes);
BigInteger number = new BigInteger(1, messageDigest);
String hashtext;
for(hashtext = number.toString(16); hashtext.length() < 32; hashtext = "0" + hashtext) {
;
}
return hashtext;
}
} catch (Exception var5) {
return null;
}
}
}

View File

@ -0,0 +1,39 @@
package com.agileboot.domain.shop.payment.dto;
public class CommonErrorCode {
// 成功
public static final CommonErrorCode OK = new CommonErrorCode(2000000, "OK");
// 400
public static final CommonErrorCode BAD_PARAMETER = new CommonErrorCode(4000001, "bad parameter: {0}");
public static final CommonErrorCode INVALID_REQUEST_BODY = new CommonErrorCode(4000002, "bad reqeust body");
// 403
public static final CommonErrorCode USER_NOT_LOGIN = new CommonErrorCode(4030001, "user not login");
public static final CommonErrorCode INVALID_REQUEST = new CommonErrorCode(4030003, "invalid request");
// 404
public static final CommonErrorCode REQUEST_NOT_FOUND = new CommonErrorCode(4040001, "request not found, {0}");
// 500
public static final CommonErrorCode INTERNAL_SERVER_ERROR = new CommonErrorCode(5000000, "internal server error, {0}");
public static final CommonErrorCode TAIR_SERVER_ERROR = new CommonErrorCode(5000003, "internal server error");
private int errorCode;
private String messagePattern;
protected CommonErrorCode() {}
protected CommonErrorCode(int errorCode, String messagePattern) {
this.errorCode = errorCode;
this.messagePattern = messagePattern;
}
public int getErrorCode() {
return errorCode;
}
public String getMessagePattern() {
return messagePattern;
}
public void throwMyError(Object... msgArgs) {
throw new MyError(this, msgArgs);
}
}

View File

@ -0,0 +1,155 @@
package com.agileboot.domain.shop.payment.dto;
import cn.hutool.core.util.URLUtil;
import cn.hutool.json.JSONUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class CommonRequest<T> {
private String clientId;
private String bizId;
private String sign;
private String signType;
private Long timestamp;
private String version;
private String nonceStr;
private T bizContent;
public CommonRequest() {
super();
}
public CommonRequest(String clientId, String bizId, String nonceStr, T bizContent) {
super();
this.clientId = clientId;
this.bizId = bizId;
this.signType = "MD5";
this.timestamp = Instant.now().toEpochMilli();
this.version = "1.0";
this.nonceStr = nonceStr;
this.bizContent = bizContent;
}
public static Map<String, String> getSSMapFromKvPairs(String reqBody) {
String[] fields = reqBody.split("&");
Map<String, String> fieldMap = new HashMap<>();
for (String field : fields) {
String[] pair = field.split("=");
if (pair.length != 2) {
continue;
}
fieldMap.put(pair[0], pair[1]);
}
return fieldMap;
}
public static <T> CommonRequest<T> build(String reqBody, Class<T> clazz) {
if (StringUtils.isBlank(reqBody)) {
return null;
}
// TODO: 防止恶意传入&
Map<String, String> fieldMap = getSSMapFromKvPairs(reqBody);
CommonRequest<T> req = new CommonRequest<T>();
Optional.ofNullable(fieldMap.get("client_id")).ifPresent(e -> req.setClientId(e));
Optional.ofNullable(fieldMap.get("biz_id")).ifPresent(e -> req.setBizId(e));
Optional.ofNullable(fieldMap.get("sign")).ifPresent(e -> req.setSign(e));
Optional.ofNullable(fieldMap.get("sign_type")).ifPresent(e -> req.setSignType(e));
Optional.ofNullable(fieldMap.get("timestamp")).ifPresent(e -> {
if (NumberUtils.isNumber(e)) {
req.setTimestamp(Long.valueOf(e));
}
});
Optional.ofNullable(fieldMap.get("version")).ifPresent(e -> req.setVersion(e));
Optional.ofNullable(fieldMap.get("nonce_str")).ifPresent(e -> {
req.setNonceStr(URLUtil.decode(e));
});
Optional.ofNullable(fieldMap.get("biz_content")).ifPresent(e -> {
req.setBizContent(JSONUtil.toBean(URLUtil.decode(e), clazz));
});
return req;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getBizId() {
return bizId;
}
public void setBizId(String bizId) {
this.bizId = bizId;
}
public String getSign() {
return sign;
}
public void setSign(String sign) {
this.sign = sign;
}
public String getSignType() {
return signType;
}
public void setSignType(String signType) {
this.signType = signType;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public String getNonceStr() {
return nonceStr;
}
public void setNonceStr(String nonceStr) {
this.nonceStr = nonceStr;
}
public T getBizContent() {
return bizContent;
}
public void setBizContent(T bizContent) {
this.bizContent = bizContent;
}
@Override
public String toString() {
return JSONUtil.toJsonStr(this);
}
}

View File

@ -0,0 +1,58 @@
package com.agileboot.domain.shop.payment.dto;
public class CommonResponse<T> {
private Integer code;
private String msg;
private T data;
public CommonResponse() {
super();
}
public CommonResponse(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public CommonResponse(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public static <T> CommonResponse<T> resultOk(T data) {
CommonResponse response = new CommonResponse(CommonErrorCode.OK.getErrorCode(), CommonErrorCode.OK.getMessagePattern(), data);
return response;
}
public static <T> CommonResponse<T> resultError(Integer errorCode,String errorMsg) {
CommonResponse response = new CommonResponse(errorCode, errorMsg);
return response;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}

View File

@ -0,0 +1,70 @@
package com.agileboot.domain.shop.payment.dto;
import cn.hutool.json.JSONUtil;
import java.text.MessageFormat;
public class MyError extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private int code;
public MyError() {
super();
}
public MyError(int code, String msg) {
super(msg, null, true, true);
this.code = code;
this.msg = msg;
}
public MyError(CommonErrorCode errorCode, Object... msgArgs) {
this(errorCode.getErrorCode(), MessageFormat.format(errorCode.getMessagePattern(), msgArgs));
}
public static void invalidUrl() {
throw new MyError(CommonErrorCode.INVALID_REQUEST);
}
public static void badParameter(String msg) {
throw new MyError(CommonErrorCode.BAD_PARAMETER, msg);
}
public static void internalError(String msg) {
throw new MyError(CommonErrorCode.INTERNAL_SERVER_ERROR, msg);
}
public static void userNotLogin() {
throw new MyError(CommonErrorCode.USER_NOT_LOGIN);
}
public static void throwError(CommonErrorCode errorCode, Object ...args) {
throw new MyError(errorCode, args);
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
@Override
public String toString() {
return JSONUtil.toJsonStr(this);
}
}

View File

@ -0,0 +1,12 @@
package com.agileboot.domain.shop.payment.dto;
import lombok.Data;
@Data
public class PaymentGatewayError {
private String timestamp;
private Integer status;
private String error;
private String message;
private String path;
}

View File

@ -0,0 +1,49 @@
package com.agileboot.domain.shop.payment.dto;
public class RefundVO {
private Boolean success;
private String msg;
private String refundId;
private String bizOrderId;
private String wxCallbackJson;
public Boolean getSuccess() {
return success;
}
public void setSuccess(Boolean success) {
this.success = success;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public String getRefundId() {
return refundId;
}
public void setRefundId(String refundId) {
this.refundId = refundId;
}
public String getBizOrderId() {
return bizOrderId;
}
public void setBizOrderId(String bizOrderId) {
this.bizOrderId = bizOrderId;
}
public String getWxCallbackJson() {
return wxCallbackJson;
}
public void setWxCallbackJson(String wxCallbackJson) {
this.wxCallbackJson = wxCallbackJson;
}
}

View File

@ -41,3 +41,12 @@ CREATE TABLE `wx_user_info` (
PRIMARY KEY (`wx_user_id`),
UNIQUE KEY `uk_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信用户基本信息表';
-- 在已有表结构下方添加
ALTER TABLE `return_approval`
ADD COLUMN `order_goods_id` BIGINT NOT NULL COMMENT '关联订单商品ID' AFTER `goods_id`,
ADD KEY `idx_order_goods` (`order_goods_id`),
ADD CONSTRAINT `fk_return_order_goods` FOREIGN KEY (`order_goods_id`) REFERENCES `shop_order_goods` (`order_goods_id`);
ALTER TABLE `shop_order`
ADD COLUMN `biz_order_id` VARCHAR(32) NULL COMMENT '业务系统订单ID对接外部系统' AFTER `trade_id`;