微信手机号登录
🍚 SpringBoot 🎯 2026-05-10 🔥 4

数据准备

注意

在支付时会用到openid,所以需要在用户注册时就加上

请求VO

package com.fan.model.login.vo;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class UserLoginReqVo {

    /**
     * JsCode(开发接口wx.login获得)
     */
    @NotBlank(message = "JsCode不能为空")
    private String jsCode;

    /**
     * 动态令牌(open-type="getPhoneNumber")获得
     */
    @NotBlank(message = "动态令牌不能为空")
    private String code;

}

响应Vo

package com.fan.model.login.vo;

import lombok.Data;

@Data
public class UserLoginRespVo {

    /**
     * Token
     */
    private String token;

}

缓存Vo

package com.fan.model.login.vo;

import lombok.Data;

/**
 * 缓存用户信息
 */
@Data
public class CacheUserVo {

    /**
     * ID
     */
    private Long id;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 微信openId
     */
    private String openId;

}

接口

package com.fan.model.login;

import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fan.entity.TbUser;
import com.fan.entity.vo.ComResult;
import com.fan.enumEntity.RedisKeyEnum;
import com.fan.model.login.vo.CacheUserVo;
import com.fan.model.login.vo.UserLoginReqVo;
import com.fan.model.login.vo.UserLoginRespVo;
import com.fan.model.tbUser.TbUserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.yulichang.query.MPJLambdaQueryWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 登录
 */
@Slf4j
@RestController
public class UserLoginController {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    // appId
    private final String appId = "xxxx";
    // AppSecret
    private final String secret = "xxxx";

    @Resource
    private TbUserService tbUserService;

