【WeChatPayment】基础支付API(4)-生成订单、支付通知

6. 生成订单

6.1. 存入数据库

在 OrderInfo 业务层创建生成订单的业务,即创建新的接口和对应实现方法

(1)OrderInfoService 接口:根据商品 id 创建一个订单

(2)实现类中,首先需要能通过商品 id 查出商品信息

因此需要先注入 productMapper

再通过 id 获取商品信息,得到商品对象

(3)生成订单部分参考 WxPayService 实现类的部分,将商品相关常量改为通过商品信息获取

(4)将商品存入数据库

(5)在 WxPayService 实现类中调用 OrderInfo 的生成,代替原来的步骤

该方法中的后续步骤就可以从生成的订单 orderInfo 中,获取调用统一下单 api 过程中需要的各种信息

@Resource
private ProductMapper productMapper;


@Override
public OrderInfo createOrderByProductId(Long productId) {

    //查找已存在但未支付的订单
    OrderInfo orderInfo = this.getNoPayOrderByProductId(productId);
    if( orderInfo != null){
        return orderInfo;
    }

    //获取商品信息
    Product product = productMapper.selectById(productId);

    //生成订单
    orderInfo = new OrderInfo();
    orderInfo.setTitle(product.getTitle());
    orderInfo.setOrderNo(OrderNoUtils.getOrderNo()); //订单号
    orderInfo.setProductId(productId);
    orderInfo.setTotalFee(product.getPrice()); //分
    orderInfo.setOrderStatus(OrderStatus.NOTPAY.getType());
    baseMapper.insert(orderInfo);

    return orderInfo;
}

6.2. 优化:获取已存在的订单

(1)在生成订单之前,先查找已存在但未支付的订单

若存在,就把之前未支付的订单获取出来将二维码展示给用户

(2)创建辅助方法:根据商品 id 查询未支付订单

实现:在数据库中创建一个查询——商品 id 相同且订单状态为未支付

QueryWrapper:MyBatis-Plus 中的查询对象,可以以面向对象的形式组装查询语句

/**
* 根据商品id查询未支付订单
* 防止重复创建订单对象
* @param productId
* @return
*/
private OrderInfo getNoPayOrderByProductId(Long productId) {

    QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("product_id", productId);
    queryWrapper.eq("order_status", OrderStatus.NOTPAY.getType());

    // queryWrapper.eq("user_id", userId);
    OrderInfo orderInfo = baseMapper.selectOne(queryWrapper);
    return orderInfo;
}

启动项目测试

6.3. 优化:存储二维码地址

原本的下单方法中,每次生成或获取订单后都需要调用统一下单的 api,若用户频繁操作,那么 api 也会被频繁调用

而在 API 文档中,二维码有效期为 2 小时,因此在有效期内,可以在存储订单的同时把二维码地址也缓存起来,需要时直接使用,就不用重复调用 api 了

(1)service 中创建接口

(2)实现方法:根据订单号给订单的 codrUrl 赋值

baseMapper 的 update 方法:根据一个查询条件 queryWrapper 更新一条数据记录 orderInfo

/**
* 存储订单二维码
* @param orderNo
* @param codeUrl
*/
@Override
public void saveCodeUrl(String orderNo, String codeUrl) {
    QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("order_no", orderNo);

    OrderInfo orderInfo = new OrderInfo();
    orderInfo.setCodeUrl(codeUrl);
    baseMapper.update(orderInfo, queryWrapper);
}

(3)在 WxPayService 实现类中调用方法

调用统一下单接口时,在得到 codeUrl 之后,返回值之前存储该二维码

而在每次生成订单后,先判断该订单二维码是否已保存,若是,则直接返回二维码,不需要再调用统一下单接口和后续操作了

@Resource
private OrderInfoService orderInfoService;

