SpringBoot实现微信登录实战:手把手教你搞定小程序登录!

内容分享1周前发布
0 0 0

作为一名资深后端开发,你有没有遇到过这样的场景:产品经理突然跑过来说:“我们小程序要支持微信一键登录,还要获取用户手机号,今天就要上线!”

别慌,今天就来手把手教你如何用SpringBoot实现微信登录,让你轻松应对产品经理的”今天就要”!

一、微信登录原理:先搞懂流程再动手

在开始编码之前,我们先来理解一下微信官方推荐的登录流程:

前端获取临时凭证:小程序调用
wx.login()
获取临时登录凭证code后端换取用户标识:后端使用code调用
auth.code2Session
接口,换取openId、unionId和session_key自定义登录状态:开发者服务器根据用户标识自定义登录状态,用于后续业务逻辑识别用户身份

这个流程看似简单,但里面有不少坑需要注意,比如:

code的有效期只有5分钟session_key不能泄露给前端AppSecret绝对不能暴露在前端代码中

二、准备工作:兵马未动,粮草先行

2.1 获取必要参数

首先,你需要在微信公众平台获取以下参数:

appId:小程序唯一标识appSecret:小程序密钥(切记不要暴露给前端!)

2.2 数据库表设计

我们需要一张用户表来存储用户信息:


CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号',
  `nickname` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '昵称',
  `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '头像',
  `open_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'OpenID',
  `union_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'UnionID',
  `gender` int DEFAULT NULL COMMENT '性别(0:未知,1:男,2:女)',
  `create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uk_open_id` (`open_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户表';

三、核心代码实现:一步步带你写

3.1 请求/响应对象定义

首先定义前端传来的请求参数:


@Data
@ApiModel("用户登录请求")
public class UserLoginRequestDto {
    
    @ApiModelProperty("微信用户昵称")
    private String nickname;
    
    @ApiModelProperty("登录临时凭证code")
    private String code;
    
    @ApiModelProperty("手机号临时凭证")
    private String phoneCode;
}

再定义返回给前端的响应对象:


@Data
@ApiModel("登录响应")
public class LoginVo {
    
    @ApiModelProperty("JWT token")
    private String token;
    
    @ApiModelProperty("用户昵称")
    private String nickname;
    
    @ApiModelProperty("用户ID")
    private Long userId;
}

3.2 微信服务接口封装

创建微信服务接口,用于调用微信API:


public interface WechatService {
    
    /**
     * 获取openid
     * @param code 临时登录凭证
     * @return openid
     */
    String getOpenid(String code);
    
    /**
     * 获取手机号
     * @param phoneCode 手机号临时凭证
     * @return 手机号
     */
    String getPhone(String phoneCode);
    
    /**
     * 获取用户信息
     * @param code 临时登录凭证
     * @return 用户信息
     */
    WechatUserInfo getUserInfo(String code);
}

实现类:


@Service
@Slf4j
public class WechatServiceImpl implements WechatService {
    
    // 登录接口
    private static final String LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session";
    
    // 获取token接口
    private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
    
    // 获取手机号接口
    private static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
    
    @Value("${wechat.app-id}")
    private String appId;
    
    @Value("${wechat.app-secret}")
    private String appSecret;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Override
    public String getOpenid(String code) {
        // 构造请求参数
        Map<String, Object> params = new HashMap<>();
        params.put("appid", appId);
        params.put("secret", appSecret);
        params.put("js_code", code);
        params.put("grant_type", "authorization_code");
        
        try {
            // 发起请求
            String response = HttpUtil.get(LOGIN_URL, params);
            JSONObject jsonObject = JSONUtil.parseObj(response);
            
            // 检查是否有错误
            if (jsonObject.containsKey("errcode") && jsonObject.getInt("errcode") != 0) {
                log.error("获取openid失败: {}", jsonObject.getStr("errmsg"));
                throw new BizException("获取用户信息失败");
            }
            
            // 缓存session_key,后续可能需要用于解密用户信息
            String sessionKey = jsonObject.getStr("session_key");
            String openId = jsonObject.getStr("openid");
            String cacheKey = "wechat:session:" + openId;
            redisTemplate.opsForValue().set(cacheKey, sessionKey, 5, TimeUnit.MINUTES);
            
            return openId;
        } catch (Exception e) {
            log.error("获取openid异常", e);
            throw new BizException("获取用户信息异常");
        }
    }
    
