需求
根据一个小程序,发布一个和其一模一样的小程序。(可能不只一个)
如果按常规的做法每个小程序都需要经历这样的步骤:在公众平台申请appid——>拉代码到自己的仓库——>打开编译器绑定自己的appId——>上传代码到公众平台——>再提交审核——>在公众平台调整开发设置。
如果用微信开放平台的服务平台开发的步骤:在微信开放平台申请第三方服务平台——>上传小程序模板(只有第一次有)——>调接口上传小程序代码
——>调接口提交审核——>调接口发布小程序。
上传小程序模板的步骤:在公众平台申请appid——>在微信开放平台的第三方服务平台中添加开发小程序——>上传小程序代码(此时会直接上传到了开放平台的草稿箱)——>将草稿箱的代码作为普通模板
这就是服务平台开发平台的便捷,只需上传一次小程序代码,就可以快速的发布小程序。
实现
步骤:获取component_verify_ticket——>获取component_access_token——>获取authorizer_access_token——>调接口发布小程序
1、获取component_verify_ticket
官方文档:验证票据 | 微信开放文档 (qq.com)
因为这个验证票据是微信官方主动推送的,所以需要在第三方平台配置一波
controller
@ApiOperation(value = "微信开放平台:授权事件接收URL,验证票据", notes = "zhuguangliang")
@AnonymousPostMapping("/pushTicket")
public String wechatPlatformEvent(@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("msg_signature") String msgSignature,
@RequestBody String postData) {
return openPlatformUtil.parseRequest(timestamp, nonce, msgSignature, postData);
}
实现类
关于存储component_verify_ticket方案,我这里是mysql和redis各存一份。
public String parseRequest(String timeStamp, String nonce, String msgSignature, String postData) {
try {
if (redisUtils.hasKey(CacheKey.OPEN_PLATFORM_TICKET)) {
return "success";
}
WXBizMsgCrypt pc = new WXBizMsgCrypt(Token, Key, APPID);
String xml = pc.decryptMsg(msgSignature, timeStamp, nonce, postData);
Map<String, String> result = WXXmlToMapUtil.xmlToMap(xml);
String componentVerifyTicket = result.get("ComponentVerifyTicket");
if (StringUtils.isNotEmpty(componentVerifyTicket)) {
SpiritOpenPlatform spiritOpenPlatform = new SpiritOpenPlatform();
if (platformMapper.selectByAppId(APPID) == 0) {
spiritOpenPlatform.setAppid(APPID);
spiritOpenPlatform.setAppSecret(AppSecret);
spiritOpenPlatform.setPlatformKey(Key);
spiritOpenPlatform.setToken(Token);
spiritOpenPlatform.setComponentVerifyTicket(componentVerifyTicket);
platformMapper.insert(spiritOpenPlatform);
} else {
spiritOpenPlatform.setComponentVerifyTicket(componentVerifyTicket);
LambdaUpdateWrapper<SpiritOpenPlatform> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(SpiritOpenPlatform::getAppid, APPID);
platformMapper.update(spiritOpenPlatform, updateWrapper);
}
redisUtils.set(CacheKey.OPEN_PLATFORM_TICKET, componentVerifyTicket, 60 * 60 * 12);
log.info("微信开放平台,第三方平台获取【验证票据】成功");
} else {
log.error("微信开放平台,第三方平台获取【验证票据】失败");
}
} catch (AesException e) {
log.error("微信开放平台,第三方平台获取【验证票据】失败,异常信息:" + e.getMessage());
}
return "success";
}
官方提供的解密类:WXBizMsgCrypt.java
import org.apache.commons.codec.binary.Base64;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.util.Arrays;
public class WXBizMsgCrypt {
static Charset CHARSET = Charset.forName("utf-8");
Base64 base64 = new Base64();
byte[] aesKey;
String token;
String appId;
public WXBizMsgCrypt(String token, String encodingAesKey, String appId) throws AesException {
if (encodingAesKey.length() != 43) {
throw new AesException(AesException.IllegalAesKey);
}
this.token = token;
this.appId = appId;
aesKey = Base64.decodeBase64(encodingAesKey + "=");
}
int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
String decrypt(String text) throws AesException {
byte[] original;
try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);
byte[] encrypted = Base64.decodeBase64(text);
original = cipher.doFinal(encrypted);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.DecryptAESError);
}
String xmlContent, from_appid;
try {
byte[] bytes = PKCS7Encoder.decode(original);
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
from_appid =
new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), CHARSET);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.IllegalBuffer);
}
if (!from_appid.equals(appId)) {
throw new AesException(AesException.ValidateSignatureError);
}
return xmlContent;
}
public String decryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws AesException {
Object[] encrypt = extract(postData);
String signature = getSHA1(token, timeStamp, nonce, encrypt[1].toString());
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
String result = decrypt(encrypt[1].toString());
return result;
}
public static Object[] extract(String xmltext) throws AesException {
Object[] result = new Object[3];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(xmltext);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
NodeList nodelist2 = root.getElementsByTagName("ToUserName");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
if (nodelist2 != null) {
if (nodelist2.item(0) != null) {
result[2] = nodelist2.item(0).getTextContent();
}
}
return result;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ParseXmlError);
}
}
public static String getSHA1(String token, String timestamp, String nonce, String encrypt)
throws AesException {
try {
String[] array = new String[]{token, timestamp, nonce, encrypt};
StringBuffer sb = new StringBuffer();
Arrays.sort(array);
for (int i = 0; i < 4; i++) {
sb.append(array[i]);
}
String str = sb.toString();
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(str.getBytes());
byte[] digest = md.digest();
StringBuffer hexstr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexstr.append(0);
}
hexstr.append(shaHex);
}
return hexstr.toString();
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ComputeSignatureError);
}
}
}
PKCS7Encode.java
import java.nio.charset.Charset;
import java.util.Arrays;
public class PKCS7Encoder {
static Charset CHARSET = Charset.forName("utf-8");
static int BLOCK_SIZE = 32;
static byte[] encode(int count) {
int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE);
if (amountToPad == 0) {
amountToPad = BLOCK_SIZE;
}
char padChr = chr(amountToPad);
String tmp = new String();
for (int index = 0; index < amountToPad; index++) {
tmp += padChr;
}
return tmp.getBytes(CHARSET);
}
static byte[] decode(byte[] decrypted) {
int pad = (int) decrypted[decrypted.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
}
static char chr(int a) {
byte target = (byte) (a & 0xFF);
return (char) target;
}
}
WXXmlToMapUtil.java
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.dom4j.io.XMLWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class WXXmlToMapUtil {
private static final Logger logger = LoggerFactory.getLogger(WXXmlToMapUtil.class);
public static Map<String, String> xmlToMap(String xml) {
try {
Map<String, String> data = new HashMap<>();
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
InputStream stream = new ByteArrayInputStream(xml.getBytes("UTF-8"));
org.w3c.dom.Document doc = documentBuilder.parse(stream);
doc.getDocumentElement().normalize();
NodeList nodeList = doc.getDocumentElement().getChildNodes();
for (int idx = 0; idx < nodeList.getLength(); ++idx) {
Node node = nodeList.item(idx);
if (node.getNodeType() == Node.ELEMENT_NODE) {
org.w3c.dom.Element element = (org.w3c.dom.Element) node;
data.put(element.getNodeName(), element.getTextContent());
}
}
stream.close();
return data;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public static String mapToXml(Map<String, String> data) throws Exception {
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
org.w3c.dom.Document document = documentBuilder.newDocument();
org.w3c.dom.Element root = document.createElement("xml");
document.appendChild(root);
for (String key : data.keySet()) {
String value = data.get(key);
if (value == null) {
value = "";
}
value = value.trim();
org.w3c.dom.Element filed = document.createElement(key);
filed.appendChild(document.createTextNode(value));
root.appendChild(filed);
}
TransformerFactory tf = TransformerFactory.newInstance();
Transformer transformer = tf.newTransformer();
DOMSource source = new DOMSource(document);
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
StringWriter writer = new StringWriter();
StreamResult result = new StreamResult(writer);
transformer.transform(source, result);
String output = writer.getBuffer().toString();
writer.close();
return output;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public static Map<String, Object> multilayerXmlToMap(String xml) {
Document doc = null;
try {
doc = DocumentHelper.parseText(xml);
} catch (DocumentException e) {
logger.error("xml字符串解析,失败 --> {}", e);
}
Map<String, Object> map = new HashMap<>();
if (null == doc) {
return map;
}
Element rootElement = doc.getRootElement();
recursionXmlToMap(rootElement, map);
return map;
}
private static void recursionXmlToMap(Element element, Map<String, Object> outmap) {
List<Element> list = element.elements();
int size = list.size();
if (size == 0) {
outmap.put(element.getName(), element.getTextTrim());
} else {
Map<String, Object> innermap = new HashMap<>();
list.forEach(childElement -> recursionXmlToMap(childElement, innermap));
outmap.put(element.getName(), innermap);
}
}
public static String multilayerMapToXml(Map<String, Object> map, boolean isCDATA) {
String parentName = "xml";
Document doc = DocumentHelper.createDocument();
doc.addElement(parentName);
String xml = recursionMapToXml(doc.getRootElement(), parentName, map, isCDATA);
return formatXML(xml);
}
private static String recursionMapToXml(Element element, String parentName, Map<String, Object> map, boolean isCDATA) {
Element xmlElement = element.addElement(parentName);
map.keySet().forEach(key -> {
Object obj = map.get(key);
if (obj instanceof Map) {
recursionMapToXml(xmlElement, key, (Map<String, Object>) obj, isCDATA);
} else {
String value = obj == null ? "" : obj.toString();
if (isCDATA) {
xmlElement.addElement(key).addCDATA(value);
} else {
xmlElement.addElement(key).addText(value);
}
}
});
return xmlElement.asXML();
}
public static String formatXML(String xml) {
String requestXML = null;
try {
SAXReader reader = new SAXReader();
Document document = reader.read(new StringReader(xml));
if (null != document) {
StringWriter stringWriter = new StringWriter();
OutputFormat format = new OutputFormat(" ", true);
format.setNewLineAfterDeclaration(false);
format.setSuppressDeclaration(false);
format.setNewlines(true);
XMLWriter writer = new XMLWriter(stringWriter, format);
writer.write(document);
writer.flush();
writer.close();
requestXML = stringWriter.getBuffer().toString();
}
return requestXML;
} catch (Exception e) {
logger.error("格式化xml,失败 --> {}", e);
return null;
}
}
}
AesException.java
@SuppressWarnings("serial")
public class AesException extends Exception {
public final static int OK = 0;
public final static int ValidateSignatureError = -40001;
public final static int ParseXmlError = -40002;
public final static int ComputeSignatureError = -40003;
public final static int IllegalAesKey = -40004;
public final static int ValidateCorpidError = -40005;
public final static int EncryptAESError = -40006;
public final static int DecryptAESError = -40007;
public final static int IllegalBuffer = -40008;
private int code;
private static String getMessage(int code) {
switch (code) {
case ValidateSignatureError:
return "签名验证错误";
case ParseXmlError:
return "xml解析失败";
case ComputeSignatureError:
return "sha加密生成签名失败";
case IllegalAesKey:
return "SymmetricKey非法";
case ValidateCorpidError:
return "corpid校验失败";
case EncryptAESError:
return "aes加密失败";
case DecryptAESError:
return "aes解密失败";
case IllegalBuffer:
return "解密后得到的buffer非法";
default:
return null;
}
}
public int getCode() {
return code;
}
AesException(int code) {
super(getMessage(code));
this.code = code;
}
}
2、获取component_access_token
官方文档:令牌 | 微信开放文档 (qq.com)
实现类
@Override
public void getComponentAccessToken() {
String componentAccessTokenUrl = "https://api.weixin.qq.com/cgi-bin/component/api_component_token";
if (redisUtils.hasKey(CacheKey.COMPONENT_ACCESS_TOKEN)) {
if (redisUtils.getExpire(CacheKey.COMPONENT_ACCESS_TOKEN) > 10 * 60) {
return;
}
}
JSONObject params = new JSONObject();
params.put("component_appid", APPID);
params.put("component_appsecret", AppSecret);
String component_verify_ticket;
if (redisUtils.hasKey(CacheKey.OPEN_PLATFORM_TICKET)) {
component_verify_ticket = (String) redisUtils.get(CacheKey.OPEN_PLATFORM_TICKET);
} else {
component_verify_ticket = platformMapper.selectTicket();
redisUtils.set(CacheKey.OPEN_PLATFORM_TICKET, component_verify_ticket, 60 * 60 * 12);
}
if (StringUtils.isBlank(component_verify_ticket)) {
throw new BadRequestException("微信开放平台,第三方平台获取【验证票据】失败");
}
params.put("component_verify_ticket", component_verify_ticket);
JSONObject data = JSONObject.parseObject(HttpUtil.post(componentAccessTokenUrl, JSON.toJSONString(params)));
if (data.containsKey("component_access_token")) {
String componentAccessToken = data.getString("component_access_token");
redisUtils.set(CacheKey.COMPONENT_ACCESS_TOKEN, componentAccessToken, 60 * 60 * 2);
return;
}
redisUtils.del(CacheKey.COMPONENT_ACCESS_TOKEN);
redisUtils.del(CacheKey.OPEN_PLATFORM_TICKET);
throw new BadRequestException("获取component_access_token失败,请重试,接口返回信息:" + data.toJSONString());
}
3、获取authorizer_access_token
步骤:
获取预授权码pre_auth_code——>微信公众平台管理员授权——>获取authorizer_access_token&authorizer_refresh_token
官方文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/Before_Develop/Authorization_Process_Technical_Description.html
①获取预授权码pre_auth_code
@Override
public String getPreAuthCode() {
getComponentAccessToken();
String preAuthCodeUrl = "https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=" + redisUtils.get(CacheKey.COMPONENT_ACCESS_TOKEN);
JSONObject params = new JSONObject();
params.put("component_appid", APPID);
JSONObject data = JSONObject.parseObject(HttpUtil.post(preAuthCodeUrl, JSON.toJSONString(params)));
if (data.containsKey("pre_auth_code")) {
return data.getString("pre_auth_code");
}
throw new BadRequestException("获取预授权失败,接口返回信息:" + data.toJSONString());
}
②微信公众平台管理员授权
生成授权链接,可以在前端用a标签填入这个接口地址,点击a标签打开新标签页,管理员进行扫码授权
@Override
public void toAuthorization(HttpServletResponse response) throws IOException {
String authUrl = "https://mp.weixin.qq.com/cgi-bin/componentloginpagecomponent_appid=%s&pre_auth_code=%s&redirect_uri=%s&auth_type=1";
String redirectUrl = "https://服务端地址/callback";
String preAuthCode = getPreAuthCode();
response.setStatus(301);
response.sendRedirect(String.format(authUrl, APPID, preAuthCode, redirectUrl));
}
授权之后,会回调:“https://服务端地址/callback”,并将授权码auth_code携带到这个接口
@ApiOperation(value = "微信开放平台:用户授权后,回调地址", notes = "zhuguangliang")
@AnonymousGetMapping("/callback")
@Notice("2393194918@qq.com")
public void callback(String auth_code) {
}
③获取authorizer_access_token&authorizer_refresh_token
官方文档:https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/ThirdParty/token/authorization_info.html
说明:authorization_code就是授权之后,返回的授权码
如果你使用微信的第三方平台管理工具,可以在数据库中直接获取authorizer_refresh_token,然后根据:
获取/刷新接口调用令牌 | 微信开放文档 (qq.com)这个接口进行刷新authorizer_access_token,下面是实现代码:
@Override
public void getAuthorizationInfo(String authorizerAppId) {
String key = CacheKey.AUTH_ACCESS_TOKEN + authorizerAppId;
if (redisUtils.hasKey(key) && redisUtils.getExpire(key) > 5 * 60) {
return;
}
getComponentAccessToken();
String refreshTokenUrl = "https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token=" + redisUtils.get(CacheKey.COMPONENT_ACCESS_TOKEN);
JSONObject param = new JSONObject();
param.put("component_appid", APPID);
param.put("authorizer_appid", authorizerAppId);
String refreshToken;
if (redisUtils.hasKey(CacheKey.AUTH_REFRESH_TOKEN + authorizerAppId)) {
refreshToken = (String) redisUtils.get(CacheKey.AUTH_REFRESH_TOKEN + authorizerAppId);
} else {
refreshToken = platformMapper.selectRefreshTokenByAppId(authorizerAppId);
if (StringUtils.isBlank(refreshToken)) {
throw new BadRequestException("小程序管理员未授权,请让小程序管理员重新授权");
}
LocalDateTime authTime = platformMapper.selectAuthTimeByAppId(authorizerAppId).toLocalDateTime();
Duration duration = Duration.between(authTime, LocalDateTime.now());
redisUtils.set(CacheKey.AUTH_REFRESH_TOKEN + authorizerAppId, refreshToken, 60 * 60 * 24 * 30 - (duration.toMillis() / 1000 + 60));
}
param.put("authorizer_refresh_token", refreshToken);
JSONObject tokenData = JSONObject.parseObject(HttpUtil.post(refreshTokenUrl, JSON.toJSONString(param)));
if (tokenData.containsKey("authorizer_access_token")) {
redisUtils.set(CacheKey.AUTH_ACCESS_TOKEN + authorizerAppId, tokenData.getString("authorizer_access_token"), 2 * 60 * 60);
LambdaUpdateWrapper<SpiritOpenPlatform> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(SpiritOpenPlatform::getAppid, authorizerAppId);
SpiritOpenPlatform openPlatform = new SpiritOpenPlatform();
openPlatform.setAuthorizerRefreshToken(refreshToken);
openPlatform.setAuthorizerAccessToken(tokenData.getString("authorizer_access_token"));
platformMapper.update(openPlatform, updateWrapper);
return;
}
throw new BadRequestException("获取authorizer_access_token失败,接口返回信息:" + tokenData.toJSONString());
}
到此,微信第三方平台开发中的关键token,authorizer_access_token已经成功获取
4、调接口发布小程序
最后
总结:微信第三方平台开发的流程,涉及的接口有点多,微信的文档其实也写的很详细了,但是在开发过程中,免不了遇见莫名其妙的问题,这个时候可以在微信开放社区里找找或者搜下博客。
|