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();
}
}
}