/**
* 创建订单,调用Native支付接口
* @param productId
* @return code_url 和 订单号
* @throws Exception
*/
@Override
public Map<String, Object> nativePay(Long productId) throws Exception {

    log.info("生成订单");

    //生成订单
    OrderInfo orderInfo = orderInfoService.createOrderByProductId(productId);
    String codeUrl = orderInfo.getCodeUrl();
    if(orderInfo != null && !StringUtils.isEmpty(codeUrl)){
        log.info("订单已存在,二维码已保存");

        //返回二维码
        Map<String, Object> map = new HashMap<>();
        map.put("codeUrl", codeUrl);
        map.put("orderNo", orderInfo.getOrderNo());
        return map;
    }

    log.info("调用统一下单API");

    //其他代码。。。。。。
    try {

    //其他代码。。。。。。
    
    //保存二维码
    String orderNo = orderInfo.getOrderNo();
    orderInfoService.saveCodeUrl(orderNo, codeUrl);
    
    //返回二维码
    //其他代码。。。。。。
    } finally {
        response.close();
    }
}

6.4. 查询订单列表

在前端“我的订单”中将订单按创建时间的倒序展示出来,即最新创建的排在最前面

(1)在 controller 层创建 OrderInfoController 接口文件

业务层注入 OrderInfoService

import com.atguigu.paymentdemo.entity.OrderInfo;
import com.atguigu.paymentdemo.service.OrderInfoService;
import com.atguigu.paymentdemo.vo.R;
import io.swagger.annotations.Api;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@CrossOrigin //开放前端的跨域访问
@Api(tags = "商品订单管理")
@RestController
@RequestMapping("/api/order-info")
public class OrderInfoController {

    @Resource
    private OrderInfoService orderInfoService;

    @ApiOperation("订单列表")
    @GetMapping("/list")
    public R list(){
        List<OrderInfo> list = orderInfoService.listOrderByCreateTimeDesc();
        return R.ok().data("list", list);
    }
}

(2)在业务层实现业务方法:根据创建时间倒序排列

/**
* 查询订单列表,并倒序查询
* @return
*/
@Override
public List<OrderInfo> listOrderByCreateTimeDesc() {
    QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<OrderInfo().orderByDesc("create_time");
    return baseMapper.selectList(queryWrapper);
}

启动项目测试

7. 支付通知

Ngrok内网穿透详见文章:Ngrok内网穿透教程-优快云博客

修改配置文件

原接口的内网地址访问

外网地址访问

7.2. 通知的基本接收和应答

(1)启动 ngrok

(2)配置文件中设置通知地址

(3)在 controller 层 WxPayController 中创建通知接口

通知接口:用户支付完成后,微信把相关支付结果和用户信息发送给商户,商户需要接收处理该消息,并返回应答

支付结果通知报文是以 POST 方法访问商户设置的 url,通知的数据以 JSON 格式通过请求主体 body 传输,数据中包括了加密的支付结果详情

由于涉及到回调加解密,商户需要先设置好 api v3 的密钥后才能解密回调通知

① 设置通知地址

② 处理通知参数

使用 httpUtils 工具类的方法从请求体 body 中拿到 json 数据,并转换为 map 格式,打印相关数据

③ 成功应答:返回状态码 code 和信息 message

(新版无应答包体)

/**
* 支付通知
* 微信支付通过支付通知接口将用户支付成功消息通知给商户
*/
@ApiOperation("支付通知")
@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response){
    
    Gson gson = new Gson();
    Map<String, String> map = new HashMap<>();//应答对象

    //处理通知参数
    String body = HttpUtils.readData(request);
    Map<String, Object> bodyMap = gson.fromJson(body, HashMap.class);
    log.info("支付通知的id ===> {}", bodyMap.get("id"));
    log.info("支付通知的完整数据 ===> {}", body);

    //TODO : 签名的验证
    //TODO : 处理订单

    //成功应答:成功应答必须为200或204,否则就是失败应答
    response.setStatus(200);
    map.put("code", "SUCCESS");
    map.put("message", "成功");

    return gson.toJson(map);
}

④ 支付成功测试(测试前把数据库中的订单清空)

7.3. 应答异常和超时

