作為一名資深後端開發,你有沒有遇到過這樣的場景:產品經理突然跑過來説:"我們小程序要支持微信一鍵登錄,還要獲取用户手機號,今天就要上線!"

別慌,今天就來手把手教你如何用SpringBoot實現微信登錄,讓你輕鬆應對產品經理的"今天就要"!

一、微信登錄原理:先搞懂流程再動手

在開始編碼之前,我們先來理解一下微信官方推薦的登錄流程:

  1. 前端獲取臨時憑證:小程序調用wx.login()獲取臨時登錄憑證code
  2. 後端換取用户標識:後端使用code調用auth.code2Session接口,換取openId、unionId和session_key
  3. 自定義登錄狀態:開發者服務器根據用户標識自定義登錄狀態,用於後續業務邏輯識別用户身份

這個流程看似簡單,但裏面有不少坑需要注意,比如:

  • 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 敏感信息保護

  1. AppSecret絕不能暴露給前端
  2. session_key不能傳給小程序端
  3. 使用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");
}

七、總結

通過以上步驟,我們就完成了一個完整的微信登錄功能實現。整個流程的關鍵點包括:

  1. 理解微信登錄流程:掌握code、openId、session_key的作用和關係
  2. 安全編碼:保護敏感信息,使用HTTPS傳輸
  3. 異常處理:妥善處理各種異常情況
  4. 用户體驗:提供友好的錯誤提示和默認值

記住,微信登錄只是用户認證的第一步,後續還需要考慮用户權限管理、數據安全等更多問題。但只要掌握了這個基礎,後續的開發就會順暢很多。

希望今天的分享能幫助你在下次面對產品經理的"今天就要"時,能夠從容應對!

在實際項目中,建議根據具體業務需求進行調整,比如添加更多的用户信息字段、完善錯誤處理機制、增加日誌記錄等。只有在實踐中不斷優化,才能寫出更加健壯的代碼。