开源 - Java接口API授权认证与规范

开源 - Java接口API授权认证与规范

很多码友在处理Java后端接口API上,对于安全认证却是一种很头疼的事

开源地址

https://github.com/hiparker/interface-api-auth

为什么要授权认证

1.防止未授权的用户,非法获得不该他所能看到的数据
2.数据的安全性,防止被同行或者有心人士,通过接口爬取重要数据
3.防止接口大批量灌水,如果提前设置好Token失效时间,即使拿到了认证密文也只是短时间内起效(况且密文能不能解析还是一回事)

逻辑思维导图

Java后端接口API认证授权

接口认证效果

接口1

接口2

如果后端 通过认证文件调用API接口,则每次都会去取Token,即使Token失效也会重新生成

核心代码解析

API提供服务端 - HTTP协议 - 其他语言也可以调用

统一返回格式

package com.parker.api.common.result;

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.http.HttpStatus;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;


/**
 * 统一返回参数
 *
 * @date 2020年5月15日10:40:54
 * @author Parker
 * 
 */
public class Result extends HashMap<String,Object> implements Serializable {


	public Result(){
		this.put("success", true);
		this.put("code", HttpStatus.OK.value());
		this.put("msg", "操作成功");
	}

	public String getMsg() {
		return (String)this.get("msg");
	}

	public void setMsg(String msg) {//向json中添加属性,在js中访问,请调用data.msg
		this.put("msg", msg);
	}


	public boolean isSuccess() {
		return (boolean)this.get("success");
	}

	public void setSuccess(boolean success) {
		this.put("success", success);
	}
	
	@JsonIgnore//返回对象时忽略此属性
	public String getJsonStr() {//返回json字符串数组,将访问msg和key的方式统一化,都使用data.key的方式直接访问。
		String json = JsonMapper.getInstance().toJson(this);
		return json;
	}
	@JsonIgnore//返回对象时忽略此属性
	public static Result success(String msg) {
		Result j = new Result();
		j.setMsg(msg);
		return j;
	}
	@JsonIgnore//返回对象时忽略此属性
	public static Result error(String msg) {
		Result j = new Result();
		j.setSuccess(false);
		j.setMsg(msg);
		return j;
	}

	public static Result success(Map<String, Object> map) {
		Result restResponse = new Result();
		restResponse.putAll(map);
		return restResponse;
	}

	public static Result success() {
		return new Result();
	}


	@Override
	public Result put(String key, Object value) {
		super.put(key, value);
		return this;
	}

	public Result putMap(Map m) {
		super.putAll(m);
		return this;
	}

	public int getCode() {
		return (int)this.get("code");
	}

	public void setCode(int code) {
		this.put("code", code);
	}

}

工具类

package com.parker.api.common;

import com.alibaba.fastjson.JSONObject;
import com.parker.api.common.result.Result;
import com.parker.api.common.util.IdGen;
import com.parker.api.common.util.RedisUtil;
import com.parker.api.common.util.misc.AESUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStreamWriter;

/**
 * Created Date by 2020/5/14 0014.
 *
 * 公用接口工具类
 * @author Parker
 */
@Slf4j
public final class InterfaceUtil {

    /** Redis前缀 */
    private static final String REDIS_PREFIX = "TOKEN_";

    /** 文本加密KEY */
    private static final String ENCRYPT_KEY = "1010110";
    /** 密码加密KEY */
    private static final String PASSWORD_KEY = "arcinbj";

    /** token 过期时间(秒) 600s = 10m */
    private static final int TOKEN_EXPIRE_TIME = 600;

    /** 参数 */
    private static final String INTERFACE_UUID = "uuid";
    public static final String INTERFACE_USER_LOGIN_NAME = "login_name";
    public static final String INTERFACE_USER_PASSWORD = "password";

    /**
     * 私有化 构造函数
     * 防止序列化 工具类
     */
    private InterfaceUtil(){}