@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {

    Gson gson = new Gson();
    Map<String, String> map = new HashMap<>();

    try {
    } catch (Exception e) {
        e.printStackTrace();

        // 测试错误应答
        response.setStatus(500);
        map.put("code", "ERROR");
        map.put("message", "系统错误");
        return gson.toJson(map);
    }
}

7.4. 针对请求的签名验证

(前文是针对响应的验签,SDK 有自动封装)

(1)将 SDK 中的响应验签复制到 util 工具包下,将工具类改为针对请求的验签

(2)修改验签方法

改动较繁琐,具体看代码

(3)调用验签方法

import com.wechat.pay.contrib.apache.httpclient.auth.Verifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;

import static com.wechat.pay.contrib.apache.httpclient.constant.WechatPayHttpHeaders.*;


public class WechatPay2ValidatorForRequest{

    protected static final Logger log = LoggerFactory.getLogger(WechatPay2ValidatorForRequest.class);
    /**
     * 应答超时时间,单位为分钟
     */
    protected static final long RESPONSE_EXPIRED_MINUTES = 5;
    protected final Verifier verifier;
    protected final String body;
    protected final String requestId;

    public WechatPay2ValidatorForRequest(Verifier verifier, String body, String requestId) {
        this.verifier = verifier;
        this.body = body;
        this.requestId = requestId;
    }

    protected static IllegalArgumentException parameterError(String message, Object... args) {
        message = String.format(message, args);
        return new IllegalArgumentException("parameter error: " + message);
    }

    protected static IllegalArgumentException verifyFail(String message, Object... args) {
        message = String.format(message, args);
        return new IllegalArgumentException("signature verify fail: " + message);
    }

    public final boolean validate(HttpServletRequest request) throws IOException {
        try {
            //处理请求参数
            validateParameters(request);

            //构造验签串
            String message = buildMessage(request);
            String serial = request.getHeader(WECHAT_PAY_SERIAL);
            String signature = request.getHeader(WECHAT_PAY_SIGNATURE);

            //验签
            if (!verifier.verify(serial, message.getBytes(StandardCharsets.UTF_8), signature)) {
                throw verifyFail("serial=[%s] message=[%s] sign=[%s], request-id=[%s]",
                        serial, message, signature, request.getHeader(REQUEST_ID));
            }
        } catch (IllegalArgumentException e) {
            log.warn(e.getMessage());
            return false;
        }

        return true;
    }

    protected final void validateParameters(HttpServletRequest request) {

        // NOTE: ensure HEADER_WECHAT_PAY_TIMESTAMP at last
        String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP};

        String header = null;
        for (String headerName : headers) {
            header = request.getHeader(headerName);
            if (header == null) {
                throw parameterError("empty [%s], request-id=[%s]", headerName, requestId);
            }
        }

        //判断是否过期
        String timestampStr = header;
        try {
            Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr));
            // 拒绝过期应答
            if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) {
                throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId);
            }
        } catch (DateTimeException | NumberFormatException e) {
            throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId);
        }
    }

    protected final String buildMessage(HttpServletRequest request) throws IOException {
        String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP);
        String nonce = request.getHeader(WECHAT_PAY_NONCE);
        return timestamp + "\n"
                + nonce + "\n"
                + body + "\n";
    }
}

(4)重启项目测试

7.5. 通知参数 resource 解密

resource 属性:

  • 加密算法类型:AEAD_AES_256_GCM
  • 数据密文:Base64 编码后的回调数据密文
  • 附加数据:参与解密的附加数据,16 字节,可能为空
  • 原始回调类型:加密前的对象类型
  • 随机串:参与解密,12 字节

(1)获取 api v3 对称密钥(32 字节)

(2)针对 resource 的算法类型,获取对应的随机串和附加数据

(3)用密钥、随机串和附加数据对数据密文进行解密,得到 json 形式的资源对象

(4)重启项目测试

7.6. 处理订单

(1)转换明文格式、更新订单状态、记录支付日志

@Resource
private PaymentInfoService paymentInfoService;