    @Override
    public String getPhone(String phoneCode) {
        try {
            // 获取access_token
            String accessToken = getAccessToken();
            
            // 构造请求URL
            String url = PHONE_URL + "?access_token=" + accessToken;
            
            // 构造请求参数
            Map<String, Object> params = new HashMap<>();
            params.put("code", phoneCode);
            
            // 发起请求
            String response = HttpUtil.post(url, JSONUtil.toJsonStr(params));
            JSONObject jsonObject = JSONUtil.parseObj(response);
            
            // 检查是否有错误
            if (jsonObject.getInt("errcode") != 0) {
                log.error("获取手机号失败: {}", jsonObject.getStr("errmsg"));
                throw new BizException("获取手机号失败");
            }
            
            return jsonObject.getJSONObject("phone_info").getStr("phoneNumber");
        } catch (Exception e) {
            log.error("获取手机号异常", e);
            throw new BizException("获取手机号异常");
        }
    }
    
    /**
     * 获取access_token
     * @return access_token
     */
    private String getAccessToken() {
        // 先从缓存中获取
        String cacheKey = "wechat:access_token";
        String accessToken = redisTemplate.opsForValue().get(cacheKey);
        if (StrUtil.isNotBlank(accessToken)) {
            return accessToken;
        }
        
        // 缓存中没有,重新获取
        Map<String, Object> params = new HashMap<>();
        params.put("appid", appId);
        params.put("secret", appSecret);
        params.put("grant_type", "client_credential");
        
        try {
            String response = HttpUtil.get(TOKEN_URL, params);
            JSONObject jsonObject = JSONUtil.parseObj(response);
            
            if (jsonObject.containsKey("errcode") && jsonObject.getInt("errcode") != 0) {
                log.error("获取access_token失败: {}", jsonObject.getStr("errmsg"));
                throw new BizException("获取access_token失败");
            }
            
            accessToken = jsonObject.getStr("access_token");
            // 缓存7000秒,避免token过期
            redisTemplate.opsForValue().set(cacheKey, accessToken, 7000, TimeUnit.SECONDS);
            
            return accessToken;
        } catch (Exception e) {
            log.error("获取access_token异常", e);
            throw new BizException("获取access_token异常");
        }
    }
}

3.3 用户服务实现


@Service
@Slf4j
public class UserServiceImpl implements UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private WechatService wechatService;
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    // 默认昵称前缀列表
    private static final List<String> DEFAULT_NICKNAME_PREFIX = Arrays.asList(
        "生活更美好", "大桔大利", "日富一日", "好柿开花", 
        "柿柿如意", "一椰暴富", "大柚所为", "杨梅吐气", "天生荔枝"
    );
    
    @Override
    @Transactional(rollbackFor = Exception.class)
    public LoginVo login(UserLoginRequestDto requestDto) {
        // 1. 调用微信API获取openId
        String openId = wechatService.getOpenid(requestDto.getCode());
        log.info("获取到用户openId: {}", openId);
        
        // 2. 根据openId查询用户
        User user = userMapper.selectByOpenId(openId);
        
        // 3. 如果用户不存在,则创建新用户
        if (user == null) {
            user = new User();
            user.setOpenId(openId);
            user.setNickname(generateDefaultNickname());
            user.setCreateTime(new Date());
        }
        
        // 4. 获取用户手机号(如果提供了phoneCode)
        if (StrUtil.isNotBlank(requestDto.getPhoneCode())) {
            try {
                String phone = wechatService.getPhone(requestDto.getPhoneCode());
                user.setPhone(phone);
            } catch (Exception e) {
                log.warn("获取用户手机号失败,使用默认处理", e);
            }
        }
        
        // 5. 更新用户昵称(如果提供了)
        if (StrUtil.isNotBlank(requestDto.getNickname())) {
            user.setNickname(requestDto.getNickname());
        }
        
        // 6. 保存或更新用户信息
        if (user.getId() == null) {
            userMapper.insert(user);
        } else {
            userMapper.updateById(user);
        }
        
        // 7. 生成JWT token
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("nickname", user.getNickname());
        String token = jwtTokenUtil.generateToken(claims);
        
        // 8. 构造返回结果
        LoginVo loginVo = new LoginVo();
        loginVo.setToken(token);
        loginVo.setNickname(user.getNickname());
        loginVo.setUserId(user.getId());
        
        return loginVo;
    }
    
    /**
     * 生成默认昵称
     * @return 默认昵称
     */
    private String generateDefaultNickname() {
        int randomIndex = new Random().nextInt(DEFAULT_NICKNAME_PREFIX.size());
        String prefix = DEFAULT_NICKNAME_PREFIX.get(randomIndex);
        String suffix = String.valueOf(System.currentTimeMillis() % 10000);
        return prefix + suffix;
    }
}

