Spring Boot 实现微信公众号网页授权

微信公众号网页授权的场景

微信公众号网页授权支持用户在关注微信公众号后,通过 OAuth2.0 协议,允许第三方网页获取用户在微信中的基本信息的授权机制。这种授权机制在很多场景下都可以被应用,包括但不限于以下几个方面:

网页登录: 微信公众号网页授权可以用于实现用户在网页上使用微信账号登录的功能,无需额外注册账号即可登录网站或应用。

个性化服务: 基于用户授权获取的微信信息,可以为用户提供个性化的服务和体验,比如根据用户的微信昵称、头像等信息进行定制化的展示。

线下营销: 在线下活动中,可以通过微信公众号网页授权收集用户信息,比如举办抽奖活动、线下签到等。

参考博客:JAVA(SpringBoot)对接微信登录_java对接微信登录-优快云博客

准备

内网穿透

我们需要提前对后端服务进行内网穿透,这里只做测试,使用 Cpolar(或者自己使用其他的内网穿透工具)进行内网穿透,如果不熟悉的可以关注我之前的博文: http://t.csdnimg.cn/gTGRV。我的后端服务地址为 localhost:9998,故我只需提前将端口穿透出去即可,如:

敲下回车,等待响应,此时会得到两个公网地址 http://532908f7.r24.cpolar.top 或 https://532908f7.r24.cpolar.top 选择一个使用即可,需要注意的是,这个公网地址是临时的,意味着你一旦结束服务即会作废。:

微信开放平台配置

完成了内网穿透后,我们打开微信公众平台接口测试账号申请页面:微信公众平台,如果你有已经注册的企业微信公众号可以参考官方文档进行配置,这里只介绍测试公众号的开发流程,真实公众号开发流程与测试公众号开发流程基本一致,做个参考即可。

登录进去以后我们需要配置几个信息:

1.记录下自己的 appIDappsecret,后面需要使用

2.配置接口配置信息,点击修改,然后将之前的公网地址填入 URL 处,再自己自定义一个 Token(Token 在后面参数校验时需要使用),填完之后点提交,不出意外的话会提示配置失败,如下:

为什么?ok,我们看一下官方文档:

官方文档说明了,当你提交信息后,微信服务器将发送 GET 请求到你自己的服务器上,来进行校验,它要求你对微信服务器发送的请求信息进行校验,校验通过后要原样返回 echostr 参数的内容,注意是原样返回,你不能对它作任何修改!

由于此时我们还未完成校验接口,故暂时不能提交,那么我们就先填好信息不提交,等到后面完成代码再提交即可。

3.配置网页账号-网页授权获取用户基本信息,我们往下找:

红色框标出的就是我们需要修改的,点击修改,并填入与上面一样的域名:

这里有个坑,你需要填的是域名,不要带上了 http、https 或者端口号等信息,否则会检测不通过!填写正确后,点击确认即可。到这里就完成了微信开放平台的基本配置,除了第二步我们暂时放下,等完成代码再重新提交即可。

数据表建立

user_id是逻辑外键,需要自己实现一个user表,当然也可以仅使用公众号授权登录。

DROP TABLE IF EXISTS `wechat_user`;

