作為一名資深後端開發,你有沒有遇到過這樣的場景:產品經理突然跑過來説:"我們小程序要支持微信一鍵登錄,還要獲取用户手機號,今天就要上線!"
別慌,今天就來手把手教你如何用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傳輸
- 異常處理:妥善處理各種異常情況
- 用户體驗:提供友好的錯誤提示和默認值
記住,微信登錄只是用户認證的第一步,後續還需要考慮用户權限管理、數據安全等更多問題。但只要掌握了這個基礎,後續的開發就會順暢很多。
希望今天的分享能幫助你在下次面對產品經理的"今天就要"時,能夠從容應對!
在實際項目中,建議根據具體業務需求進行調整,比如添加更多的用户信息字段、完善錯誤處理機制、增加日誌記錄等。只有在實踐中不斷優化,才能寫出更加健壯的代碼。