微信小程序登录是开发现代移动应用时最常见的需求之一。作为一名有多年Spring Boot开发经验的工程师,我想分享一下如何高效、安全地实现微信登录功能。这个方案已经在多个生产环境中验证过,能够稳定处理高并发场景。
在开始编码前,我们需要理解几个关键概念:
注意:获取unionId需要小程序已绑定到微信开放平台账号,否则只能获取openId
微信官方推荐的登录流程分为以下几个步骤:
这个流程看似简单,但在实际开发中有许多需要注意的细节和优化点。
在微信公众平台获取必要的配置信息:
properties复制# application.yml配置示例
wechat:
appId: wx1234567890abcdef # 替换为你的AppID
appSecret: 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p # 替换为你的AppSecret
确保pom.xml中包含必要的依赖:
xml复制<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Hutool工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
建议使用以下表结构存储用户信息:
sql复制CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`open_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '微信openId',
`union_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '微信unionId',
`nickname` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '昵称',
`avatar` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '头像URL',
`phone` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号',
`gender` tinyint DEFAULT '0' COMMENT '性别(0-未知 1-男 2-女)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_openid` (`open_id`) COMMENT 'openId唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户表';
java复制@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String openId;
private String unionId;
private String nickname;
private String avatar;
private String phone;
private Integer gender;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
我们首先封装微信接口调用服务:
java复制public interface WeChatService {
/**
* 获取openId和session_key
* @param code 登录凭证
* @return Map包含openid和session_key
*/
Map<String, String> getSessionInfo(String code);
/**
* 获取微信access_token
* @return access_token
*/
String getAccessToken();
/**
* 解密获取用户手机号
* @param encryptedData 加密数据
* @param iv 加密算法的初始向量
* @param sessionKey 会话密钥
* @return 手机号
*/
String getPhoneNumber(String encryptedData, String iv, String sessionKey);
}
实现类:
java复制@Service
@Slf4j
public class WeChatServiceImpl implements WeChatService {
@Value("${wechat.appId}")
private String appId;
@Value("${wechat.appSecret}")
private String appSecret;
private static final String JSCODE2SESSION_URL =
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code";
private static final String ACCESS_TOKEN_URL =
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
@Override
public Map<String, String> getSessionInfo(String code) {
String url = String.format(JSCODE2SESSION_URL, appId, appSecret, code);
String response = HttpUtil.get(url);
JSONObject json = JSONUtil.parseObj(response);
if (json.containsKey("errcode")) {
log.error("获取session失败: {}", response);
throw new RuntimeException("微信登录失败: " + json.getStr("errmsg"));
}
Map<String, String> result = new HashMap<>();
result.put("openid", json.getStr("openid"));
result.put("session_key", json.getStr("session_key"));
if (json.containsKey("unionid")) {
result.put("unionid", json.getStr("unionid"));
}
return result;
}
@Override
public String getAccessToken() {
String url = String.format(ACCESS_TOKEN_URL, appId, appSecret);
String response = HttpUtil.get(url);
JSONObject json = JSONUtil.parseObj(response);
if (json.containsKey("errcode")) {
log.error("获取access_token失败: {}", response);
throw new RuntimeException("获取access_token失败: " + json.getStr("errmsg"));
}
return json.getStr("access_token");
}
@Override
public String getPhoneNumber(String encryptedData, String iv, String sessionKey) {
byte[] encryptedDataBytes = Base64.decode(encryptedData);
byte[] ivBytes = Base64.decode(iv);
byte[] sessionKeyBytes = Base64.decode(sessionKey);
try {
byte[] result = decrypt(encryptedDataBytes, sessionKeyBytes, ivBytes);
JSONObject json = JSONUtil.parseObj(new String(result));
return json.getJSONObject("phone_info").getStr("phoneNumber");
} catch (Exception e) {
log.error("解密手机号失败", e);
throw new RuntimeException("解密手机号失败");
}
}
private byte[] decrypt(byte[] encryptedData, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
return cipher.doFinal(encryptedData);
}
}
java复制@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final WeChatService weChatService;
private final UserService userService;
private final TokenService tokenService;
@PostMapping("/login")
public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) {
// 1. 获取session信息
Map<String, String> sessionInfo = weChatService.getSessionInfo(dto.getCode());
// 2. 查询或创建用户
User user = userService.findOrCreateUser(
sessionInfo.get("openid"),
sessionInfo.get("unionid")
);
// 3. 如果需要获取手机号
if (StringUtils.isNotBlank(dto.getEncryptedData()) &&
StringUtils.isNotBlank(dto.getIv())) {
String phone = weChatService.getPhoneNumber(
dto.getEncryptedData(),
dto.getIv(),
sessionInfo.get("session_key")
);
user.setPhone(phone);
userService.updateById(user);
}
// 4. 生成token
String token = tokenService.generateToken(user.getId());
// 5. 返回结果
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setUserInfo(userService.convertToVO(user));
return Result.success(vo);
}
}
实现JWT token的生成与验证:
java复制@Service
@Slf4j
public class TokenService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private long expire;
public String generateToken(Long userId) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception e) {
log.warn("token验证失败: {}", e.getMessage());
return false;
}
}
}
微信登录接口容易被刷,建议增加以下防护措施:
java复制@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final RedisTemplate<String, String> redisTemplate;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = "rate_limit:" + RequestUtil.getClientIP();
long count = redisTemplate.opsForValue().increment(key, 1);
if (count == 1) {
redisTemplate.expire(key, rateLimit.time(), TimeUnit.SECONDS);
}
if (count > rateLimit.count()) {
throw new RuntimeException("请求过于频繁,请稍后再试");
}
return joinPoint.proceed();
}
}
微信的session_key有过期时间(约30分钟),建议:
java复制public class SessionManager {
private final RedisTemplate<String, Object> redisTemplate;
public void saveSession(String openid, String sessionKey) {
String key = "wechat:session:" + openid;
redisTemplate.opsForValue().set(key, sessionKey, 30, TimeUnit.MINUTES);
}
public String getSession(String openid) {
String key = "wechat:session:" + openid;
return (String) redisTemplate.opsForValue().get(key);
}
public void refreshSession(String openid) {
String key = "wechat:session:" + openid;
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
}
}
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 获取openId失败 | code已使用或过期 | 确保每次使用新code,有效期5分钟 |
| 解密手机号失败 | session_key不匹配 | 确保使用获取手机号时的session_key |
| access_token无效 | 超过有效期(2小时)或重复获取 | 实现access_token缓存机制 |
用户信息不同步问题:
多端登录问题:
token失效处理:
java复制@Async
public void asyncRecordLoginLog(Long userId, String ip) {
LoginLog log = new LoginLog();
log.setUserId(userId);
log.setLoginIp(ip);
log.setLoginTime(LocalDateTime.now());
loginLogMapper.insert(log);
}
在实际项目中,微信登录看似简单,但要做好需要考虑很多细节。我在多个项目中实践发现,良好的错误处理和日志记录对后期维护至关重要。建议对每个微信接口调用都做好状态记录和异常捕获,这样在出现问题时可以快速定位。