CREATE TABLE `wechat_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
  `user_id` bigint DEFAULT NULL COMMENT '用户id',
  `openid` varchar(255) DEFAULT NULL COMMENT '用户唯一标识',
  `access_token` varchar(255) DEFAULT NULL COMMENT 'AccessToken',
  `refresh_token` varchar(255) DEFAULT NULL COMMENT 'RefreshToken',
  `nickname` varchar(255) DEFAULT NULL COMMENT '微信昵称',
  `sex` char(1) DEFAULT NULL COMMENT '性别',
  `province` varchar(255) DEFAULT NULL COMMENT '省份',
  `city` varchar(255) DEFAULT NULL COMMENT '用户城市',
  `country` varchar(255) DEFAULT NULL COMMENT '国家',
  `avatar` varchar(255) DEFAULT NULL COMMENT '用户头像',
  `privilege` varchar(255) DEFAULT NULL COMMENT '特权信息',
  `unionid` varchar(255) DEFAULT NULL COMMENT 'unionid',
  `create_user` bigint DEFAULT NULL COMMENT '创建用户',
  `update_user` bigint DEFAULT NULL COMMENT '更新用户',
  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
  `update_date` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=32 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

LOCK TABLES `wechat_user` WRITE;

UNLOCK TABLES;

Maven依赖 

<!-- 版本控制 -->
<hutool.versin>5.8.25</hutool.versin>
<lombok.versin>1.18.22</lombok.versin>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<fastjson.version>2.0.32</fastjson.version>

<!-- hutool -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>${hutool.versin}</version>
</dependency>

<!-- lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${lombok.versin}</version>
</dependency>

<!-- mybatis-plus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>${mybatis-plus.version}</version>
</dependency>

<!-- fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>${fastjson.version}</version>
</dependency>

代码实现 

整体架构是这样的:

配置类 - WeChatConfig

/**
 * 微信通用配置类
 *
 */
public class WeChatConfig {

    /**
     * APPID,填自己的APPID
     */
    public final static String APPID = "wxXXXXXX84";
    /**
     * APPSECRET,填自己的APPSECRET
     */
    public final static String APPSECRET = "7XXXXXX61";
    public final static String GET_CODE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
    /**
     * 回调地址,需要修改域名,修改http://1dcd46bb.r24.cpolar.top这个就好啦
     */
    public final static String REDIRECT_URL = "http://1dcd46bb.r24.cpolar.top/weChat/getAccessToken";
    public final static String GET_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
    public final static String REFRESH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/refresh_token";
    public final static String GET_USER_INFO = "https://api.weixin.qq.com/sns/userinfo";
}

工具类 - OtherUtil

import java.security.MessageDigest;
import java.util.Locale;
import java.util.Random;

/**
 * 其他工具类
 *
 */
public class OtherUtil {

    private final static byte[] HEX = "0123456789ABCDEF".getBytes();
    private final static String STRING = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private final static String MESSAGE_DIGEST_INSTANCE = "SHA-1";
    private final static String ENCODING = "UTF-8";


    /**
     * 从字节数组到十六进制字符串转换
     */
    public static String bytes2HexString(byte[] b) {
        byte[] buff = new byte[2 * b.length];
        for (int i = 0; i < b.length; i++) {
            buff[2 * i] = HEX[(b[i] >> 4) & 0x0f];
            buff[2 * i + 1] = HEX[b[i] & 0x0f];
        }
        return new String(buff);
    }

    /**
     * 校验参数是否合法
     */
    public static boolean verification(String[] params, String signature) throws Exception {
        // 拼接
        String paramstr = params[0] + params[1] + params[2];
        // 获取 shal 算法封装类
        MessageDigest messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_INSTANCE);
        // 进行加密
        byte[] digestResult = messageDigest.digest(paramstr.getBytes(ENCODING));
        // 拿到加密结果
        String mysignature = OtherUtil.bytes2HexString(digestResult);
        mysignature = mysignature.toLowerCase(Locale.ROOT);
        // 是否正确
        return mysignature.equals(signature);
    }


    /**
     * 用户要求产生字符串的长度
     */
    public static String getRandomString(int length){
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i < length; i++){
            int number = random.nextInt(62);
            sb.append(STRING.charAt(number));
        }
        return sb.toString();
    }
}

工具类 - WeChatUtil

import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSONObject;
import com.xjl.common.exception.EasyInvoiceException;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Map;

import static com.xjl.admin.testconnect.wechat.config.WeChatConfig.*;

/**
 * 微信登录工具类
 *
 */
public class WeChatUtil {

    private final static String ERROR_CODE = "errcode";

    /**
     * 获取微信授权code
     *
     * @param state state信息
     * @return 返回微信授权code的URL
     */
    public static String getCode(String state) {
        try {
            StringBuffer url = new StringBuffer();
            url.append(GET_CODE_URL)
                    .append("?appid=")
                    .append(APPID)
                    .append("&redirect_uri=")
                    .append(URLEncoder.encode(REDIRECT_URL, "UTF-8"))
                    .append("&response_type=code&scope=snsapi_userinfo&state=")
                    .append(state)
                    .append("#wechat_redirect");
            return url.toString();
        } catch (UnsupportedEncodingException e) {
            throw new EasyInvoiceException("URL格式化异常");
        }

    }

    /**
     * 获取微信AccessToken
     *
     * @param code 用户code
     * @return 返回包含微信AccessToken的Map
     */
    public static Map<?, ?> getAccessToken(String code) {
        StringBuffer url = new StringBuffer();
        url.append(GET_ACCESS_TOKEN_URL)
                .append("?appid=").append(APPID)
                .append("&secret=").append(APPSECRET)
                .append("&code=").append(code)
                .append("&grant_type=authorization_code");
        String rs = HttpUtil.get(url.toString());
        Map<?, ?> map = JSONObject.parseObject(rs, Map.class);
        if (null == map.get(ERROR_CODE)) {
            return map;
        } else {
            throw new EasyInvoiceException("获取access_token出错");
        }
    }

    /**
     * 刷新AccessToken
     *
     * @param refreshToken 用户刷新token
     * @return 返回包含刷新后的微信AccessToken的Map
     */
    public static Map<?, ?> refreshToken(String refreshToken) {
        StringBuffer url = new StringBuffer();
        url.append(REFRESH_TOKEN_URL)
                .append("?appid=").append(APPID)
                .append("&grant_type=refresh_token&refresh_token=").append(refreshToken);
        String rs = HttpUtil.get(url.toString());
        Map<?, ?> map = JSONObject.parseObject(rs, Map.class);
        if (null == map.get(ERROR_CODE)) {
            return map;
        } else {
            throw new EasyInvoiceException("刷新access_token出错");
        }
    }

    /**
     * 获取用户信息
     *
     * @param accessToken 微信AccessToken
     * @param openid 用户的openid
     * @return 返回包含用户信息的JSON字符串
     */
    public static String getUserInfo(String accessToken, String openid) {
        StringBuffer url = new StringBuffer();
        url.append(GET_USER_INFO)
                .append("?access_token=").append(accessToken)
                .append("&openid=").append(openid)
                .append("&lang=zh_CN");
        return HttpUtil.get(url.toString());
    }
}

Service层 - WeChatUserService

import com.baomidou.mybatisplus.extension.service.IService;
import com.xjl.admin.testconnect.wechat.empower.entity.WeChatUser;

public interface WeChatUserService extends IService<WeChatUser> {
}

Service层实现类 - WeChatUserServiceImpl

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xjl.admin.testconnect.wechat.empower.entity.WeChatUser;
import com.xjl.admin.testconnect.wechat.empower.mapper.WeChatUserMapper;
import com.xjl.admin.testconnect.wechat.empower.service.WeChatUserService;
import org.springframework.stereotype.Service;

@Service
public class WeChatUserServiceImpl extends ServiceImpl<WeChatUserMapper, WeChatUser> implements WeChatUserService {
}

Mapper层 - WeChatUserMapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xjl.admin.testconnect.wechat.empower.entity.WeChatUser;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface WeChatUserMapper extends BaseMapper<WeChatUser> {
}

实体类 - WeChatUser

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Date;

/**
 * 微信用户信息
 *
 */
@Data
@TableName(value = "wechat_user")
public class WeChatUser implements Serializable {
    private static final long serialVersionUID = -40356785423868312L;

    private Long id;
    /**
     * 用户id
     */
    private Long userId;

    /**
     * 用户唯一标识
     */
    private String openid;

    /**
     * access_token
     */
    private String accessToken;

    /**
     * refresh_token
     */
    private String refreshToken;

    /**
     * 用户昵称
     */
    private String nickname;

    /**
     * 性别(0未知,1男性,2女性)
     */
    private String sex;

    /**
     * 用户省份
     */
    private String province;

    /**
     * 用户城市
     */
    private String city;

    /**
     * 国家
     */
    private String country;

    /**
     * 用户头像
     */
    private String avatar;

    /**
     * 用户特权信息,json 数组
     */
    private String privilege;

    /**
     * unionid
     */
    private String unionid;

    /**
     * 创建用户
     */
    private Long createUser;

    /**
     * 更新用户
     */
    private Long updateUser;

    /**
     * 创建时间
     */
    private LocalDateTime createDate;

    /**
     * 更新时间
     */
    private LocalDateTime updateDate;
}

控制层 - WeChatController

package com.xjl.admin.testconnect.wechat.empower.controller;

import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.xjl.admin.testconnect.wechat.empower.entity.WeChatUser;
import com.xjl.admin.testconnect.wechat.empower.utils.WeChatUtil;
import com.xjl.admin.testconnect.wechat.empower.service.WeChatUserService;
import com.xjl.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;

import static com.xjl.admin.testconnect.wechat.empower.page.WeChatPage.SUCCESS_PAGE;
import static com.xjl.admin.testconnect.wechat.empower.utils.OtherUtil.verification;

/**
 * @author b16mt
 */
@Slf4j
@RestController
@RequestMapping("/weChat")
public class WeChatController{

    private final static String TOKEN = "abcd1234";
    private final static String ENCODING = "UTF-8";
    private final static String ACCESS_TOKEN = "access_token";
    private final static String REFRESH_TOKEN = "refresh_token";
    private final static String OPEN_ID = "openid";
    private final static String NICK_NAME = "nickname";
    private final static String SEX = "sex";
    private final static String HEAD_IMG_URL = "headimgurl";
    /**
     * 二维码保存路径
     */
    private final static String IMG_PATH = "C:\\Users\\code.png";

    private final WeChatUserService weChatUserService;

    public WeChatController(WeChatUserService weChatUserService) {
        this.weChatUserService = weChatUserService;
    }

    /**
     * 微信Token验证
     *
     * @param signature 微信加密签名
     * @param timestamp 时间戳
     * @param nonce 随机数
     * @param echostr 随机字符串
     * @param response HTTP响应对象
     * @throws Exception 如果处理过程中出现错误
     */
    @GetMapping("/verifyToken")
    public void verifyToken(@RequestParam(value = "signature") String signature,
                            @RequestParam(value = "timestamp") String timestamp,
                            @RequestParam(value = "nonce") String nonce,
                            @RequestParam(value = "echostr") String echostr, HttpServletResponse response) throws Exception {

        // 参数排序
        String[] params = new String[] { timestamp, nonce, TOKEN };
        Arrays.sort(params);

        // 校验成功则响应 echostr,失败则不响应
        if (verification(params, signature) && echostr != null) {
            response.setCharacterEncoding(ENCODING);
            response.getWriter().write(echostr);
            response.getWriter().flush();
            response.getWriter().close();
        }
    }

    /**
     * 获取微信code
     *
     * @param state 状态参数
     */
    @GetMapping("/getCode")
    public void weiXinLogin(String state) {
        QrConfig config = new QrConfig(300, 300);
        // 设置边距,即二维码和背景之间的边距
        config.setMargin(1);
        // 生成二维码到文件,也可以到流
        QrCodeUtil.generate(WeChatUtil.getCode(state), config,
                FileUtil.file(IMG_PATH));
    }

    /**
     * 获取token和userInfo
     *
     * @param code 微信授权码
     * @return 访问令牌
     */
    @GetMapping("/getAccessToken")
    public String getAccessToken(@RequestParam String code) {
        // 发送get请求获取 AccessToken
        Map<?, ?> result = WeChatUtil.getAccessToken(code);
        String accessToken = result.get(ACCESS_TOKEN).toString();
        String refreshToken = result.get(REFRESH_TOKEN).toString();
        String openid = result.get(OPEN_ID).toString();

        WeChatUser user = weChatUserService.getOne(new LambdaUpdateWrapper<WeChatUser>().eq(WeChatUser::getOpenid, openid));

        // 如果用户历史上已经完成授权
        if (user != null) {
            log.info("该用户已授权");
            return "<h1>你已经授权过啦~</h1>";
        }

        // 如果用户是第一次进行微信公众号授权
        // 进行这一步时用户应点击了同意授权按钮
        String userInfoJsom = WeChatUtil.getUserInfo(accessToken, openid);

        // 解析JSON数据
        JSONObject jsonObject = new JSONObject(userInfoJsom);

        // 设置相关实体属性
        WeChatUser weChatUser = new WeChatUser();
        weChatUser.setAccessToken(accessToken);
        weChatUser.setRefreshToken(refreshToken);
        weChatUser.setCreateDate(LocalDateTime.now());
        weChatUser.setUpdateDate(LocalDateTime.now());

        // TODO 这里需要绑定系统真实的用户id
        weChatUser.setUserId(StpUtil.getLoginIdAsLong());
        weChatUser.setCreateUser(StpUtil.getLoginIdAsLong());
        weChatUser.setUpdateUser(StpUtil.getLoginIdAsLong());
//        weChatUser.setUserId(1L);
//        weChatUser.setCreateUser(1L);
//        weChatUser.setUpdateUser(1L);

        weChatUser.setOpenid(openid);
        weChatUser.setNickname(jsonObject.getStr(NICK_NAME));
        weChatUser.setSex(jsonObject.getStr(SEX));
        weChatUser.setAvatar(jsonObject.getStr(HEAD_IMG_URL));

        // 存储用户信息
        weChatUserService.save(weChatUser);

        return SUCCESS_PAGE;
    }

    /**
     * 刷新token,微信提供的token是有限时间的,但是对于财务报销系统仅需授权一次的情况下一般不需要进行更新
     *
     * @return accessToken
     */
    @GetMapping("/refreshToken")
    public Result<String> refreshToken() {
        // TODO 这里需要绑定系统真实的用户id
        Long userId = StpUtil.getLoginIdAsLong();
        //Long userId = 1L;

        WeChatUser weChatUser = weChatUserService.getOne(new LambdaUpdateWrapper<WeChatUser>().eq(WeChatUser::getUserId, userId));
        if (weChatUser == null){
            return Result.error("error");
        }

        // 发送get请求获取 RefreshToken
        Map<?, ?> result = WeChatUtil.refreshToken(weChatUser.getRefreshToken());
        String accessToken = result.get(ACCESS_TOKEN).toString();
        String refreshToken = result.get(REFRESH_TOKEN).toString();

        // 更新用户信息
        WeChatUser weChatUserUpdate = new WeChatUser();
        weChatUserUpdate.setId(weChatUser.getId());
        weChatUserUpdate.setAccessToken(accessToken);
        weChatUserUpdate.setRefreshToken(refreshToken);
        weChatUserUpdate.setUpdateDate(LocalDateTime.now());

        // 存储数据库
        weChatUserService.updateById(weChatUserUpdate);

        return Result.success(accessToken);
    }
}

其他 - WeChatPage

这个代码就是授权成功的回调页面,在自己业务场景下可以不使用它,直接在前端实现一下授权成功的页面

public class WeChatPage {

    public static final String SUCCESS_PAGE = "<!DOCTYPE html>\n" +
            "<html lang=\"zh-CN\">\n" +
            "<head>\n" +
            "    <meta charset=\"UTF-8\">\n" +
            "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" +
            "    <title>微信登录授权成功</title>\n" +
            "    <style>\n" +
            "        body {\n" +
            "            font-family: Arial, sans-serif;\n" +
            "            background-color: #f4f4f4;\n" +
            "            margin: 0;\n" +
            "            padding: 0;\n" +
            "            display: flex;\n" +
            "            justify-content: center;\n" +
            "            align-items: center;\n" +
            "            height: 100vh;\n" +
            "        }\n" +
            "\n" +
            "        .container {\n" +
            "            text-align: center;\n" +
            "            background-color: #fff;\n" +
            "            padding: 20px;\n" +
            "            border-radius: 10px;\n" +
            "            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n" +
            "        }\n" +
            "\n" +
            "        h1 {\n" +
            "            color: #333;\n" +
            "            font-size: 24px;\n" +
            "            margin-bottom: 20px;\n" +
            "        }\n" +
            "\n" +
            "        p {\n" +
            "            color: #666;\n" +
            "            font-size: 18px;\n" +
            "            margin-bottom: 20px;\n" +
            "        }\n" +
            "\n" +
            "        img {\n" +
            "            width: 100px;\n" +
            "            height: 100px;\n" +
            "            border-radius: 50%;\n" +
            "            margin-bottom: 20px;\n" +
            "        }\n" +
            "    </style>\n" +
            "</head>\n" +
            "<body>\n" +
            "    <div class=\"container\">\n" +
            "        <h1>微信登录授权成功!</h1>\n" +
            "        <p>感谢您的支持,您可以继续浏览我们的内容</p>\n" +
            "        <p>如果您有任何问题,请随时联系我们</p>\n" +
            "    </div>\n" +
            "</body>\n" +
            "</html>\n";
}

测试

在测试之前我们还需要把前面准备阶段放下的配置接口配置信息给提交上。我们先启动后端服务,然后在配置接口配置信息填写好下面这个接口信息,比如 http:xxxx.top/wechat/verifyToken

校验成功后就可以开始测试啦!这里要注意,校验接口我们只需要在初次使用的时候校验一次就行了,如果你的域名发生了更改才需要重新校验

我们先用自己的微信扫码关注一波测试公众号: 

关注成功后我们,我们尝试完成授权。我们需要引导用户点击授权链接,这里我把授权链接放进二维码,通过扫码来实现(当然大家可以以其他方式实现),这里和校验接口一样,如果你的域名没有发生改变只需要生成一次就可以了,在控制层中是这个接口:

 我们直接发一个 GET 请求,可以获得一张二维码,具体保存路径可以通过修改 IMG_PATH 这个常量实现:

如果你的配置是完全正确的,那么当你第一次扫码授权,会得到下面结果:

如果你历史已经授权了,那么会得到下面结果: 

上面两个结果都只是模拟授权场景,具体还需要进行修改,授权成功后数据库中就能看到授权用户的信息啦~

有了用户信息我们就能根据用户信息完成其他业务了,比如后续的公众号授权登录功能等等。 

写在最后 

上面代码仅仅演示了网页授权最基础的功能,完整的流程和功能大家可以去查看官方文档的相关内容:网页授权 | 微信开放文档,因为我的业务比较简单,仅需要获取用户授权之后,根据用户信息实现微信授权登录。最后,有任何问题欢迎与我交流,期待与你共同学习进步!

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

这小鱼在乎

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值