    /**
     * 登录
     */
    @PostMapping("/login")
    public ComResult<UserLoginRespVo> login(@RequestBody @Validated UserLoginReqVo params) {
        log.info("授权请求参数:{}", params);
        // 获取微信凭证
        String wxToken = getWxToken();
        // 获取手机号
        String phone = getPhone(wxToken, params.getCode());
        // 查询用户是否已经注册
        MPJLambdaQueryWrapper<TbUser> wrapper = new MPJLambdaQueryWrapper<>();
        wrapper.selectAll(TbUser.class);
        wrapper.eq(TbUser::getPhone, phone);
        wrapper.last("limit 1");
        TbUser user = tbUserService.selectJoinOne(TbUser.class, wrapper);
        // 构建返回数据
        String token = String.valueOf(IdWorker.getId());
        UserLoginRespVo respVo = new UserLoginRespVo();
        respVo.setToken(token);
        // 缓存用户信息
        CacheUserVo cacheUserVo = new CacheUserVo();
        String cacheJson = "{}";
        if (Objects.nonNull(user)) {
            // 用户存在,缓存登录信息
            cacheUserVo.setId(user.getId());
            cacheUserVo.setPhone(user.getPhone());
            cacheUserVo.setOpenId(user.getOpenId());
        } else {
            // 用户不存在,创建用户,缓存登录信息
            // 获取openId,支付会用到
            String openId = openId(params.getJsCode());
            log.info("用户授权openId:{}", openId);
            if (Objects.nonNull(phone) && Objects.nonNull(openId)) {
                // 注册用户
                TbUser tbUser = new TbUser();
                tbUser.setPhone(phone);
                tbUser.setOpenId(openId);
                tbUser.setName("请填写姓名");
                boolean save = tbUserService.save(tbUser);
                if (save) {
                    cacheUserVo.setId(tbUser.getId());
                    cacheUserVo.setPhone(tbUser.getPhone());
                    cacheUserVo.setOpenId(tbUser.getOpenId());
                } else {
                    return ComResult.failMsg("登录失败");
                }
            }
        }
        // 将用户信息缓存到redis中
        try {
            cacheJson = new ObjectMapper().writeValueAsString(cacheUserVo);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        redisTemplate.opsForValue().set(RedisKeyEnum.KEY_APP_USER.value() + token, cacheJson, 30, TimeUnit.DAYS);
        return ComResult.data(respVo);
    }

    /**
     * 获取微信凭证
     * 小程序全局后台接口调用凭据,有效期最长为7200s,开发者需要进行妥善保存
     * 该接口调用频率限制为1万次每分钟,每天限制调用50万次
     * <a href="https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/mp-access-token/getStableAccessToken.html"/>
     */
    private String getWxToken() {
        // 优先从缓存读取
        String accessToken = redisTemplate.opsForValue().get(RedisKeyEnum.KEY_WX_ACCESS_TOKEN.value());
        if (Objects.isNull(accessToken)) {
            // 接口地址
            String url = "https://api.weixin.qq.com/cgi-bin/stable_token";
            // 请求参数
            Map<String, String> reqMap = new HashMap<>();
            reqMap.put("grant_type", "client_credential");
            reqMap.put("appid", appId);
            reqMap.put("secret", secret);
            // 请求参数转JSON字符串
            String reqJson;
            try {
                reqJson = new ObjectMapper().writeValueAsString(reqMap);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
            log.debug("获取微信凭证请求参数:{}", reqJson);
            // REST请求
            RestTemplate restTemplate = new RestTemplate();
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.parseMediaType("application/json; charset=UTF-8"));
            headers.add("Accept", MediaType.APPLICATION_JSON.toString());
            HttpEntity<String> formEntity = new HttpEntity<>(reqJson, headers);
            String response = restTemplate.postForEntity(url, formEntity, String.class).getBody();
            log.debug("获取微信凭证返回数据:{}", response);
            if (Objects.isNull(response)) {
                throw new RuntimeException("获取微信凭证失败");
            }
            // 解析返回数据
            Map<String, Object> respData;
            try {
                respData = new ObjectMapper().readValue(response, new TypeReference<>() {
                });
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
            accessToken = respData.get("access_token").toString();
            log.debug("微信凭证:{}", accessToken);
            if (Objects.isNull(accessToken)) {
                throw new RuntimeException("解析微信凭证失败");
            }
            // 缓存
            redisTemplate.opsForValue().set(RedisKeyEnum.KEY_WX_ACCESS_TOKEN.value(), accessToken, 7000, TimeUnit.SECONDS);
        }
        return accessToken;
    }

    /**
     * 根据动态令牌获取手机号
     * 每个code只能使用一次,code的有效期为5min
     * <a href="https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html"/>
     */
    private String getPhone(String accessToken, String code) {
        // 接口地址
        String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
        // 请求参数
        Map<String, String> reqMap = new HashMap<>();
        reqMap.put("code", code);
        // 请求参数转JSON字符串
        String reqJson;
        try {
            reqJson = new ObjectMapper().writeValueAsString(reqMap);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        log.debug("微信获取手机号请求参数:{}", reqJson);
        // REST请求
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        MediaType type = MediaType.parseMediaType("application/json; charset=UTF-8");
        headers.setContentType(type);
        headers.add("Accept", MediaType.APPLICATION_JSON.toString());
        HttpEntity<String> formEntity = new HttpEntity<>(reqJson, headers);
        String response = restTemplate.postForEntity(url, formEntity, String.class).getBody();
        log.debug("微信获取手机号返回数据:{}", response);
        if (Objects.isNull(response)) {
            throw new RuntimeException("根据code获取手机号失败");
        }
        // 解析返回数据
        Map<String, Object> respData;
        try {
            respData = new ObjectMapper().readValue(response, new TypeReference<>() {
            });
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        log.debug("解析返回数据:{}", respData.toString());
        if (!Objects.equals(respData.get("errcode"), 0)) {
            throw new RuntimeException("根据code获取手机号失败,状态码错误");
        }
        // 手机号
        Object phoneInfo = respData.get("phone_info");
        if (Objects.isNull(phoneInfo)) {
            throw new RuntimeException("phone_info为空");
        }
        log.info("根据code获取手机号,phone_info:{}", phoneInfo);
        // 解析phone_info
        Map<String, Object> phoneInfoMap = new ObjectMapper().convertValue(phoneInfo, new TypeReference<>() {
        });
        // purePhoneNumber没有区号的手机号
        // phoneNumber用户绑定的手机号(国外手机号会有区号)
        String phoneNumber = phoneInfoMap.get("purePhoneNumber").toString();
        log.debug("根据code获取手机号:{}", phoneNumber);
        if (Objects.isNull(phoneNumber)) {
            throw new RuntimeException("根据code获取手机号无法解析");
        }
        return phoneNumber;
    }

    /**
     * 从微信获取openid
     * <a href="https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html"/>
     */
    private String openId(String code) {
        // 接口地址
        String url = "https://api.weixin.qq.com/sns/jscode2session";
        url += "?appid=" + appId;
        url += "&secret=" + secret;
        url += "&grant_type=authorization_code";
        url += "&js_code=" + code;
        log.info("微信获取openid:{}", url);
        // REST请求
        RestTemplate restTemplate = new RestTemplate();
        String response = restTemplate.getForObject(url, String.class);
        log.info("微信获取openid返回数据:{}", response);
        Map<String, Object> respData;
        try {
            respData = new ObjectMapper().readValue(response, new TypeReference<>() {
            });
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        return respData.get("openid").toString();
    }

}

前端uniapp写法

<template>
  <scroll-view class="org-container" scroll-y enhanced :show-scrollbar="false">
    <view class="container">
      <view class="com-header">
        <wd-navbar fixed placeholder title="授权" left-arrow safeAreaInsetTop @click-left="handleClickLeft"></wd-navbar>
      </view>
      <view class="login-box">
        <view class="icon">
          <wd-icon name="user" color="#1c6b4f" size="40px"/>
        </view>
        <view class="text">
          亲爱的用户,为了给你提供完整的服务,需要您授权手机号进行数据绑定,我们承诺严格保护您的个人信息,严格遵守
          <text @click="gotoPage('/pages/userService/userService')">《用户协议》</text><text @click="gotoPage('/pages/userPrivacy/userPrivacy')">《隐私协议》</text></view>
        <button open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">立即授权</button>
      </view>
    </view>
    <wd-toast />
  </scroll-view>
</template>

<script setup>
  import {
    reactive,
    ref
  } from 'vue';

  import httpUtil from "../../common/httpUtil.js"
  import {
    useToast
  } from '@/uni_modules/wot-design-uni'

  const toast = useToast()

  // 返回
  function handleClickLeft() {
    uni.navigateBack()
  }
  // 跳转
  function gotoPage(url) {
    uni.navigateTo({
      url: url
    })
  }

  // 获取手机号
  function getPhoneNumber(e) {
    // 如果拿到动态令牌
    if (e.detail.code) {
      // 进行微信开发接口的登录操作,拿到js_code
      wx.login({
        success(res) {
          if (res.code) {
            // 发起登录
            toast.loading('授权中...')
            httpUtil('/login', 'POST', {
              'jsCode': res.code,
              'code': e.detail.code
            }, (res) => {
              if (res.code == 200) {
                uni.setStorageSync('token', res.data.token)
                toast.success('登录成功')
                uni.navigateTo({
                  url: '/pages/index/index'
                })
              } else {
                toast.error(res.msg)
              }
            }, (err) => {
              toast.error('登录异常')
            })
          } else {
            toast.error('登录异常')
          }
        }
      })
    }
  }
</script>

<style lang="scss">
  .org-container {
    height: 100vh;
    overflow: auto;
  }

  .container {
    background: url('https://tombstone.oss-cn-chengdu.aliyuncs.com/app/common_bg_1.png');
    width: 100%;
    min-height: 100vh;
    background-repeat: no-repeat;
    background-size: cover;
    background-attachment: fixed;
    box-sizing: border-box;
    padding: 20rpx 32rpx 88rpx 32rpx;
  }

  // 导航
  .wd-navbar {
    background: url('https://tombstone.oss-cn-chengdu.aliyuncs.com/app/common_bg_1.png');
    background-repeat: no-repeat;
    background-size: cover;
    background-attachment: fixed;
    // border-bottom: 1px solid #B5925E;
  }

  .login-box {
    padding-top: 128rpx;
    
    .icon {
      text-align: center;
      margin-bottom: 80rpx;
    }

    .text {
      font-size: 30rpx;
      text-indent: 2em;
      line-height: 2;
      margin-bottom: 128rpx;

      text {
        color: blue;
      }
    }

    button {
      background-color: #4d80f0;
      color: #fff;
      font-size: 28rpx;
    }
  }
</style>

附用户协议

userService.vue

<!-- 用户协议 -->
<template>
  <scroll-view class="org-container" scroll-y enhanced :show-scrollbar="false">
    <!-- 如果样式设置在全局,不需要背景的不使用class="com-container" -->
    <view class="com-container">
      <!-- placeholder:固定在顶部时,是否生成一个等高元素,以防止塌陷 -->
      <up-navbar title="用户协议" :placeholder="true" autoBack>
      </up-navbar>

      <view class="container">
        <view>尊敬的用户,欢迎使用我们的小程序。为了保护您的个人信息,我们制定了本隐私政策。</view>

        <view class="title">一、信息收集和使用</view>
        <view>1. 我们可能会收集您提供的个人信息,用于向您提供小程序的相关功能和服务。</view>
        <view>2. 我们承诺在未经您同意的情况下,不会将您的个人信息提供给任何第三方。</view>

        <view class="title">二、信息保护</view>
        <view>1.我们采取合理的安全措施保护您的个人信息,防止数据的丢失、泄漏、被盗用等风险。</view>
        <view>2. 在合理的范围内,我们会采取措施保证您的个人信息的准确性和完整性。</view>
        <view>3. 您应当保证提供的个人信息真实、准确,并及时更新。</view>

        <view class="title">三、信息存储和处理</view>
        <view>1. 我们可能会将收集的个人信息存储在本小程序所在国家/地区或者其他相关法律允许的地方。</view>
        <view>2. 我们仅在为实现本隐私政策中描述的目的所必需的期间内保留您的个人信息。</view>

        <view class="title">四、未成年人信息保护</view>
        <view>我们非常重视对未成年人个人信息的保护。如果您是未满18周岁的未成年人,请在监护人的陪同下使用小程序,并请不要向我们提供任何个人信息。</view>

        <view class="title">五、协议修改和通知</view>
        <view>1. 我们有权根据需要修改本隐私政策内容,并通过小程序公告或其他方式通知您。</view>
        <view>2. 如您不同意修改后的隐私政策内容,您可以停止使用小程序。如果您继续使用小程序,则视为您接受修改后的隐私政策。</view>

        <view style="margin-top: 24rpx;">感谢您的阅读,祝您使用愉快!</view>
      </view>

    </view>
  </scroll-view>
</template>

<script setup>

</script>

<style lang="scss">
  // 背景图片
  $container-bg-url: 'https://xs-face.oss-cn-shanghai.aliyuncs.com/202601/2008440539537674241_61252.png';

  .com-container {
    background: url($container-bg-url);
    width: 100%;
    min-height: 100vh;
    background-repeat: no-repeat;
    background-size: cover;
    background-attachment: fixed;
    box-sizing: border-box;
    padding: 16rpx 32rpx 24rpx 32rpx;

    // 导航栏默认背景替换
    .u-navbar--fixed {
      background: url($container-bg-url);
      background-repeat: no-repeat;
      background-size: cover;
      background-attachment: fixed;

      .u-navbar__content,
      .u-status-bar {
        background-color: transparent !important;
      }
    }
  }


  .container {
    padding: 12rpx 0 88rpx 0;

    view {
      font-size: 28rpx;
      color: #555;
      line-height: 1.7;
    }

    .title {
      font-weight: bold;
      margin: 16rpx 0 12rpx 0;
    }
  }
</style>

附隐私政策

userPrivacy.vue

<!-- 隐私政策 -->
<template>
  <scroll-view class="org-container" scroll-y enhanced :show-scrollbar="false">
    <!-- 如果样式设置在全局,不需要背景的不使用class="com-container" -->
    <view class="com-container">
      <!-- placeholder:固定在顶部时,是否生成一个等高元素,以防止塌陷 -->
      <up-navbar title="隐私政策" :placeholder="true" autoBack>
      </up-navbar>

      <view class="container">
        <view>尊敬的用户,欢迎使用我们的小程序。为了保护您的个人信息,我们制定了本隐私政策。</view>

        <view class="title">一、信息收集和使用</view>
        <view>1. 我们可能会收集您提供的个人信息,用于向您提供小程序的相关功能和服务。</view>
        <view>2. 我们承诺在未经您同意的情况下,不会将您的个人信息提供给任何第三方。</view>

        <view class="title">二、信息保护</view>
        <view>1.我们采取合理的安全措施保护您的个人信息,防止数据的丢失、泄漏、被盗用等风险。</view>
        <view>2. 在合理的范围内,我们会采取措施保证您的个人信息的准确性和完整性。</view>
        <view>3. 您应当保证提供的个人信息真实、准确,并及时更新。</view>

        <view class="title">三、信息存储和处理</view>
        <view>1. 我们可能会将收集的个人信息存储在本小程序所在国家/地区或者其他相关法律允许的地方。</view>
        <view>2. 我们仅在为实现本隐私政策中描述的目的所必需的期间内保留您的个人信息。</view>

        <view class="title">四、未成年人信息保护</view>
        <view>我们非常重视对未成年人个人信息的保护。如果您是未满18周岁的未成年人,请在监护人的陪同下使用小程序,并请不要向我们提供任何个人信息。</view>

        <view class="title">五、协议修改和通知</view>
        <view>1. 我们有权根据需要修改本隐私政策内容,并通过小程序公告或其他方式通知您。</view>
        <view>2. 如您不同意修改后的隐私政策内容,您可以停止使用小程序。如果您继续使用小程序,则视为您接受修改后的隐私政策。</view>

        <view class="title">六、联系我们</view>
        <view>如您对本隐私政策有任何疑问或意见,请通过以下方式与我们联系:</view>
        <view>邮箱:979398409@qq.com</view>


        <view style="margin-top: 24rpx;">感谢您的阅读,祝您使用愉快!</view>
      </view>

    </view>
  </scroll-view>
</template>

<script setup>

</script>

<style lang="scss">
  // 背景图片
  $container-bg-url: 'https://xs-face.oss-cn-shanghai.aliyuncs.com/202601/2008440539537674241_61252.png';

  .com-container {
    background: url($container-bg-url);
    width: 100%;
    min-height: 100vh;
    background-repeat: no-repeat;
    background-size: cover;
    background-attachment: fixed;
    box-sizing: border-box;
    padding: 16rpx 32rpx 24rpx 32rpx;

    // 导航栏默认背景替换
    .u-navbar--fixed {
      background: url($container-bg-url);
      background-repeat: no-repeat;
      background-size: cover;
      background-attachment: fixed;

      .u-navbar__content,
      .u-status-bar {
        background-color: transparent !important;
      }
    }
  }


  .container {
    padding: 12rpx 0 88rpx 0;

    view {
      font-size: 28rpx;
      color: #555;
      line-height: 1.7;
    }

    .title {
      font-weight: bold;
      margin: 16rpx 0 12rpx 0;
    }
  }
</style>

附拦截器

package com.fan.config;

import com.fan.entity.vo.ComResult;
import com.fan.enumEntity.RedisKeyEnum;
import com.fan.model.login.vo.CacheUserVo;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.common.lang.NonNullApi;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
 * 请求拦截
 */
@NonNullApi
@Slf4j
public class ReqInterceptor implements HandlerInterceptor {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        // 获取请求头的token
        String token = request.getHeader("token");
        if (Objects.isNull(token)) {
            deal(request, response, "未携带Token", ComResult.CODE_FAIL);
            return false;
        }
        // 从缓存获取用户数据
        String userData = redisTemplate.opsForValue().get(RedisKeyEnum.KEY_APP_USER.value() + token);
        if (Objects.isNull(userData)) {
            deal(request, response, "未登录", ComResult.CODE_UNAUTHORIZED);
            return false;
        }
        CacheUserVo user = null;
        try {
            user = new ObjectMapper().readValue(userData, CacheUserVo.class);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        if (Objects.isNull(user)) {
            deal(request, response, "账号错误", ComResult.CODE_UNAUTHORIZED);
            return false;
        }
        // 在请求中放入用户信息
        request.setAttribute("appUserId", user.getId());
        request.setAttribute("appUserPhone", user.getPhone());
        request.setAttribute("appUserOpenId", user.getOpenId());
        return true;
    }

    /**
     * 无Token或未授权响应处理
     */
    private void deal(HttpServletRequest request, HttpServletResponse response, String text, Integer code) throws IOException {
        // 没有权限不放行并设置一个状态码给前端做判断
        response.setStatus(200);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        ComResult<String> result = new ComResult<>();
        result.setCode(code);
        result.setMsg(text);
        log.info("请求未达响应:{} --- {}", request.getRequestURI(), result);
        // 使用字节流输出
        try (ServletOutputStream out = response.getOutputStream()) {
            byte[] jsonBytes = new ObjectMapper().writeValueAsString(result).getBytes(StandardCharsets.UTF_8);
            out.write(jsonBytes);
        }
    }

}

附拦截器注册

package com.fan.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 拦截器注册
 */
@Configuration
public class Interceptor implements WebMvcConfigurer {

    @Bean
    ReqInterceptor reqInterceptor() {
        return new ReqInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(reqInterceptor()).addPathPatterns("/**")
                .excludePathPatterns("/login");

    }

}

👨‍💻自述
踏实走好脚下的路,热爱生活,坚持学习,怀揣的理想,终有一天会实现
🏝️目录