支付宝扫码支付Spring版本
在阿里官方给的支付宝扫码支付demo中,使用的是jsp版本,今天参照官方demo将其整合到Spring中。
我们主要做个demo模拟Q币充值过程,其主要功能:
1、登录,充值前得有用户登入
2、充Q币(包含:下单+支付+充值,3个过程)
如何开通支付宝沙箱或查看其它API参见《蚂蚁金服开放平台》或者自行百度。
首先介绍一下支付宝支付流程,大致如下:
实际上的流程远没有那么简单,支付流程伴随的各种数据会存储在支付宝服务器中。
现支付宝的通知有两类。
A、异步通知,也就是服务器通知,对应的参数为notify_url,支付宝通知使用POST方式 ,告诉服务器该订单已支付成功
B、同步通知,也就是页面跳转通知,对应的参数为return_url,支付宝通知使用GET方式 ,主要是给用户看以便后续操作
支付成功后支付宝会向并且异步通知的处理是非常重要的,由于项目部署在本地,支付宝无法获取success反馈(公网无法访问到本地服务器),我们仅仅做同步通知处理。
==================================== 项目开始 ====================================
技术:SpringBoot + Freemarker
项目结构:
application.properties配置
###FREEMARKER (FreeMarkerAutoConfiguration)###
# 设定ftl文件路径
spring.freemarker.template-loader-path=classpath:/templates
spring.freemarker.cache=true
spring.freemarker.charset=UTF-8
spring.freemarker.check-template-location=true
spring.freemarker.content-type=text/html
spring.freemarker.expose-request-attributes=false
spring.freemarker.expose-session-attributes=false
spring.freemarker.request-context-attribute=request
spring.freemarker.suffix=.ftl
0、导入alipay官方lib
这里我已经提前将lib导入本地Maven仓库了,不会的小伙伴可以自行百度“添加jar包到本地Maven仓库”,或者手动导包,lib在阿里官方支付宝demo附带。
<!-- 支付宝依赖 -->
<dependency>
<groupId>com.alipay</groupId>
<artifactId>alipay-sdk</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.alipay</groupId>
<artifactId>alipay-sdk-source</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.alipay</groupId>
<artifactId>commons-logging</artifactId>
<version>1.1.1</version>
</dependency>
1、4个前端页面:
登录页,首页(充值页),充Q币成功页,充Q币失败页
login.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h1>登录</h1>
<form action="/index">
<input type="text" name="username" placeholder="用户名"/>
<input type="password" name="password"placeholder="密码"/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
index.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
<h1>首页</h1>
<h3>充值Q币</h3>
<form action="/recharge/alipay">
<input type="text" name="money"placeholder="金额"/>
<input type="submit" value="确定"/>
</form>
</body>
</html>
success.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>充值成功</title>
</head>
<body>
<h1>充值成功</h1>
<h2>用户名称:${username}</h2>
<h2>用户账户:${accountId}</h2>
<h2>金额:${money}</h2>
</body>
</html>
fal.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>充值失败</title>
</head>
<body>
<h1>充值失败</h1>
<h2>${msg}</h2>
</body>
</html>
2、登录功能
a、登录控制层
package com.alipay.alipayspringdemo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpSession;
/**
* @author WindShadow
* @verion 2020/1/11.
*/
@Controller
public class LoginHandler {
// 模拟登陆验证
public boolean loginService(String username, String password){
return !( username == null || "".equals(username) || password == null || "".equals(password) );
}
@RequestMapping("/login")
public String loginPage(){
return "login";
}
@RequestMapping("/index")
public String login(String username, String password, HttpSession session){
if (!loginService(username,password)) return "redirect:login";
session.setAttribute("username",username);
return "index";
}
}
b、拦截器配置
LoginInterceptor登录拦截器主要职责是根据session域中是否包含用户信息而决定放行。
package com.alipay.alipayspringdemo.interceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
/**
* @author WindShadow
* @verion 2019/12/28.
*/
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加拦截路径
registry.addInterceptor(new LoginInterceptor()).
addPathPatterns("/*").
excludePathPatterns("/login").
excludePathPatterns("/index").
excludePathPatterns("/recharge/notify");// 这里放行支付宝异步通知,原因是请求不来自用户,把通知拦截了就不对了
super.addInterceptors(registry);
}
// 静态资源不过滤
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
super.addResourceHandlers(registry);
}
}
3、阿里支付模块提取
AlipayConfig
package com.alipay.alipayspringdemo.alipayService;
/**
* @author WindShadow
* @verion 2019/12/27.
*/
/*
*类名:AlipayConfig
*功能:基础配置类
*详细:设置帐户有关信息及返回路径
*说明:
*以下代码只是为了方便商户测试而提供的样例代码,商户可以根据自己网站的需要,按照技术文档编写,并非一定要使用该代码。
*该代码仅供学习和研究支付宝接口使用,只是提供一个参考。
*/
public class AlipayConfig {
//↓↓↓↓↓↓↓↓↓↓请在这里配置您的基本信息↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
// 应用ID,您的APPID,收款账号既是您的APPID对应支付宝账号
public String appId = "9999999999";
// 商户私钥,您的PKCS8格式RSA2私钥
public String merchantPrivateKey = "这里是马赛克";
// 支付宝公钥
public String alipayPublicKey = "这里是马赛克";
// 服务器异步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
public String notifyUrl = "http://localhost:8080/";
// 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
public String returnUrl = "http://localhost:8080/";
// 签名方式
public String signType = "RSA2";
// 字符编码格式
public String charset = "utf-8";
// 支付宝网关
public String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";
// 日志路径
public String logPath = "C:\\";
//↑↑↑↑↑↑↑↑↑↑请在这里配置您的基本信息↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
/**
* 写日志,方便测试(看网站需求,也可以改成把记录存入数据库)
* @param sWord 要写入日志里的文本内容
*/
public void logResult(String sWord) {
// System.out.println(sWord);
// FileWriter writer = null;
// try {
// writer = new FileWriter(logPath + "alipay_log_" + System.currentTimeMillis()+".txt");
// writer.write(sWord);
// } catch (Exception e) {
// e.printStackTrace();
// } finally {
// if (writer != null) {
// try {
// writer.close();
// } catch (IOException e) {
// e.printStackTrace();
// }
// }
// }
}
public AlipayConfig() {}
public AlipayConfig(String app_id, String merchant_private_key, String alipay_public_key, String notify_url, String return_url, String sign_type, String charset, String gatewayUrl, String log_path) {
this.appId = app_id;
this.merchantPrivateKey = merchant_private_key;
this.alipayPublicKey = alipay_public_key;
this.notifyUrl = notify_url;
this.returnUrl = return_url;
this.signType = sign_type;
this.charset = charset;
this.gatewayUrl = gatewayUrl;
this.logPath = log_path;
}
/**-------------------------------Get Set 略------------------------------**/
说明:
这个配置类预设的值是我的支付宝沙箱信息;
因为考虑到同一个系统中可能有多个收款方,如电商平台,这些配置信息都不是static的,方便生成多个不同的实例对象;
然后同步通知地址(returnUrl)和异步通知地址(notifyUrl)还没有确定,原因是,假如收款方确定了,那么AlipayConfig的公钥私钥等也确定了,但是付款方充值的业务不只一种,那么通知也就不只一种,所以随时可以通过set方法更改;
TradeInfo
package com.alipay.alipayspringdemo.alipayService;
import java.io.UnsupportedEncodingException;
/**
* @author WindShadow
* @verion 2019/12/28.
*/
public class TradeInfo {
//商户订单号,商户网站订单系统中唯一订单号,必填
private String outTradeNo = null;
//付款金额,必填
private String totalAmount = null;
//订单名称,必填
private String subject = null;
//商品描述,可空
private String body = null;
//超时时间,可空
private String timeoutExpress;
// 乱码处理
private String deal(String str){
try {
return new String(str.getBytes("ISO-8859-1"),"UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return str;
}
// 返回交易上下文
public String creatBizContent(){
/**
* 按官方的demo使用下划线的参数名(推测负责支付服务的后台不是用JAVA写的),
* 不知都修改了会不会影响二维码页面渲染或者后台接收数据啥的,暂时不修改
*/
//请求参数可查阅【电脑网站支付的API文档-payByUserId.trade.page.pay-请求参数】章节
String str = "{\"out_trade_no\":\""+ outTradeNo +"\","
+ "\"total_amount\":\""+ totalAmount +"\","
+ "\"subject\":\""+ subject +"\","
+ "\"body\":\""+ body +"\","
+ "\"timeout_express\":\""+timeoutExpress+"\","
+ "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}";
return str;
}
/**-------------------------------Get Set 略------------------------------**/
}
说明:
TradeInfo类保存了一次订单的信息,其中creatBizContent()方法返回交易上下文,利于订单提交到支付宝后台生成并返回二维码页面,也就是说这个类封装了一些请求参数。在官方文档中,请求参数有很多,上面只列出了一些需要的,大家可以根据自己的需要重写TradeInfo类,记得重写creatBizContent()方法。
AlipayService
public interface AlipayService {
/**
* 支付宝服务接口
*/
// 充值
boolean pay(AlipayConfig alipayConfig, TradeInfo orderInfo, HttpServletRequest request, HttpServletResponse response);
// 支付宝验签
boolean rsaCheck(AlipayConfig alipayConfig, HttpServletRequest request) throws UnsupportedEncodingException;
}
说明:
阿里支付接口,
a、根据配置对象和订单对象生成返回二维码页面给用户,遇到异常则返回false;
b、验签;
AlipayServiceImp
package com.alipay.alipayspringdemo.alipayService;
import com.alipay.api.*;
import com.alipay.api.internal.util.AlipaySignature;
import com.alipay.api.request.*;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/**
* @author WindShadow
* @verion 2019/12/27.
*/
@Service
public class AlipayServiceImp implements AlipayService {
@Override
public boolean pay(AlipayConfig alipayConfig, TradeInfo tradeInfo, HttpServletRequest request, HttpServletResponse response) {
// 获得初始化的AlipayClient
AlipayClient alipayClient = new DefaultAlipayClient(alipayConfig.gatewayUrl, alipayConfig.appId, alipayConfig.merchantPrivateKey, "json", alipayConfig.charset, alipayConfig.alipayPublicKey, alipayConfig.signType);
// 设置请求参数
AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
alipayRequest.setReturnUrl(alipayConfig.returnUrl);
alipayRequest.setNotifyUrl(alipayConfig.notifyUrl);
alipayRequest.setBizContent(tradeInfo.creatBizContent());
// 请求并加载二维码
try {
String result = alipayClient.pageExecute(alipayRequest).getBody();
PrintWriter out = response.getWriter();
out.write(result);
out.flush();
out.close();
} catch (AlipayApiException e) {
e.printStackTrace();
///alipayConfig.logResult(e.getErrMsg());
System.out.println("跳转支付失败!");
return false;
} catch (IOException e) {
e.printStackTrace();
///alipayConfig.logResult(e.getErrMsg());
System.out.println("输出页面失败!");
return false;
}
return true;
}
// 支付宝验签
@Override
public boolean rsaCheck(AlipayConfig alipayConfig, HttpServletRequest request) throws UnsupportedEncodingException {
//获取支付宝提交过来反馈信息,同步通知为GET异步为POST
Map<String,String> params = new HashMap<String,String>();
Map<String,String[]> requestParams = request.getParameterMap();
for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用
valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
boolean signVerified = false;
try {
//调用SDK验证签名
signVerified = AlipaySignature.rsaCheckV1(params, alipayConfig.alipayPublicKey, alipayConfig.charset, alipayConfig.signType);
} catch (AlipayApiException e) {
e.printStackTrace();
}
return signVerified;
}
}
4、继续完善充Q币系统
支付宝支付模块提取完成后,该轮到充值Q币模块了,大家千万不要把支付和充值混为一谈,充值的前提是已经支付完成。
充值服务:为指定账户充值
public interface RechargeService {
void rechargeByUserId(String accountId, Double money, AlipayConfig alipayConfig, HttpServletRequest request, HttpServletResponse response);
}
package com.alipay.alipayspringdemo.service;
import com.alipay.alipayspringdemo.alipayService.AlipayConfig;
import com.alipay.alipayspringdemo.alipayService.AlipayService;
import com.alipay.alipayspringdemo.alipayService.TradeInfo;
import com.alipay.alipayspringdemo.util.TradeNoMaker;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author WindShadow
* @verion 2020/1/11.
*/
@Service
public class RechargeServiceImp implements RechargeService {
// 订单号生成器
@Autowired
TradeNoMaker maker;
// 阿里支付服务
@Autowired
AlipayService alipayService;
@Override
public void rechargeByUserId(String accountId, Double money, AlipayConfig alipayConfig, HttpServletRequest request, HttpServletResponse response) {
// 配置订单信息
TradeInfo tradeInfo = new TradeInfo();
tradeInfo.setOutTradeNo(maker.ctreateTradeNo());// 由订单编号生成器生成订单号
tradeInfo.setTotalAmount(String.valueOf(money));
tradeInfo.setSubject("用户["+ accountId +"]进行充值");
tradeInfo.setBody("校园卡充值");
tradeInfo.setTimeoutExpress("1m");
// 调用服务充值
alipayService.pay(alipayConfig,tradeInfo,request,response);
}
}
订单号生成器:保证每个订单有唯一编号,不然提交到支付宝后台时会出错。
public interface TradeNoMaker {
String ctreateTradeNo();
String ctreateTradeNo(Object... seed);
}
package com.alipay.alipayspringdemo.util;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* @author WindShadow
* @verion 2019/12/28.
*/
@Service
public class TradeNoMakerImp implements TradeNoMaker {
@Override
public String ctreateTradeNo() {
//根据时间戳生成
LocalDateTime dateTime = LocalDateTime.now();
String str = ""+dateTime.getYear()+dateTime.getMonthValue()+dateTime.getDayOfMonth()+dateTime.getHour()+dateTime.getMinute()+dateTime.getSecond();
return str;
}
@Override
public String ctreateTradeNo(Object... seed) {
//根据种子生成
/* 自行安排 */
return null;
}
}
充值控制层
package com.alipay.alipayspringdemo.controller;
import com.alipay.alipayspringdemo.alipayService.AlipayConfig;
import com.alipay.alipayspringdemo.alipayService.AlipayService;
import com.alipay.alipayspringdemo.service.RechargeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.UnsupportedEncodingException;
import java.util.Map;
import java.util.Objects;
/**
* @author WindShadow
* @verion 2020/1/6.
*/
@Controller
@RequestMapping("/recharge")
public class RechargeHandler {
private static String RETURN_URL = "http://localhost:8080/recharge/return";
private static String NOTIFY_URL = "http://localhost:8080/recharge/notify";
private static AlipayConfig alipayConfig = new AlipayConfig();//也可通过IOC注入
// 支付配置通知路径
static {
alipayConfig.setReturnUrl(RETURN_URL);
alipayConfig.setNotifyUrl(NOTIFY_URL);
}
@Autowired
RechargeService rechargeService;
@Autowired
AlipayService alipayService;
@RequestMapping("/alipay")
public void recharge(Double money,HttpSession session, HttpServletRequest request, HttpServletResponse response){
String username = (String)session.getAttribute("username");
String accountId = daoGetAccountId(username);
rechargeService.rechargeByUserId(accountId,money,alipayConfig,request,response);
}
// 同步通知
@RequestMapping("/return")
public String dealReturnUrl(HttpSession session, HttpServletRequest request,Map<String,Object> map) throws UnsupportedEncodingException {
System.out.println("同步通知=================");
// 验签
boolean signVerified = alipayService.rsaCheck(alipayConfig,request);
//↓↓↓↓↓执行业务↓↓↓↓↓//
String username = (String)session.getAttribute("username");
if (signVerified){
// 付款金额
String totalAmount = new String(request.getParameter("total_amount").getBytes("ISO-8859-1"),"UTF-8");
Double money = Double.valueOf(totalAmount);
// 获取用户账户
String accontId = daoGetAccountId(username);
// 模拟充Q币
System.out.println("---==↓↓↓↓↓==---");
System.out.println("|为 ["+username+" ]充值:"+money+"Q币");
System.out.println("---==↑↑↑↑↑==---");
map.put("username",username);
map.put("accountId",accontId);
map.put("money",money);
return "success";
}else {
System.out.println("验签失败!");
map.put("msg","验签失败!");
return "fail";
}
//↑↑↑↑↑执行业务↑↑↑↑↑//
}
// 异步通知
@RequestMapping("/notify")
@ResponseBody
public String dealNotifyUrl(HttpSession session, HttpServletRequest request,Map<String,Object> map) throws UnsupportedEncodingException{
System.out.println("异步通知================");
// 验签
boolean signVerified = alipayService.rsaCheck(alipayConfig,request);
//↓↓↓↓↓执行业务↓↓↓↓↓//
/* 实际验证过程建议商户务必添加以下校验:
1、需要验证该通知数据中的out_trade_no是否为商户系统中创建的订单号,
2、判断total_amount是否确实为该订单的实际金额(即商户订单创建时的金额),
3、校验通知中的seller_id(或者seller_email) 是否为out_trade_no这笔单据的对应的操作方(有的时候,一个商户可能有多个seller_id/seller_email)
4、验证app_id是否为该商户本身。
*/
if(signVerified) {//验证成功
//商户订单号
String outTradeNo = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");
//支付宝交易号
String tradeNo = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");
//交易状态
String tradeStatus = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");
if(tradeStatus.equals("TRADE_FINISHED")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//如果有做过处理,不执行商户的业务程序
//注意:
//退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
}else if (tradeStatus.equals("TRADE_SUCCESS")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//如果有做过处理,不执行商户的业务程序
//注意:
//付款完成后,支付宝系统发送该交易状态通知
}
return "success";
}else {//验证失败
//调试用,写文本函数记录程序运行情况是否正常
//String sWord = AlipaySignature.getSignCheckContentV1(params);
//AlipayConfig.logResult(sWord);
return "fail";
}
//↑↑↑↑↑执行业务↑↑↑↑↑//
}
// 模拟从DAO层获取账户ID
public String daoGetAccountId(String username){
return String.valueOf(Objects.hashCode(username));
}
}
说明:
该控制层负责当前登录用户的充值(获取其账户ID,后进行充值),以及通知处理。专注点在于同步通知处理。
最终效果:
样式什么的就先不要在意了,其它功能如退款、订单查询等也可参考官方demo作进一步的整合。
完整demo地址:https://github.com/WindMo/alipay-spring.git