@Override
public void processOrder(Map<String, Object> bodyMap) throws GeneralSecurityException {

    log.info("处理订单");
    String plainText = decryptFromResource(bodyMap);

    //转换明文
    Gson gson = new Gson();
    Map<String, Object> plainTextMap = gson.fromJson(plainText, HashMap.class);
    String orderNo = (String)plainTextMap.get("out_trade_no");

    //更新订单状态
    orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);

    //记录支付日志
    paymentInfoService.createPaymentInfo(plainText);
}

更新订单状态方法

/**
* 根据订单编号更新订单状态
* @param orderNo
* @param orderStatus
*/
@Override
public void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus) {

    log.info("更新订单状态 ===> {}", orderStatus.getType());
    QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("order_no", orderNo);
    
    OrderInfo orderInfo = new OrderInfo();
    orderInfo.setOrderStatus(orderStatus.getType());

    baseMapper.update(orderInfo, queryWrapper);
}

记录支付日志方法

/**
* 记录支付日志
* @param plainText
*/
@Override
public void createPaymentInfo(String plainText) {

    log.info("记录支付日志");
    
    Gson gson = new Gson();
    Map<String, Object> plainTextMap = gson.fromJson(plainText, HashMap.class);

    String orderNo = (String)plainTextMap.get("out_trade_no");
    String transactionId = (String)plainTextMap.get("transaction_id");
    String tradeType = (String)plainTextMap.get("trade_type");
    String tradeState = (String)plainTextMap.get("trade_state");
    
    Map<String, Object> amount = (Map)plainTextMap.get("amount");
    Integer payerTotal = ((Double) amount.get("payer_total")).intValue();

    PaymentInfo paymentInfo = new PaymentInfo();
    paymentInfo.setOrderNo(orderNo);
    paymentInfo.setPaymentType(PayType.WXPAY.getType());
    paymentInfo.setTransactionId(transactionId);
    paymentInfo.setTradeType(tradeType);
    paymentInfo.setTradeState(tradeState);
    paymentInfo.setPayerTotal(payerTotal);
    paymentInfo.setContent(plainText);
    
    baseMapper.insert(paymentInfo);
}

(2)处理重复通知和接口调用的幂等性

重复通知情况:响应超时,不会影响修改订单状态,但会影响记录日志

处理方法:检查订单状态

  • 未支付:数据未被处理,重新处理
  • 已支付:数据已被处理,直接返回结果成功

//处理重复通知
//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {
    return;
}
/**
* 根据订单号获取订单状态
* @param orderNo
* @return
*/
@Override
public String getOrderStatus(String orderNo) {

    QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("order_no", orderNo);

    OrderInfo orderInfo = baseMapper.selectOne(queryWrapper);

    //防止被删除的订单的回调通知的调用
    if(orderInfo == null){
        return null;
    }
    
    return orderInfo.getOrderStatus();
}

(3)数据锁-并发控制

private final ReentrantLock lock = new ReentrantLock();

@Override
public void processOrder(Map<String, Object> bodyMap) throws GeneralSecurityException {

    log.info("处理订单");

    //解密报文
    String plainText = decryptFromResource(bodyMap);

    //将明文转换成map
    Gson gson = new Gson();
    HashMap plainTextMap = gson.fromJson(plainText, HashMap.class);
    String orderNo = (String)plainTextMap.get("out_trade_no");

    /*在对业务数据进行状态检查和处理之前,
    要采用数据锁进行并发控制,
    以避免函数重入造成的数据混乱*/

    //尝试获取锁:
    // 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放
    if(lock.tryLock()){
        try {
            //处理重复的通知
            //接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的。
            String orderStatus = orderInfoService.getOrderStatus(orderNo);
            if(!OrderStatus.NOTPAY.getType().equals(orderStatus)){
                return;
            }

            //模拟通知并发
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            //更新订单状态
            orderInfoService.updateStatusByOrderNo(orderNo,
            OrderStatus.SUCCESS);

            //记录支付日志
            paymentInfoService.createPaymentInfo(plainText);
        } finally {

            //要主动释放锁
            lock.unlock();
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值