开发设置
服务器域名:必须是https,不能是IP,域名必须已备案,可以配置多个
登录
1、前端调用wx.login() 得到 jsCode,传给后端
2、后端验证jsCode,得到openid、session_key(用于解密前端发来的加密数据)
@Autowired
private RestTemplate restTemplate;
String appId = "***";
String appSecret = "***";
if (jsCode == null) {
throw new RestfulException("登录失败");
}
String url = String.format("https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appId, appSecret, jsCode);
WxSession wxSession = restTemplate.getForObject(url, WxSession.class);
if (wxSession.getErrcode() != 0) {
throw new RestfulException("验证失败");
} else {
// 验证成功,将微信返回的内容保存的session
request.getSession().setAttribute("wxSession", wxSession);
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WxSession implements Serializable {
private String jsCode; // 前端登录请求里的字段
private String openid; // 登录微信服务器返回的字段
private String session_key; // 登录微信服务器返回的字段
private int errcode;
private String errmsg;
@NotBlank
private String iv; // 前端发来的加密算法的初始向量
@NotBlank
private String encryptedData; // 前端发来的加密数据(phoneNumber、unionId)
}
获取手机号
0、前提 已经 wx.login(),且 后端存有 session_key
1、在前端设置 open-type="getPhoneNumber" 的按钮,得到 iv 和 encryptedData
2、后端解密 encryptedData
@RequestMapping(value = "/customer/mobile", method = RequestMethod.PUT)
public RestfulResult setMobile(@Validated @RequestBody WxSession data, @SessionAttribute("wxSession") WxSession wxSession) throws Exception {
// 密钥
byte[] sessionKey = Base64.decodeBase64(wxSession.getSession_key());
// 格式化密钥
SecretKeySpec keySpec = new SecretKeySpec(sessionKey, "AES");
// 初始化向量
byte[] iv =Base64.decodeBase64(data.getIv());
// 格式化 初始化向量
AlgorithmParameterSpec ivSpec = new IvParameterSpec(iv);
// 确定算法
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// 进入解密模式
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
// 密文
byte[] encryptedData = Base64.decodeBase64(data.getEncryptedData());
// 解密
String plainData = new String(cipher.doFinal(encryptedData),"UTF-8");
// FastJson 库 ,JSON字符串转对象
JSONObject jsonObject = JSON.parseObject(plainData);
String mobile = jsonObject.getString("phoneNumber");
return new RestfulResult(mobile);
}
获取 unionId
前提:微信开放平台上绑定了小程序
方式一:在登录时(code2Session)时得到 unionid,此时无法预知unionid是否存在,因此不建议使用此方式
方式二:在登录后,判断后端是否已存有unionId,没有的话,前端调用wx.getUserInfo({withCredentials: true})得到加密数据,发给后端解密
@ApiOperation(value = "解析小程序getUserInfo得到的敏感数据")
@RequestMapping(value = "/encrypted-data", method = RequestMethod.PUT)
public RestfulResult setUnionId(@Validated @RequestBody WxSession data, @SessionAttribute WxSession wxSession, @SessionAttribute Customer userInfo) throws Exception {
String signature = DigestUtils.sha1Hex(data.getRawData() + wxSession.getSession_key());
if(!data.getSignature().equals(signature)){ // 验证前端发来的signature是否正确
throw new RestfulException("签名不正确");
}
/* 解密过程参考上方的获取手机号 */
String unionId = jsonObject.getString("unionId");
if(unionId != null){
customerService.setUnionId(userInfo.getId(), unionId);
}
return new RestfulResult(unionId);
}
服务消息推送
一、模板消息(已于2020年1月10日下线)
0、消息模板在微信公众平台上管理
1、使用form组件,用户提交表单时,得到 formId,后端保存 formId,可在 7 天内向用户推送 1 次模板消息
2、用户完成支付,得到 prepay_id,后端保存 prepay_id,可在 7 天内向用户推送 3 次模板消息
3、服务器端调用的接口为 /cgi-bin/message/wxopen/template/uniform_send
二、订阅消息
0、消息模板在微信公众平台上管理
1、前端调用 wx.requestSubscribeMessage,弹出授权框,可以一次授权多个消息模板
2、此订阅为一次性订阅,用户授权 1 次,同一个消息模板只能推送 1 条消息,时间不限;用户多次授权,可以累加
3、服务器端调用的接口为 /cgi-bin/message/subscribe/send
4、开通长期订阅的途径,在 微信开发社区 联系 社区技术运营专员 申请,原则是仅对线下公共服务开放
模板数据 里的一项数据
public class TemplateDataItem implements Serializable {
private String value;
public TemplateDataItem(String value) {
this.value = value;
}
}
模板数据,具体字段 在 微信公众平台上查看
public class TemplateData implements Serializable {
private TemplateDataItem thing1;
private TemplateDataItem phrase1;
private TemplateDataItem amount1;
private TemplateDataItem time1;
}
推送请求
public class SubscribeRequest implements Serializable {
private String access_token;
private String touser; // 目标用户的openId
private String template_id = "***"; // 模板ID 在 微信公众平台上查看
private String page = "pages/home/index"; // 点击服务通知卡片 进入到的页面路径
private TemplateData data;
public SubscribeRequest(String access_token, String touser) {
this.access_token = access_token;
this.touser = touser;
}
}
推送
@Autowired
private RestTemplate restTemplate;
@Autowired
private WeixinService weixinService; // 参考 https://www.jianshu.com/p/9ceb8103f1b2
private String subscribeUrl = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s";
private String openId = "xx";
public void sendMessage() {
String accessToken = weixinService.getAccessToken();
String urlStr = String.format(subscribeUrl, accessToken);
SubscribeRequest request = new SubscribeRequest(accessToken, openId);
TemplateData data = new TemplateData();
data.setPhrase1(new TemplateDataItem("得是5个内汉字"));
data.setTime1(new TemplateDataItem("得是年月日"));
data.setThing1(new TemplateDataItem("***"));
data.setAmount1(new TemplateDataItem("****"));
request.setData(data);
WxSession wxSession = restTemplate.postForObject(urlStr, request, WxSession.class);
}
微信支付
一、微信登录,参考上方
二、向微信服务器下单
1、Service
// user 即用户对象,order即订单对象
public Map place(User user, Order order) throws DocumentException {
orderDao.place(user.getId(), order); // 将订单存到数据库
WxPay wxPay = new WxPay(notifyUrl, user.getOpenId(), order.getOrderId(), "商品名", order.getSum());
Map responseDataMap = this.unifiedOrder(wxPay); // 向微信服务器下单
Map dataForFront = this.dataForFront((String) responseDataMap.get("prepay_id")); // 生成给前端的数据
return dataForFront;
}
/* 统一下单 */
public Map unifiedOrder(WxPay wxPay) throws DocumentException {
Map<String, String> dataMap = new HashMap<>();
dataMap.put("appid", appId); // 小程序appId
dataMap.put("body", wxPay.getBody()); // 商品描述
dataMap.put("mch_id", mchId); // 商户id
dataMap.put("nonce_str", RandomStringUtils.randomAlphanumeric(16)); // 随机串
dataMap.put("notify_url", wxPay.getNotify_url()); // 回调地址
dataMap.put("out_trade_no", wxPay.getOut_trade_no()); // 订单号
dataMap.put("openid", wxPay.getOpenid()); // trade_type=JSAPI时,openid必填
dataMap.put("sign_type", "MD5"); // 签名算法
dataMap.put("spbill_create_ip", billCreateIp); // 我方服务器的公网IP
dataMap.put("total_fee", wxPay.getTotal_fee()); // 金额
dataMap.put("trade_type", "JSAPI");
String sign = this.sign(dataMap); // 签名
dataMap.put("sign", sign);
Map<String, Map> xmlMap = new HashMap<>();
xmlMap.put("xml", dataMap); // 注意需要最外层的<xml>标签
StringBuffer sb = new StringBuffer();
String xml = XMLUtil.parseMap(xmlMap, sb); // Map 转 xml字符串,参考https://www.jianshu.com/p/fc5001b5b5bc
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
HttpEntity<String> requestEntity = new HttpEntity<>(xml, headers); // 构造请求数据
ResponseEntity<String> responseEntity = restTemplate.postForEntity(placeOrderUrl, requestEntity, String.class); // 发送请求
Map responseXmlMap = XMLUtil.parseXml(responseEntity.getBody()); // 将xml转换成 map
Map responseDataMap = (Map) responseXmlMap.get("xml"); // 注意有最外层的<xml>标签
if ("FAIL".equals(responseDataMap.get("return_code"))) {
throw new RestfulException((String) responseDataMap.get("return_msg"));
}
if ("FAIL".equals(responseDataMap.get("result_code"))) {
throw new RestfulException((String) responseDataMap.get("err_code_des"));
}
return responseDataMap;
}
/* 签名 */
public String sign(Map map) {
List<String> list = new ArrayList(map.keySet());
Collections.sort(list); // 字典排序
StringBuffer sb = new StringBuffer();
for (String key : list) {
sb.append(key + "=" + map.get(key) + "&"); // 拼接参数
}
sb.append("key=" + mchKey); // 拼上商户号的API key,注意不是小程序的app secret
return DigestUtils.md5Hex(sb.toString()).toUpperCase(); // MD5 摘要,并转大写
}
/* 给前端调用wx.requestPayment()的数据 及其 签名 */
public Map dataForFront(String prepay_id) {
Map<String, String> map = new HashMap<>();
map.put("appId", appId);
map.put("nonceStr", RandomStringUtils.randomAlphanumeric(16));
map.put("package", "prepay_id=" + prepay_id);
map.put("timeStamp", "" + (System.currentTimeMillis() / 1000));
map.put("signType", "MD5");
String sign = this.sign(map);
map.put("paySign", sign);
return map;
}
2、pojo
public class WxPay implements Serializable {
private String body; // 商品描述
private String notify_url; // 统一下单的回调地址
private String out_trade_no; // 我方的订单ID
private String openid;
private String total_fee; // 费用,以分为单位
public WxPay(String notify_url, String openid, String out_trade_no, String body, String total_fee) {
this.body = body;
this.notify_url = notify_url;
this.out_trade_no = out_trade_no;
this.openid = openid;
this.total_fee = total_fee;
}
}
三、前端调用 wx.requestPayment()
四、处理支付通知
1、Service
public String payNotify(String requestXml) throws DocumentException {
String responseXml = "<xml><return_code>SUCCESS</return_code></xml>";
Map requestDataMap = this.preTreat(requestXml);
long orderId = Long.parseLong((String) requestDataMap.get("out_trade_no"));
Order order = orderDao.getOrderByOrderId(orderId);
this.notifyValidate(requestDataMap, order.getSum());
if ("FAIL".equals(requestDataMap.get("result_code"))) {
orderDao.setOrderStatus(order.getId(), "fail"); // 更新数据库里的订单状态
return responseXml;
}
orderDao.setOrderStatus(order.getId(), "success"); // 更新数据库里的订单状态
return responseXml;
}
/* 预处理 支付通知 */
public Map preTreat(String requestXml) throws DocumentException {
Map requestXmlMap = XMLUtil.parseXml(requestXml);
Map requestDataMap = (Map) requestXmlMap.get("xml");
if ("FAIL".equals(requestDataMap.get("return_code"))) {
throw new RestfulException("通信错误");
}
return requestDataMap;
}
/* 校验 支付通知 */
public void notifyValidate(Map notifyMap,int sum){
String wxSign = (String) notifyMap.get("sign");
notifyMap.remove("sign");
String mySign = this.sign(notifyMap);
if (!wxSign.equals(mySign)) { // 校验签名
throw new RestfulException("签名不正确");
}
if (sum != Integer.parseInt((String) notifyMap.get("total_fee"))) { // 校验金额
throw new RestfulException("金额不正确");
}
}
微信退款
一、申请退款
1、从商户平台上下载证书 .p12
2、给RestTemplate设置证书
@Bean("RestTemplateWithCert")
// 传入 证书存放的路径 和 商户ID
public RestTemplate restTemplate(@Value("${wx.p12Path}") String keyFilePath, @Value("${wx.mchId}") String keyPassword) {
HttpComponentsClientHttpRequestFactory httpRequestFactory;
try {
InputStream keyStream = new FileInputStream(new File(keyFilePath)); // 从绝对路径取
InputStream keyStream = this.getClass().getResourceAsStream(keyFilePath); // 从 resources 目录里取
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(keyStream, keyPassword.toCharArray());
SSLContext sslContext = SSLContexts.custom().loadKeyMaterial(keyStore, keyPassword.toCharArray()).build();
CloseableHttpClient httpClient = HttpClients.custom().setSSLContext(sslContext).build();
// SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(sslContext, new String[]{"TLSv1"}, null, NoopHostnameVerifier.INSTANCE);
// CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslSocketFactory).build();
httpRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
} catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException | UnrecoverableKeyException | CertificateException | IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
RestTemplate restTemplate = new RestTemplate(httpRequestFactory);
restTemplate.getMessageConverters().add(new FastJsonHttpMessageConverter());
restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
return restTemplate;
}
3、申请退款 Service
public void agreeRefund(int orderId) throws DocumentException {
Order order = orderDao.getOrderById(orderId);
WxPay wxPay = new WxPay(refundNotifyUrl,"" + order.getTradeNum(), "" + order.getSum());
this.refund(wxPay);
orderDao.setOrderStatus(orderId, "refunding"); // 在数据库中更新订单状态
}
public Map refund(WxPay wxPay) throws DocumentException {
Map<String, String> dataMap = new HashMap<>();
dataMap.put("appid", appId);
dataMap.put("mch_id", mchId);
dataMap.put("nonce_str", RandomStringUtils.randomAlphanumeric(16));
dataMap.put("notify_url", wxPay.getNotify_url());
dataMap.put("sign_type", "MD5");
dataMap.put("out_trade_no", wxPay.getOut_trade_no()); // 订单号
dataMap.put("out_refund_no", wxPay.getOut_trade_no()); // 退款订单号
dataMap.put("total_fee", wxPay.getTotal_fee()); // 订单金额
dataMap.put("refund_fee", wxPay.getTotal_fee()); // 退款金额
String sign = this.sign(dataMap); // 签名
dataMap.put("sign", sign);
Map<String, Map> xmlMap = new HashMap<>();
xmlMap.put("xml", dataMap); // 注意需要最外层的<xml>标签
StringBuffer sb = new StringBuffer();
String xml = XMLUtil.parseMap(xmlMap, sb); // Map 转 xml字符串
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
HttpEntity<String> requestEntity = new HttpEntity<>(xml, headers);
ResponseEntity<String> responseEntity = restTemplateWithCert.postForEntity(refundUrl, requestEntity, String.class); // 使用带证书的 RestTemplate
Map responseXmlMap = XMLUtil.parseXml(responseEntity.getBody());
Map responseDataMap = (Map) responseXmlMap.get("xml"); // 注意有最外层的<xml>标签
if ("FAIL".equals(responseDataMap.get("return_code"))) {
throw new RestfulException((String) responseDataMap.get("return_msg"));
}
if ("FAIL".equals(responseDataMap.get("result_code"))) {
throw new RestfulException((String) responseDataMap.get("err_code_des"));
}
return responseDataMap;
}
二、退款结果通知
1、pom.xml
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk16</artifactId>
<version>1.46</version>
</dependency>
2、AES/ECB/PKCS7Padding 解密算法工具
@Bean("PKCS7Cipher")
public Cipher cipher(@Value("${wx.mchKey}") String mchKey) throws Exception {
Security.addProvider(new BouncyCastleProvider()); // BouncyCastleProvider 来自 org.bouncycastle.bcprov-jdk16
// 对商户的API key取MD5摘要,注意不是商户ID
String keyString = DigestUtils.md5Hex(mchKey);
Key key = new SecretKeySpec(keyString.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding");
cipher.init(Cipher.DECRYPT_MODE, key);
return cipher;
}
3、退款通知处理
@Transactional
public String refundNotify(String requestXml) throws Exception {
Map requestDataMap = this.preTreat(requestXml);
String reqInfo = (String) requestDataMap.get("req_info");
String reqInfoXml = this.decrypt(reqInfo);
Map rootMap = XMLUtil.parseXml(reqInfoXml);
Map dataMap = (Map)rootMap.get("root"); // 注意最外面有一层<root>
long tradeNum = Long.parseLong((String)dataMap.get("out_trade_no"));
String refundStatus = (String)dataMap.get("refund_status");
if ("SUCCESS".equals(refundStatus)) {
orderDao.setOrderStatusByTradeNum(tradeNum, ServeOrderStatus.REFUNDED.name().toLowerCase());
}
else if ("REFUNDCLOSE".equals(dataMap.get("refund_status")) || "CHANGE".equals(dataMap.get("refund_status"))) {
orderDao.setOrderStatusByTradeNum(tradeNum, ServeOrderStatus.REFUND_FAIL.name().toLowerCase());
}
return "<xml><return_code>SUCCESS</return_code></xml>";
}
/* 退款通知解密 */
public String decrypt(String reqInfo) throws BadPaddingException, IllegalBlockSizeException {
// Base64解码得到字节数组后不要转为字符串,因为解密的输入是就是字节数组。字节数组转字符串,再转回字节数组,可能会带来变化
byte[] cipherBytes = Base64.decodeBase64(reqInfo);
byte[] plainBytes = pkcs7Cipher.doFinal(cipherBytes);
String plainText = new String(plainBytes);
return plainText;
}
获取小程序码
public String getUnlimited() {
String accessToken = weixinService.fetchToken();
// 注意,request里不能带access_token
AppCodeRequest request = new AppCodeRequest();
request.setPage("pages/activity/index");
request.setScene(String.format("id=**", 1)); // 注意,不能大于32个字符
String urlStr = String.format("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", accessToken);
byte[] bytes = restTemplate.postForObject(urlStr, request, byte[].class);
String base64 = new String(Base64.encodeBase64(bytes));
base64 = base64.replaceAll("=", ""); // 去掉可能会有的等号,多等号可能对前端写入到本地有影响
return base64; // 返回Base64;要写到canvas里的话,不能直接用base64,需要存到本地得到path
}
pojo
public class AppCodeRequest implements Serializable {
private String scene;
private String page;
}
企业付款
从商户号付款到用户余额,商户号要达到一定条件 才能开通
public Map mmpay(WxPay wxPay) throws DocumentException {
Map<String, String> dataMap = new HashMap<>();
dataMap.put("mch_appid", appId);
dataMap.put("mchid", mchId);
dataMap.put("nonce_str", RandomStringUtils.randomAlphanumeric(16));
dataMap.put("partner_trade_no", wxPay.getOut_trade_no()); // 订单号
dataMap.put("openid", wxPay.getOpenid());
dataMap.put("check_name", "NO_CHECK"); // 不校验实名
dataMap.put("amount", wxPay.getTotal_fee()); // 付款金额
dataMap.put("desc", wxPay.getBody()); // 付款备注
String sign = this.sign(dataMap); // 签名
dataMap.put("sign", sign);
String xml = this.generateXml(dataMap); // 生成请求xml
ResponseEntity<String> responseEntity = this.postXmlWithCert(mmpayUrl, xml); // 发送xml
Map responseDataMap = this.preTreatMmpay(responseEntity.getBody()); // 处理响应xml
return responseDataMap;
}
public ResponseEntity postXmlWithCert(String url, String xml) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
HttpEntity<String> requestEntity = new HttpEntity<>(xml, headers);
ResponseEntity<String> responseEntity = restTemplateWithCert.postForEntity(url, requestEntity, String.class);
return responseEntity;
}
public Map preTreatMmpay(String xml) throws DocumentException {
logger.info("notify:" + xml);
Map responseXmlMap = XMLUtil.parseXml(xml);
Map responseDataMap = (Map) responseXmlMap.get("xml"); // 注意有最外层的<xml>标签
if ("FAIL".equals(responseDataMap.get("return_code"))) {
throw new RestfulException((String) responseDataMap.get("return_msg"));
}
if ("FAIL".equals(responseDataMap.get("result_code"))) {
String err_code = (String) responseDataMap.get("err_code");
String err_code_des = (String) responseDataMap.get("err_code_des");
throw new RestfulException(err_code + "-" + err_code_des);
}
return responseDataMap;
}
小程序用户信息保存
1、微信昵称可能包含表情,mysql 的字符编码得是utf8mb4,数据库连接url得加上 ?useUnicode=true&characterEncoding=utf8
mysql的 utf8 最大字符长度为 3 字节,utf8mb4则最大为4字节
2、头像的域名有 wx.qlogo.cn、thirdwx.qlogo.cn,小程序要下载头像存到临时路径,需要在微信后台设置这两个下载域名
小程序广告
eCPM:effective Cost Per Mile,每千次曝光的收益
ARPU:Average Revenue Per User,每用户平均收入
小程序广告eCPM:banner广告1-10,格子广告1-10,插屏广告5-50,视频广告2-20,激励式视频广告10-100,视频前贴广告(在有视频内容的小程序中才能使用)
广告收益总结:嵌入广告(banner\格子\视频)ARPU在0.5分左右,满屏广告(插屏/激励式视频)ARPU在5分左右
广告策略:在内容型、游戏型、工具型小程序中插入广告才有意义;在交易型小程序中,广告不能作为主要模式(例如看广告抽奖),仅能作为辅助收益(非满屏广告)
小商店
小程序小商店:与微信支付相比,微信支付T+6提现,小商店确认收货后可提现,发货15天后默认收货
个人小商店:与微店对比,小商店是微信官方出品,免服务费
企业小商店:与个人小商店相比,有电脑端管理后台
小程序跳小商店小程序:会有跳转提示,可以跳转到第三方小商店
小程序接入小商店组件:只能接入自己的小商店;组件只能在单独页面显示,不能嵌入到原小程序页面;商品订单等信息可以通过接口查询
阿里云
小程序云:一键在ECS上初始化后端运行环境(JAVA,Nodejs,Mysql等),提供Serverless服务