作为一名资深后端开发,你有没有遇到过这样的场景:产品经理突然跑过来说:“我们小程序要支持微信一键登录,还要获取用户手机号,今天就要上线!”
别慌,今天就来手把手教你如何用SpringBoot实现微信登录,让你轻松应对产品经理的”今天就要”!
一、微信登录原理:先搞懂流程再动手
在开始编码之前,我们先来理解一下微信官方推荐的登录流程:
前端获取临时凭证:小程序调用获取临时登录凭证code后端换取用户标识:后端使用code调用
wx.login()接口,换取openId、unionId和session_key自定义登录状态:开发者服务器根据用户标识自定义登录状态,用于后续业务逻辑识别用户身份
auth.code2Session
这个流程看似简单,但里面有不少坑需要注意,比如:
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传输异常处理:妥善处理各种异常情况用户体验:提供友好的错误提示和默认值
记住,微信登录只是用户认证的第一步,后续还需要考虑用户权限管理、数据安全等更多问题。但只要掌握了这个基础,后续的开发就会顺畅很多。
希望今天的分享能帮助你在下次面对产品经理的”今天就要”时,能够从容应对!
在实际项目中,建议根据具体业务需求进行调整,比如添加更多的用户信息字段、完善错误处理机制、增加日志记录等。只有在实践中不断优化,才能写出更加健壮的代码。