    /**
     * 根据 cnf 密文 ,获得Token 密文
     * 注:
     *      Token 生命时长为 10分钟
     *      10分钟后 生成新的Token
     *      每个Token都不一样 防止接口被爬取 或者 盗链疯狂灌水
     *
     * @param cnfStr
     * @return
     */
    public static Result getToken(String cnfStr){
        Result result = new Result();
        String dcnfStr = "";
        boolean dcnfFlag = false;
        try {
            dcnfStr = InterfaceUtil.decryptCnfString(cnfStr);
            dcnfFlag = true;
        }catch (Exception e){
            log.error(e.getMessage(),e);
        }
        if(!dcnfFlag){
            return  Result.error("解析失败");
        }

        JSONObject jsonObject = JSONObject.parseObject(dcnfStr);

        // ----------
        // 用户登录名
        String userLoginName = (String) jsonObject.get(INTERFACE_USER_LOGIN_NAME);
        // 验证码 - UUID
        String UUID = IdGen.uuid();

        // ----------
        // 授权Token 密文
        String encryptStr = null;
        Object objToken = RedisUtil.get(REDIS_PREFIX + userLoginName);
        // 如果Token 过期 ,则重新生成一个Token放入到Redis
        if(objToken == null){
            JSONObject beforeEncryptJson = new JSONObject(true);
            beforeEncryptJson.put(INTERFACE_USER_LOGIN_NAME,userLoginName);
            beforeEncryptJson.put(INTERFACE_UUID,UUID);

            boolean encryptStrFlag = false;
            try {
                // 加密 json
                encryptStr = encryptTokenString(beforeEncryptJson);
                encryptStrFlag = true;
            }catch (Exception e){
                log.error(e.getMessage(),e);
            }
            if(!encryptStrFlag){
                return  Result.error("加密失败");
            }

            // 将生成好的 授权Token 加入到Redis 设置为10分钟过期
            RedisUtil.set(REDIS_PREFIX + userLoginName,encryptStr,TOKEN_EXPIRE_TIME);
        }else{
            encryptStr = (String) objToken;
        }

        // ----------
        // 判断 如果 encryptStr 密文token为空 则报错
        if(StringUtils.isEmpty(encryptStr)){
            result.setSuccess(false);
            result.setMsg("Token 获取失败!");
        }else{
            result.put("token",encryptStr);
        }
        return result;
    }


    /**
     * 加密CNF String
     * @param userLoginName 用户登录名
     * @param userPassword  用户密码
     * @return String
     */
    public static String encryptCnfString(String userLoginName,String userPassword){
        JSONObject beforeEncryptJson = new JSONObject(true);
        beforeEncryptJson.put(INTERFACE_USER_LOGIN_NAME,userLoginName);
        // 对于密码再次加密
        beforeEncryptJson.put(INTERFACE_USER_PASSWORD, AESUtil.encrypt(userPassword, PASSWORD_KEY));
        // 为了安全性 加密2次
        // 加密 json
        String encryptJsonStr = AESUtil.encrypt(beforeEncryptJson.toJSONString(), ENCRYPT_KEY);
        return AESUtil.encrypt(encryptJsonStr, ENCRYPT_KEY);
    }

    /**
     * 解密CNF String
     * @param cnfStr cnf 文本
     * @return String
     */
    public static String decryptCnfString(String cnfStr) throws Exception{
        // 第一次解密
        String encryptJsonStr = AESUtil.decrypt(cnfStr, ENCRYPT_KEY);
        // 第二次解密
        String encryptStr = AESUtil.decrypt(encryptJsonStr, ENCRYPT_KEY);

        JSONObject jsonObject = JSONObject.parseObject(encryptStr);
        String userPassword = (String) jsonObject.get(InterfaceUtil.INTERFACE_USER_PASSWORD);
        // 解密 密码
        jsonObject.put(INTERFACE_USER_PASSWORD,AESUtil.decrypt(userPassword, PASSWORD_KEY));
        return jsonObject.toJSONString();
    }

    /**
     * 加密Token String
     * @param tokenJson Token
     * @return String
     */
    private static String encryptTokenString(JSONObject tokenJson) throws Exception{
        // 为了安全性 加密3次
        // 加密 json
        String encryptJsonStr = AESUtil.encrypt(tokenJson.toJSONString(), ENCRYPT_KEY);
        String encryptJsonStr2 = AESUtil.encrypt(encryptJsonStr, ENCRYPT_KEY);
        return AESUtil.encrypt(encryptJsonStr2, ENCRYPT_KEY);
    }