3.4 控制器实现


@RestController
@RequestMapping("/api/user")
@Api(tags = "用户相关接口")
@Slf4j
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @PostMapping("/login")
    @ApiOperation("微信小程序登录")
    public Result<LoginVo> login(@RequestBody @Valid UserLoginRequestDto requestDto) {
        try {
            LoginVo loginVo = userService.login(requestDto);
            return Result.success(loginVo);
        } catch (Exception e) {
            log.error("用户登录异常", e);
            return Result.error("登录失败,请稍后重试");
        }
    }
}

四、前端调用示例

在小程序端,我们需要这样调用:


// 登录按钮点击事件
login() {
  wx.login({
    success: (res) => {
      if (res.code) {
        // 获取用户信息
        wx.getUserProfile({
          desc: '用于完善会员资料',
          success: (userInfoRes) => {
            // 获取手机号
            wx.request({
              url: 'https://your-domain.com/api/user/login',
              method: 'POST',
              data: {
                code: res.code,
                nickname: userInfoRes.userInfo.nickName,
                phoneCode: '' // 如果需要获取手机号,这里传手机号的code
              },
              success: (loginRes) => {
                if (loginRes.data.code === 200) {
                  // 保存token到本地存储
                  wx.setStorageSync('token', loginRes.data.data.token);
                  // 跳转到首页
                  wx.switchTab({
                    url: '/pages/index/index'
                  });
                } else {
                  wx.showToast({
                    title: '登录失败',
                    icon: 'none'
                  });
                }
              }
            });
          }
        });
      } else {
        console.log('登录失败!' + res.errMsg);
      }
    }
  });
}

五、安全注意事项

5.1 敏感信息保护

AppSecret绝不能暴露给前端session_key不能传给小程序端使用HTTPS传输所有敏感数据

5.2 Token安全


@Component
public class JwtTokenUtil {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.expiration}")
    private Long expiration;
    
    /**
     * 生成token
     */
    public String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000);
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    
    /**
     * 验证token
     */
    public Boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    
    /**
     * 获取token中的用户ID
     */
    public Long getUserIdFromToken(String token) {
        try {
            Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
            return Long.valueOf(claims.get("userId").toString());
        } catch (Exception e) {
            return null;
        }
    }
}

5.3 接口安全拦截


@Component
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
    
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从header中获取token
        String token = request.getHeader("Authorization");
        if (StrUtil.isBlank(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        
        // 验证token
        if (!jwtTokenUtil.validateToken(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        
        return true;
    }
}

六、常见问题及解决方案

6.1 code失效问题


// 在获取openid时处理code失效的情况
if (jsonObject.getInt("errcode") == 40029) {
    log.warn("code已失效,请重新登录");
    throw new BizException("登录凭证已过期,请重新登录");
}

6.2 token过期处理


// 在拦截器中处理token过期
if (jsonObject.getInt("errcode") == 40001) {
    log.warn("access_token已过期,需要重新获取");
    // 清除缓存中的token,下次请求会重新获取
    redisTemplate.delete("wechat:access_token");
}

七、总结

通过以上步骤,我们就完成了一个完整的微信登录功能实现。整个流程的关键点包括:

理解微信登录流程:掌握code、openId、session_key的作用和关系安全编码:保护敏感信息,使用HTTPS传输异常处理:妥善处理各种异常情况用户体验:提供友好的错误提示和默认值

记住,微信登录只是用户认证的第一步,后续还需要考虑用户权限管理、数据安全等更多问题。但只要掌握了这个基础,后续的开发就会顺畅很多。

希望今天的分享能帮助你在下次面对产品经理的”今天就要”时,能够从容应对!

在实际项目中,建议根据具体业务需求进行调整,比如添加更多的用户信息字段、完善错误处理机制、增加日志记录等。只有在实践中不断优化,才能写出更加健壮的代码。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...