一、前期准备
项目代码https://gitee.com/lcaicai/xdvideo.git
微信扫码支付官方文档:https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_5
从官方文档主要汲取交互流程,调用接口或者获得回调时发送、接收的参数规范,错误码的提示。
什么是appid、appsecret、授权码code
appid和appsecret是 资源所有者向申请人分配的一个id和秘钥
code是授权凭证,A->B 发起授权,想获取授权用户信息,那a必须携带授权码,才可以向B获取授权信息
二、与微信交互总体流程
扫码登录时序图:
1、用点击微信第三方登录,第三方应用带上appid、回调地址(开放平台重定向地址,第三方应用)、当前页面url请求微信开放平台,微信开放平台返回登录二维码。
2、用户扫码确认,此时微信开放平台根据之前的回调地址带上code码进行回调,第三方应用带上appid、appappsecret、微信回调的code请求微信,获得access_token,此时便可以带上access_token去请求用户公开的基本信息(用户名、头像等)。
扫码支付时序图:
1、用户点击购买对象,请求商户后台系统生成订单(此时的订单的支付状态是未支付,等待微信平台通知支付成功消息再修改订单状态)。
2、商户系统调微信统一下单API,如果签名校验成功,微信平台会返回一个codeurl(二维码url)。
3、商户系统拿到codeurl转成二维码图片呈现给用户。
4、用户扫码支付确认,微信平台回调商户系统,通知用户已经支付成功,商户系统修改用户订单状态,并且通知微信订单处理成功(在收到商户系统确认信息前,微信平台有个策略会按照一定时间间隔一直请求商户系统)。
三、所用技术栈
总体框架:springboot+mybatis+maven+mysql
pageHelper 分页插件、JWT标准解决单点登录、google二维码生成包、httpClient
使用netapp做本地域名映射,对外提供访问,具体使用方法见netapp的官方文档。
四、项目详述
1、微信扫码登录
用户点击微信第三方登录,带上当前页面地址请求商户系统后端接口
//获取微信扫码地址
function get_wechat_url() {
//获取当前页面地址
var current_page = window.location.href;
//向后端发送请求获取url
$.ajax({
type:'get',
url:host+'/api/v1/wechat/login_url?access_url='+current_page,
dataType:'json',
success:function(res){
$("#login").attr("href",res.data);
global_login_url = res.data;
}
})
}
后端返回处理拼接好的url
@RequestMapping("login_url")
@ResponseBody
public JsonData loginUrl(@RequestParam(value = "access_url",required = true) String accessUrl) throws UnsupportedEncodingException {
//拼接上需要微信回调的商户系统url
String redirectUrl = weChatConfig.getOpenRedirectUrl();//获取开放平台重定向地址
String callbackUrl = URLEncoder.encode(redirectUrl,"GBK");
//private final String OPEN_QRCODE_URL= "https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect";用占位符替换参数
String qrcodeUrl = String.format(weChatConfig.getOpenQrcodeUrl(),weChatConfig.getOpenAppId(),callbackUrl,accessUrl);
return JsonData.buildSuccess(qrcodeUrl);
}
用户扫码登录,微信平台带上授权code回调redirectUrl接口
/**
* 用户扫码后,微信平台自动带上code回调该接口,获取access_token,再带上access_token去获取用户 信息
* 生成token返回给前端,并且重定向到state页面(用户当前页面)
* @param code
* @param state
* @param response
* @throws IOException
*/
@RequestMapping("user_Callback")
public void weChatUserCallback(@RequestParam(value = "code",required = true)String code,
String state, HttpServletResponse response) throws IOException {
User user = userService.saveWeChatUser(code);
if (null != user){
//生成JWT,返回客户端
String token = JwtUtils.geneJsonWebToken(user);
//页面地址返回给前端,并重定向到state URL页面
//state 当前用户的页面地址,需要拼接 http:// 这样才不会站内跳转
response.sendRedirect(state+"?token="+token+"&head_img="+user.getHeadImg()+"&name="+URLEncoder.encode(user.getName(),"UTF-8"));
}
}
根据微信code用HttpClient去请求access_token,来获取用户基本信息,存库。
@Override
public User saveWeChatUser(String code) {
try {
String get_access_token_url = String.format(WeChatConfig.getOpenAccessTokenUrl(),
weChatConfig.getOpenAppId(),weChatConfig.getOpenAppSecret(),code);
//获取acess_token
Map<String,Object> access_token_map = HttpUtils.doGet(get_access_token_url);
if (null == access_token_map || access_token_map.isEmpty()){
return null;
}
String accessToken = (String) access_token_map.get("access_token");
String openid = (String) access_token_map.get("openid");
//获取用户基本信息
String userInfoUrl = String.format(WeChatConfig.getOpenUserInfoUrl(),
accessToken,openid);
Map<String,Object> userMap = HttpUtils.doGet(userInfoUrl);
if (null == userMap || userMap.isEmpty()){
return null;
}
User dbUser = userMapper.findUserByOpenId(openid);
//如果用户已经存在,不需要再插入该用户信息,只需在其他接口更新信息
if (null != dbUser){
return dbUser;
}
String nickname = (String) userMap.get("nickname");
//解决乱码问题
nickname = new String(nickname.getBytes("ISO-8859-1"), "UTF-8");
Double sexTemp = (Double) userMap.get("sex");
int sex = sexTemp.intValue();
String province = (String) userMap.get("province");
String city = (String) userMap.get("city");
String country = (String) userMap.get("country");
String headimgurl = (String) userMap.get("headimgurl");
String unionid = (String) userMap.get("unionid");
//拼接国家省份,用","分割
StringBuilder stringBuilder = new StringBuilder(country).append(",")
.append(province).append(",").append(city);
String finalAddress = stringBuilder.toString();
//请求用户地址加入'lang=zh_CN'后乱码,在这里转一次码
finalAddress = new String(finalAddress.getBytes("ISO-8859-1"), "UTF-8");
User user = new User();
user.setName(nickname);
user.setSex(sex);
user.setHeadImg(headimgurl);
user.setCity(finalAddress);
user.setCreateTime(new Date());
userMapper.save(user);
return user;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
利用JWT存储用户session,设置过期时间,之后每次请求前获取cookie中的token,带上token请求
public static final String SUBJECT = "LC666";
public static final long EXPIRE = 1000*60*60*24*3;
public static final String APPSECRET = "LC666";
/**
* 生成JWT
* @param user
* @return
*/
public static String geneJsonWebToken(User user){
if (null == user || null == user.getId() || null == user.getName()
|| null == user.getHeadImg()){
return null;
}
String token = Jwts.builder().setSubject(SUBJECT)
//此时user对象已经插入到User表中,并且mapper中insert插入时,
// 设置了@Options(useGeneratedKeys=true, keyProperty="id", keyColumn="id")注解
//user对象插入表中,mybatis会自动获取自动增长的主键id,并且自动注入到User实体类的id属性中
.claim("id",user.getId())
.claim("name",user.getName())
.claim("img",user.getHeadImg())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis()+EXPIRE))
.signWith(SignatureAlgorithm.HS256,APPSECRET).compact();
return token;
}
后台将token、用户信息返回前端,并且重定向到之前的页面,前端拿到url设置用户昵称,头像等。
//页面地址返回给前端,并重定向到state URL页面
//state 当前用户的页面地址,需要拼接 http:// 这样才不会站内跳转
response.sendRedirect(state+"?token="+token+"&head_img="+user.getHeadImg()+"&name="+URLEncoder.encode(user.getName(),"UTF-8"));
2、微信扫码支付
用户点击购买某个对象,后端生成订单,调微信统一下单接口。
/**
* 订单接口
*/
@RestController
//@RequestMapping("/user/api/v1/order")
@RequestMapping("/api/v1/order")
public class OrderController {
@Autowired
private VedioOrderServise vedioOrderServise;
@GetMapping(value = "add")
public void saveOrder(@RequestParam(value = "vedio_id", required = true)int videoId,
HttpServletResponse response,HttpServletRequest request) throws Exception {
String ip = IpUtils.getIpAddr(request);
// int userId = (Integer)request.getAttribute("user_id");
int userId = 1;//测试先付个值
// String ip = "120.25.1.43";//测试用的ip
VideoOrderDto videoOrderDto = new VideoOrderDto();
videoOrderDto.setUserId(userId);
videoOrderDto.setVideoId(videoId);
videoOrderDto.setIp(ip);
String codeUrl = vedioOrderServise.save(videoOrderDto);
//生成二维码
if (null == codeUrl){
throw new NullPointerException();
}
//调用二维码生成工具
CommonUtils.generateQRByCodeurl(codeUrl,response);
}
}
生成订单,调统一下单接口
@Override
@Transactional(propagation = Propagation.REQUIRED) //下单的时候开启一个事物
public String save(VideoOrderDto videoOrderDto) throws Exception {
//日志打点
dataLogger.info("module=video_order`api=save`user_id={}`video_id={}",videoOrderDto.getUserId(),videoOrderDto.getVideoId());
//查找视频信息
Video video = videoMapper.findById(videoOrderDto.getVideoId());
//查找用户信息
User user = userMapper.findUserByid(videoOrderDto.getUserId());
//生成订单
VideoOrder videoOrder = new VideoOrder();
videoOrder.setTotalFee(video.getPrice());
videoOrder.setVideoImg(video.getCoverImg());
videoOrder.setVideoTitle(video.getTitle());
videoOrder.setCreateTime(new Date());
videoOrder.setVideoId(video.getId());
videoOrder.setState(0);
videoOrder.setUserId(user.getId());
videoOrder.setHeadImg(user.getHeadImg());
videoOrder.setNickname(user.getName());
videoOrder.setDel(0);
videoOrder.setIp(videoOrderDto.getIp());
videoOrder.setOutTradeNo(CommonUtils.generateUUID());//生成流水号
videoOrderMapper.insert(videoOrder);
//获取codeurl
String codeUrl = unifiedOrder(videoOrder);
if (null != codeUrl){
return codeUrl;
}
return null;
}
统一订单方法(这里需要商户申请的id,具体请看微信扫码支付开发文档),与微信交互方式为xml方式,微信平台提供了map与xml互相转换的方法,也可以使用自己写的,重点是sign签名校验,在微信平台和服务端都保存了key,这个key用与sign签名校验。
/**
* 统一下单方法
* @return
*/
private String unifiedOrder (VideoOrder videoOrder) throws Exception {
//生成签名
SortedMap<String,String> params = new TreeMap<>();
params.put("appid",weChatConfig.getAppId());
params.put("mch_id", weChatConfig.getMchId());
params.put("nonce_str",CommonUtils.generateUUID());
params.put("body",videoOrder.getVideoTitle());
params.put("out_trade_no",videoOrder.getOutTradeNo());
params.put("total_fee",videoOrder.getTotalFee().toString());
params.put("spbill_create_ip",videoOrder.getIp());
params.put("notify_url",weChatConfig.getPayCallbackUrl());
params.put("trade_type","NATIVE");
//sign签名
String sign = WXPayUtil.createSign(params,weChatConfig.getKey());
params.put("sign",sign);
//map转xml
String payXml = WXPayUtil.mapToXml(params);
// System.out.println("========支付请求xml"+payXml);
//统一下单
String orderStr = HttpUtils.doPost(WeChatConfig.getUnifiedOrderUrl(),payXml,8000);
if (null == orderStr){
return null;
}
Map<String,String> unifieOrderMap = WXPayUtil.xmlToMap(orderStr);
// System.out.println(unifieOrderMap.toString());
if (null != unifieOrderMap){
return unifieOrderMap.get("code_url");
}
return null;
}
生成sign签名,key不能暴露!!!微信那边保存着一模一样的key,将你传过去的用户信息使用相同策略加密,最后会和你传过去的sign进行比较,如果相同此次请求才能成功。
签名生成的通用步骤如下:
第一步,设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串stringA。
第二步,在stringA最后拼接上key得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写,得到sign值signValue。key设置路径:微信商户平台(pay.weixin.qq.com)-->账户设置-->API安全-->密钥设置
可以使用SortedMap将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序)。
生成签名后,通过工具去校验(这里有个坑,参数params里面的值不能有空格,否则会校验不成功)
https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=20_1
/**
* 生成微信支付sign
* @param params
* @param key
* @return
*/
public static String createSign(SortedMap<String,String> params,String key){
StringBuilder sb = new StringBuilder();
Set<Map.Entry<String, String>> entries = params.entrySet();
Iterator<Map.Entry<String, String>> it = entries.iterator();
while (it.hasNext()){
Map.Entry<String, String> entry = (Map.Entry<String, String>)it.next();
String k = entry.getKey();
String v = entry.getValue();
if (null != k && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)){
sb.append(k+"="+v+"&");
}
}
sb.append("key=").append(key);
String sign = CommonUtils.MD5(sb.toString()).toUpperCase();
return sign;
}
准备好sign和排序好的Map转的xml后,调微信统一下单接口,微信校验sign成功返回支付码url,通过google工具生成二维码图片。
/**
* 统一下单url
*/
private static final String UNIFIED_ORDER_URL = "https://api.xdclass.net/pay/unifiedorder";
//统一下单
String orderStr = HttpUtils.doPost(WeChatConfig.getUnifiedOrderUrl(),payXml,8000);
//生成二维码
if (null == codeUrl){
throw new NullPointerException();
}
//调用二维码生成工具
CommonUtils.generateQRByCodeurl(codeUrl,response);
/**
* 将支付码链接通过google二维码工具生成
* @param codeUrl
* @param response
*/
public static void generateQRByCodeurl(String codeUrl, HttpServletResponse response){
try {
//生成二维码配置
Map<EncodeHintType,Object> hints = new HashMap<>();
//设置纠错等级
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L);
//编码类型
hints.put(EncodeHintType.CHARACTER_SET,"UTF-8");
//将链接及配置放入google二维码生成类 初始化
BitMatrix bitMatrix = new MultiFormatWriter()
.encode(codeUrl, BarcodeFormat.QR_CODE,400,400,hints);
//将二维码以流的形式输出浏览器
OutputStream outputStream = response.getOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix,"png",outputStream);
}catch (Exception e){
e.printStackTrace();
}
}
用户扫码,微信平台回调商户系统(这里使用BufferedReader效率更高),使用相同策略校验sign,校验成功后获取支付状态,如果为SUCCESS,即用户支付成功,商户后台修改订单状态,这里要注意幂等,由于网络原因微信平台没有收到商户系统的响应,会每隔一段时间回调一次该接口,所以在修改订单状态前需要判断下订单state,如果state等于未支付状态才回去数据库中修改,否则不修改。
@RequestMapping("/order/callback1")
public void orderCallback(HttpServletRequest request,HttpServletResponse response) throws Exception {
//将参数转成流的形式
InputStream inputStream = request.getInputStream();
//BufferedReader是包装设计模式,性能更高
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream,"UTF-8"));
//拼接微信回调的xml
StringBuffer sb = new StringBuffer();
String line ;
while (null != (line = bufferedReader.readLine())){
sb.append(line);
}
bufferedReader.close();
inputStream.close();
Map<String,String> callMap = WXPayUtil.xmlToMap(sb.toString());
// System.out.println(callMap.toString());
SortedMap<String,String> sortedMap = WXPayUtil.getSortedMap(callMap);
//校验签名是否正确
boolean flag = WXPayUtil.isCorrectSign(sortedMap,weChatConfig.getKey());
if (flag == true){
if ("SUCCESS".equals(sortedMap.get("result_code"))){
String outTradeNo = sortedMap.get("out_trade_no");
//此时订单已经在扫码之前插入订单表,生成了订单
VideoOrder dbvideoOrder = vedioOrderServise.findByOutTradeNo(outTradeNo);
//注意幂等性,防止多次相同请求调用
if (null != dbvideoOrder && dbvideoOrder.getState() == 0){
VideoOrder videoOrder = new VideoOrder();
videoOrder.setOpenid(sortedMap.get("openid"));
videoOrder.setOutTradeNo(outTradeNo);
videoOrder.setNotifyTime(new Date());
videoOrder.setState(1);
int row = vedioOrderServise.updateVideoOderByOutTradeNo(videoOrder);
if (row == 1){//通知微信订单处理成功
response.setContentType("text/xml");
response.getWriter().println("success");
}
}
}
}
response.setContentType("text/xml");
response.getWriter().println("fail");
}
token校验
拦截器
/**
* 登录时对用户进行拦截
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String token = request.getHeader("token");
if (null == token){
token = request.getParameter("token");
}
if (null != token){
Claims claims = JwtUtils.checkJWT(token);
if (null != claims){
Integer userid = (Integer) claims.get("id");
String name = (String) claims.get("name");
//setAttribute 以便controller可以获取到用户信息
request.setAttribute("user_id",userid);
request.setAttribute("name",name);
return true;
}
}
sendJsonMessage(response, JsonData.buildError("请登录"));
return false;
}
JWT校验token,并且把用户基本信息放入request中,方便于用户下单获取用户昵称等信息。
/**
* 校验token
* @param token
* @return
*/
public static Claims checkJWT(String token){
try{
final Claims claims = Jwts.parser().setSigningKey(APPSECRET)
.parseClaimsJws(token).getBody();
return claims;
}catch (Exception e){}
return null;
}
五、总结
总体比较简单,该项目非常适合拿来练手,主要学习的点有微信的交互流程、业务,之前没有用过JWT,可以学习下,也很简单。项目中的各种工具类都可以在以后的项目中直接复用,个人建议把所有工具类都抽出来。当然,也会有些小坑,最好根据微信开发官方文档的错误码提示来定位。代码在gitte上,地址在博客最上面,后期还会做些优化。