    /**
     * 解密Token String
     * @param tokenStr Token 文本
     * @return String
     */
    private static String decryptTokenString(String tokenStr) throws Exception{
        // 第1次解密
        String encryptJsonStr = AESUtil.decrypt(tokenStr, ENCRYPT_KEY);
        // 第2次解密
        String encryptJsonStr2 = AESUtil.decrypt(encryptJsonStr, ENCRYPT_KEY);
        // 第3次解密
        return AESUtil.decrypt(encryptJsonStr2, ENCRYPT_KEY);
    }

    /**
     * 验证 Token 有效性
     * @param tokenStr
     * @return
     */
    public static boolean validateToken(String tokenStr){
        String dTokenStr  = "";
        boolean dTokenFlag = false;
        try {
            // 解密token
            dTokenStr = InterfaceUtil.decryptTokenString(tokenStr);
            dTokenFlag = true;
        }catch (Exception e){
            log.error(e.getMessage(),e);
        }
        if(!dTokenFlag){
            return false;
        }
        if(dTokenStr != null){
            JSONObject jsonObject = JSONObject.parseObject(dTokenStr);
            if(jsonObject != null){
                // 用户登录名
                String userLoginName = (String) jsonObject.get(INTERFACE_USER_LOGIN_NAME);
                Object objToken = RedisUtil.get(REDIS_PREFIX + userLoginName);
                if(objToken != null){
                    if(tokenStr.equals((String) objToken)){
                        return true;
                    }
                }
            }
        }
        return false;
    }

    /**
     * 输出到客户端
     * @param fileName 输出文件名
     */
    public static void write(String cnf, String fileName, HttpServletResponse response) throws IOException {
        response.setHeader("Content-Disposition", "attachment;filename="
                + new String((fileName).getBytes(), "iso-8859-1"));
        try {
            OutputStreamWriter write = new OutputStreamWriter(response.getOutputStream(), "utf-8");
            BufferedWriter writer = new BufferedWriter(write);
            StringBuilder cfnStringBuilder = new StringBuilder();
            cfnStringBuilder.append(cnf);
            writer.write(cfnStringBuilder.toString());
            writer.close();
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

   

}

生产者

package com.parker.api.producer.service;

import com.alibaba.fastjson.JSONObject;
import com.parker.api.common.InterfaceUtil;
import com.parker.api.common.result.Result;
import com.parker.api.producer.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * 生产者 Service
 *
 * @author Parker
 */
@Slf4j
@Service
public class InterfaceService {

    @Autowired
    private SystemService systemService;

    /**
     * 生成 CNF
     * @param userLoginName
     * @param userPassword
     * @return
     */
    public Result createCNF(String userLoginName, String userPassword){
        Result result = new Result();
        if(StringUtils.isEmpty(userLoginName) || StringUtils.isEmpty(userPassword)){
            result.setMsg("用户登录名或密码不可为空!");
            result.setSuccess(false);
            return result;
        }

        String cnf = InterfaceUtil.encryptCnfString(userLoginName, userPassword);
        if(StringUtils.isNotEmpty(cnf)){
            result.put("cnf",cnf);
        }else{
            result.setSuccess(false);
            result.setMsg("cnf文件生成失败!");
        }
        return result;
    }

    /**
     * 获得 Token
     * @param cnf
     * @return
     */
    public Result getToken(String cnf){
        Result result = new Result();
        if(StringUtils.isEmpty(cnf)){
            result.setMsg("cnf 不可为空!");
            result.setSuccess(false);
            return result;
        }

        String dcnfStr = "";
        boolean dcnfFlag = false;
        try {
            // 解密token
            dcnfStr = InterfaceUtil.decryptCnfString(cnf);
            dcnfFlag = true;
        }catch (Exception e){
            log.error(e.getMessage(),e);
        }
        if(!dcnfFlag){
            return  Result.error("解析失败");
        }
        JSONObject jsonObject = JSONObject.parseObject(dcnfStr);
        String userLoginName = (String) jsonObject.get(InterfaceUtil.INTERFACE_USER_LOGIN_NAME);
        String userPassword = (String) jsonObject.get(InterfaceUtil.INTERFACE_USER_PASSWORD);
        // 获得用户
        User currUser = systemService.getByLoginName(userLoginName);
        if(currUser != null){
            if(StringUtils.isNotEmpty(userPassword) && StringUtils.isNotEmpty(currUser.getPassword())){
                // 密码匹配
                boolean flag = SystemService.validatePassword(userPassword,currUser.getPassword());
                if(flag){
                    return InterfaceUtil.getToken(cnf);
                }
            }
        }

        result.setMsg("Token 获取失败!");
        result.setSuccess(false);
        return result;
    }


    /**
     * 验证 Token
     * @param token
     * @return
     */
    public Result validateToken(String token){
        Result result = new Result();
        if(StringUtils.isEmpty(token)){
            result.setMsg("token 不可为空!");
            result.setSuccess(false);
            return result;
        }

        boolean flag = InterfaceUtil.validateToken(token);

        if(flag){
            result.setMsg("Token 生效中!");
        }else{
            result.setSuccess(false);
            result.setMsg("Token 已失效!");
        }
        return result;
    }

}

验证用户

package com.parker.api.producer.service;

import com.google.common.collect.Maps;
import com.parker.api.common.util.Digests;
import com.parker.api.common.util.Encodes;
import com.parker.api.common.util.RedisUtil;
import com.parker.api.producer.entity.User;
import org.apache.commons.lang3.RandomUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
 * Created Date by 2020/5/15 0015.
 *
 * 模拟 从缓存里查找用户 如果找不到 就去数据库找
 * @author Parker
 */
@Service
public class SystemService {

    public static final String HASH_ALGORITHM = "SHA-1";
    public static final int HASH_INTERATIONS = 1024;
    public static final int SALT_SIZE = 8;
    /** Redis前缀 */
    private static final String REDIS_PREFIX = "LOGIN_USER_";
    /** token 过期时间(秒) 6000s = 1h */
    private static final int TOKEN_EXPIRE_TIME = 6000;

    /** 模拟数据库 用户当前数据 */
    private static final Map<String, User> userMap = Maps.newHashMap();

    /**
     * 测试数据
     */
    static{
        User currUser1 = new User()
                .setUserName("周一")
                .setUserLoginName("parker")
                .setPassword(entryptPassword("123456"));
        User currUser2 = new User()
                .setUserName("崔二")
                .setUserLoginName("test")
                .setPassword(entryptPassword("123456"));
        User currUser3 = new User()
                .setUserName("张三")
                .setUserLoginName("tes1")
                .setPassword(entryptPassword("884848"));
        User currUser4 = new User()
                .setUserName("李四")
                .setUserLoginName("tes2")
                .setPassword(entryptPassword("ashdu12"));
        User currUser5 = new User()
                .setUserName("王五")
                .setUserLoginName("tes3")
                .setPassword(entryptPassword("gdf232"));
        User currUser6 = new User()
                .setUserName("赵六")
                .setUserLoginName("tes4")
                .setPassword(entryptPassword("4v782"));

        userMap.put(currUser1.getUserLoginName(),currUser1);
        userMap.put(currUser2.getUserLoginName(),currUser2);
        userMap.put(currUser3.getUserLoginName(),currUser3);
        userMap.put(currUser4.getUserLoginName(),currUser4);
        userMap.put(currUser5.getUserLoginName(),currUser5);
        userMap.put(currUser6.getUserLoginName(),currUser6);

    }


    /**
     * 根据登录名获取用户
     *
     * 模拟数据库查询,查到就加入缓存 防止恶意刷接口 导致 穿透效应
     *
     * @param loginName 登录名
     * @return 取不到返回 null
     */
    public User getByLoginName(String loginName) {

        // 非法判断
        if (StringUtils.isEmpty(loginName)) {
            return null;
        }

        User user = null;
        user = (User) RedisUtil.hget(REDIS_PREFIX + loginName,loginName);
        if (null == user){
            user = userMap.get(loginName);
            if (null == user){
                return null;
            }
            // 设置随机过期时间 防止雪崩
            int randomNum = RandomUtils.nextInt(600, 1200);
            RedisUtil.hset(REDIS_PREFIX + loginName,loginName,user,randomNum);
        }

        return user;
    }


    /**
     * 生成安全的密码,生成随机的16位salt并经过1024次 sha-1 hash
     */
    public static String entryptPassword(String plainPassword) {
        byte[] salt = Digests.generateSalt(SALT_SIZE);
        byte[] hashPassword = Digests.sha1(plainPassword.getBytes(), salt, HASH_INTERATIONS);
        return Encodes.encodeHex(salt)+ Encodes.encodeHex(hashPassword);
    }

    /**
     * 验证密码
     * @param plainPassword 明文密码
     * @param password 密文密码
     * @return 验证成功返回true
     */
    public static boolean validatePassword(String plainPassword, String password) {
        byte[] salt = Encodes.decodeHex(password.substring(0,16));
        byte[] hashPassword = Digests.sha1(plainPassword.getBytes(), salt, HASH_INTERATIONS);
        return password.equals(Encodes.encodeHex(salt)+ Encodes.encodeHex(hashPassword));
    }

}

第三方接口调用端

工具类 - 后端服务发起接口调用

package com.parker.api.consumer.util;

import lombok.extern.slf4j.Slf4j;

import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import com.parker.api.common.result.JsonMapper;
import com.parker.api.common.result.Result;

import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;

import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Map;
import java.util.UUID;

/**
 * Created Date by 2020/5/15 0015.
 *
 * @author Parker
 */
@Slf4j
public final class ApiUtils {

    private static final String URL = "http://127.0.0.1:8000";

    public static final Map<String,String> map = Maps.newHashMap();
    static {
        //凭证保存
        map.put("getToken","/interface/do/getToken");
    }

    /**
     * 私有化构造函数
     */
    private ApiUtils(){}

    /**
     * 获得token
     * @param uri
     * @param cnf
     * @return
     */
    public static Result getToken(String uri,String cnf){
        Result result = null;
        try {
            Map<String,Object> argsMap = Maps.newHashMap();
            argsMap.put("cnf",cnf);
            String resultStr = sendPost(uri, argsMap);
            // 获得结果
            result = (Result) JsonMapper.fromJsonString(resultStr, Result.class);
        }catch (Exception e){
            log.error(e.getMessage(),e);
        }

        if(null == result){
            return Result.error("接口请求失败");
        }
        return result;
    }


    /**
     * 发起 接口API 请求
     * @param uri
     * @param argsMap
     * @return
     */
    public static Result httpRequest(String uri,Map<String,Object> argsMap){
        Result result = null;
        try {
            String resultStr = sendPost(uri, argsMap);
            // 获得结果
            result = (Result) JsonMapper.fromJsonString(resultStr, Result.class);
        }catch (Exception e){
            log.error(e.getMessage(),e);
        }

        if(null == result){
            return Result.error("接口请求失败");
        }
        return result;
    }

    /** Common */
    /** ------------------------------------------------------------------------------------------------------------ **/

    /**
     * 发起 post 请求
     * @param url
     * @param argsMap
     * @return
     */
    public static String sendPost(String url, Map<String,Object> argsMap) {
        DataOutputStream out = null;
        BufferedReader reader = null;
        HttpURLConnection conn = null;
        StringBuffer result = new StringBuffer();
        try {
            URL realUrl = new URL(URL.concat(url));

            // 打开和URL之间的连接
            conn = (HttpURLConnection) realUrl.openConnection();
            if (!conn.getDoOutput()) {
                conn.setDoOutput(true);
            }
            // 设置为POST请求方式
            conn.setRequestMethod("POST");
            conn.setUseCaches(false);
            conn.setInstanceFollowRedirects(true);
            conn.setRequestProperty("Content-Type", "application/json");

            // 获取URLConnection对象对应的输出流
            out = new DataOutputStream(
                    conn.getOutputStream());

            // 处理请求参数
            UUID uuid = UUID.randomUUID();
            int hashCode = uuid.toString().hashCode();
            argsMap.put("format", 1);
            argsMap.put("useragent", "ApiClient");
            argsMap.put("rid", hashCode);
            argsMap.put("timestamp",  System.currentTimeMillis());
            argsMap.put("v", "1.0");

            JSONObject jObj = new JSONObject();
            for (String key : argsMap.keySet()) {
                jObj.put(key,argsMap.get(key).toString());
            }

            // 发送请求参数
            out.writeBytes(chinaToUnicode(jObj.toString()));
            // flush输出流的缓冲
            out.flush();

            // 定义BufferedReader输入流来读取URL的响应
            reader = new BufferedReader(new InputStreamReader(
                    conn.getInputStream()));

            String line;
            while ((line = reader.readLine()) != null) {
                String sResult = new String(line.getBytes(), "utf-8");
                result.append(sResult);
            }

        } catch (Exception e) {
            log.error("发送 POST 请求出现异常! Message:{}",e.getMessage(),e);
        }
        //使用finally块来关闭输出流、输入流、链接
        finally{
            try{
                if(out!=null){
                    out.close();
                }
                if(reader!=null){
                    reader.close();
                }
                if(conn != null){
                    conn.disconnect();
                }
            }
            catch(IOException ex){
                ex.printStackTrace();
            }
        }
        return result.toString();
    }

    /**
     * 把中文转成Unicode码
     *
     * @param str
     * @return
     */
    public static String chinaToUnicode(String str) {
        String result = "";
        for (int i = 0; i < str.length(); i++) {
            int chr1 = (char) str.charAt(i);
            if (chr1 >= 19968 && chr1 <= 171941) {// 汉字范围 \u4e00-\u9fa5 (中文)
                result += "\\u" + Integer.toHexString(chr1);
            } else {
                result += str.charAt(i);
            }
        }
        return result;
    }
}

解析cnf文件 调用接口

     /**
     * 实例
     *
     * 模拟 真实 第三方服务调用
     * 将cnf 文件下发给 第三方服务 , 第三方服务通过后端服务器调用接口 所以不用担心 cnf 被丢失暴力破解
     *
     * 即使丢失 只要修改 cnf授权 用户密码 即可将原有的cnf文件失效
     *
     * @return
     */
    @ResponseBody
    @RequestMapping(value = "example")
    public Result example(){
        Result result = null;
        ResourceLoader resourceLoader = new DefaultResourceLoader();
        String  RESOURCES_PATHS = "/tokenFile/";
        BufferedInputStream bis = null;
        try {
            int fileSize = 0;
            try {
                Resource resource = resourceLoader.getResource(RESOURCES_PATHS+"test.cnf");
                fileSize =(int) resource.getFile().length();
                bis = new BufferedInputStream(resource.getInputStream());
            } catch (IOException ex) {
                // TODO
                log.error(ex.getMessage(),ex);
            }

            byte[] buf = new byte[fileSize];
            int length = 0;
            String str = null;
            if((length = bis.read(buf))!=-1){
                str=new String(buf,"utf-8");
            }


            // 获得Token
            Result getToken = ApiUtils.getToken(ApiUtils.map.get("getToken"), str);
            if(getToken.isSuccess()){
                // 请求 API 接口
                Map<String,String> argsMap = Maps.newHashMap();
                argsMap.put("token",(String) getToken.get("token"));
                argsMap.put("code","1010110");
                result = ApiUtils.httpRequest("/interface/do/test", argsMap);
            }else{
                result = getToken;
            }

        }catch (Exception e){
            // TODO
        }finally {
            IOUtils.closeQuietly(bis);
        }

        if(result == null){
            return Result.error("请求失败!");
        }
        return result;
    } 

结语

开源敲码不易,如果感觉对你有帮助可以点击下方支持,请作者喝一杯咖啡!

本文由 在码圈 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
您可以自由的转载和修改,但请务必注明文章来源并且不可用于商业目的。
本站部分内容收集于互联网,如果有侵权内容、不妥之处,请联系我们删除。敬请谅解!
原文链接:https://www.arcinbj.com/archives/interfaceapi
最后更新于:2020-07-02 12:15:39

请博主喝咖啡 ☕.