Files
youlai-app/README-wechat-login.md
2025-05-29 12:41:21 +08:00

19 KiB
Raw Blame History

微信登录实现指南

本文档提供基于 wx-java-sdk 的微信授权登录后端实现方案,包括会话管理和接口设计。

技术方案概述

微信登录流程采用基于会话管理的方式,避免每次都从微信服务端获取授权:

  1. 前端通过微信 SDK 获取登录凭证(code)和手机号加密数据
  2. 后端接收凭证和加密数据,与微信服务器交互获取用户信息
  3. 后端创建或更新用户信息,并生成会话令牌(token)返回给前端
  4. 前端存储令牌,后续请求时携带令牌进行身份验证
  5. 令牌过期时,后端自动使用刷新令牌获取新的访问令牌

后端依赖

<!-- wx-java SDK -->
<dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-miniapp</artifactId>
    <version>4.5.0</version>
</dependency>

<!-- JWT 依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

后端配置

# application.yml
wx:
  miniapp:
    appid: ${WX_MINIAPP_APPID} # 微信小程序 appId
    secret: ${WX_MINIAPP_SECRET} # 微信小程序 appSecret
    token: ${WX_MINIAPP_TOKEN} # 微信小程序消息服务器配置的 token
    aesKey: ${WX_MINIAPP_AES_KEY} # 微信小程序消息服务器配置的 EncodingAESKey
    msgDataFormat: JSON # 消息格式XML 或者 JSON

# JWT 配置
jwt:
  secret: ${JWT_SECRET_KEY} # JWT 密钥
  access-token-expiration: 86400 # 访问令牌过期时间(秒)默认1天
  refresh-token-expiration: 604800 # 刷新令牌过期时间(秒)默认7天

微信服务配置类

@Configuration
@EnableConfigurationProperties(WxMaProperties.class)
public class WxMaConfiguration {
    private final WxMaProperties properties;
    private static final Map<String, WxMaService> maServices = new HashMap<>();

    @Autowired
    public WxMaConfiguration(WxMaProperties properties) {
        this.properties = properties;
    }

    @Bean
    public WxMaService wxMaService() {
        WxMaService service = new WxMaServiceImpl();
        WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
        config.setAppid(properties.getAppid());
        config.setSecret(properties.getSecret());
        config.setToken(properties.getToken());
        config.setAesKey(properties.getAesKey());
        config.setMsgDataFormat(properties.getMsgDataFormat());
        service.setWxMaConfig(config);
        maServices.put(properties.getAppid(), service);
        return service;
    }

    public static WxMaService getMaService(String appid) {
        return maServices.get(appid);
    }
}

微信用户表设计

CREATE TABLE `wx_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `open_id` varchar(128) NOT NULL COMMENT '微信开放ID',
  `union_id` varchar(128) DEFAULT NULL COMMENT '微信开放平台unionid',
  `session_key` varchar(128) DEFAULT NULL COMMENT '会话密钥',
  `nickname` varchar(64) DEFAULT NULL COMMENT '昵称',
  `avatar_url` varchar(256) DEFAULT NULL COMMENT '头像',
  `phone` varchar(32) DEFAULT NULL COMMENT '手机号',
  `gender` tinyint(1) DEFAULT NULL COMMENT '性别(0:未知 1:男 2:女)',
  `country` varchar(64) DEFAULT NULL COMMENT '国家',
  `province` varchar(64) DEFAULT NULL COMMENT '省份',
  `city` varchar(64) DEFAULT NULL COMMENT '城市',
  `language` varchar(64) DEFAULT NULL COMMENT '语言',
  `is_new_user` tinyint(1) DEFAULT '1' COMMENT '是否新用户(0:否 1:是)',
  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
  `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_open_id` (`open_id`),
  KEY `idx_union_id` (`union_id`),
  KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信用户表';

控制器层实现

@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final WxMaService wxMaService;
    private final UserService userService;
    private final JwtService jwtService;

    /**
     * 微信登录(简单版)
     */
    @PostMapping("/wechat/login")
    public R<LoginResult> wechatLogin(@RequestBody WechatLoginRequest request) {
        try {
            // 获取微信用户信息
            WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode());
            String openid = sessionResult.getOpenid();
            String sessionKey = sessionResult.getSessionKey();

            // 查询或创建用户
            User user = userService.getOrCreateWxUser(openid, sessionKey);

            // 生成令牌
            String accessToken = jwtService.generateAccessToken(user.getId());
            String refreshToken = jwtService.generateRefreshToken(user.getId());

            // 构建返回结果
            LoginResult result = new LoginResult();
            result.setAccessToken(accessToken);
            result.setRefreshToken(refreshToken);
            result.setTokenType("Bearer");
            result.setExpiresIn(jwtService.getAccessTokenExpiration());
            result.setNewUser(user.getIsNewUser());
            result.setProfileComplete(userService.isUserProfileComplete(user));

            return R.ok(result);
        } catch (WxErrorException e) {
            log.error("微信登录失败", e);
            return R.fail("微信登录失败: " + e.getMessage());
        }
    }

    /**
     * 微信小程序手机号登录
     */
    @PostMapping("/wechat/phone-login")
    public R<LoginResult> wechatPhoneLogin(@RequestBody WechatPhoneLoginRequest request) {
        try {
            // 获取微信用户信息
            WxMaJscode2SessionResult sessionResult = wxMaService.jsCode2SessionInfo(request.getCode());
            String openid = sessionResult.getOpenid();
            String sessionKey = sessionResult.getSessionKey();

            // 解密手机号
            String phoneNumber = null;

            // 新版本获取手机号
            if (request.getPhoneCode() != null) {
                WxMaPhoneNumberInfo phoneInfo = wxMaService.getPhoneNoInfo(request.getPhoneCode());
                phoneNumber = phoneInfo.getPhoneNumber();
            }
            // 旧版本获取手机号
            else if (request.getEncryptedData() != null && request.getIv() != null) {
                WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService()
                    .getPhoneNoInfo(sessionKey, request.getEncryptedData(), request.getIv());
                phoneNumber = phoneInfo.getPhoneNumber();
            }

            if (phoneNumber == null) {
                return R.fail("获取手机号失败");
            }

            // 查询或创建用户并更新手机号
            User user = userService.getOrCreateWxUserWithPhone(openid, sessionKey, phoneNumber);

            // 生成令牌
            String accessToken = jwtService.generateAccessToken(user.getId());
            String refreshToken = jwtService.generateRefreshToken(user.getId());

            // 构建返回结果
            LoginResult result = new LoginResult();
            result.setAccessToken(accessToken);
            result.setRefreshToken(refreshToken);
            result.setTokenType("Bearer");
            result.setExpiresIn(jwtService.getAccessTokenExpiration());
            result.setNewUser(user.getIsNewUser());
            result.setProfileComplete(userService.isUserProfileComplete(user));

            return R.ok(result);
        } catch (WxErrorException e) {
            log.error("微信手机号登录失败", e);
            return R.fail("微信手机号登录失败: " + e.getMessage());
        }
    }

    /**
     * 检查会话有效性
     */
    @GetMapping("/check-session")
    public R<SessionValidResult> checkSession(HttpServletRequest request) {
        String token = jwtService.getTokenFromRequest(request);
        boolean isValid = jwtService.validateToken(token);

        SessionValidResult result = new SessionValidResult();
        result.setValid(isValid);

        return R.ok(result);
    }

    /**
     * 刷新令牌
     */
    @PostMapping("/refresh-token")
    public R<RefreshTokenResult> refreshToken(@RequestBody RefreshTokenRequest request) {
        try {
            String refreshToken = request.getRefreshToken();
            if (!jwtService.validateRefreshToken(refreshToken)) {
                return R.fail("刷新令牌无效或已过期");
            }

            Long userId = jwtService.getUserIdFromToken(refreshToken);
            String newAccessToken = jwtService.generateAccessToken(userId);

            RefreshTokenResult result = new RefreshTokenResult();
            result.setAccessToken(newAccessToken);
            result.setExpiresIn(jwtService.getAccessTokenExpiration());

            return R.ok(result);
        } catch (Exception e) {
            log.error("刷新令牌失败", e);
            return R.fail("刷新令牌失败");
        }
    }

    /**
     * 登出
     */
    @PostMapping("/logout")
    public R<Void> logout(HttpServletRequest request) {
        // 此处可以将token加入黑名单
        // 在真实场景中可以将token存入Redis黑名单并设置过期时间
        return R.ok();
    }
}

JWT服务实现

@Service
@RequiredArgsConstructor
public class JwtService {

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.access-token-expiration}")
    private long accessTokenExpiration;

    @Value("${jwt.refresh-token-expiration}")
    private long refreshTokenExpiration;

    /**
     * 生成访问令牌
     */
    public String generateAccessToken(Long userId) {
        return generateToken(userId, accessTokenExpiration, "access");
    }

    /**
     * 生成刷新令牌
     */
    public String generateRefreshToken(Long userId) {
        return generateToken(userId, refreshTokenExpiration, "refresh");
    }

    /**
     * 生成令牌
     */
    private String generateToken(Long userId, long expiration, String type) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration * 1000);

        return Jwts.builder()
                .setSubject(userId.toString())
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .claim("type", type)
                .signWith(getSigningKey())
                .compact();
    }

    /**
     * 从请求中获取令牌
     */
    public String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /**
     * 验证令牌
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 验证刷新令牌
     */
    public boolean validateRefreshToken(String token) {
        try {
            Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();

            return "refresh".equals(claims.get("type"));
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 从令牌中获取用户ID
     */
    public Long getUserIdFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();

        return Long.parseLong(claims.getSubject());
    }

    /**
     * 获取签名密钥
     */
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 获取访问令牌过期时间(秒)
     */
    public long getAccessTokenExpiration() {
        return accessTokenExpiration;
    }
}

用户服务实现

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final WxUserMapper wxUserMapper;

    /**
     * 获取或创建微信用户
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public User getOrCreateWxUser(String openid, String sessionKey) {
        // 查询用户
        WxUser wxUser = wxUserMapper.selectByOpenId(openid);

        // 如果用户不存在,创建新用户
        if (wxUser == null) {
            wxUser = new WxUser();
            wxUser.setOpenId(openid);
            wxUser.setSessionKey(sessionKey);
            wxUser.setIsNewUser(true);
            wxUser.setLastLoginTime(new Date());
            wxUserMapper.insert(wxUser);
        } else {
            // 更新会话密钥和登录时间
            wxUser.setSessionKey(sessionKey);
            wxUser.setLastLoginTime(new Date());
            wxUserMapper.updateById(wxUser);
        }

        // 将 WxUser 转换为 User
        return convertToUser(wxUser);
    }

    /**
     * 获取或创建带手机号的微信用户
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public User getOrCreateWxUserWithPhone(String openid, String sessionKey, String phone) {
        // 先检查是否有该手机号的用户
        WxUser wxUserByPhone = wxUserMapper.selectByPhone(phone);

        // 如果存在该手机号用户但openid不同可能是用户换了微信号更新openid
        if (wxUserByPhone != null && !openid.equals(wxUserByPhone.getOpenId())) {
            wxUserByPhone.setOpenId(openid);
            wxUserByPhone.setSessionKey(sessionKey);
            wxUserByPhone.setLastLoginTime(new Date());
            wxUserByPhone.setIsNewUser(false);
            wxUserMapper.updateById(wxUserByPhone);
            return convertToUser(wxUserByPhone);
        }

        // 查询用户
        WxUser wxUser = wxUserMapper.selectByOpenId(openid);

        // 如果用户不存在,创建新用户
        if (wxUser == null) {
            wxUser = new WxUser();
            wxUser.setOpenId(openid);
            wxUser.setSessionKey(sessionKey);
            wxUser.setPhone(phone);
            wxUser.setIsNewUser(true);
            wxUser.setLastLoginTime(new Date());
            wxUserMapper.insert(wxUser);
        } else {
            // 更新会话密钥、手机号和登录时间
            wxUser.setSessionKey(sessionKey);
            wxUser.setPhone(phone);
            wxUser.setLastLoginTime(new Date());
            wxUserMapper.updateById(wxUser);
        }

        // 将 WxUser 转换为 User
        return convertToUser(wxUser);
    }

    /**
     * 判断用户信息是否完整
     */
    @Override
    public boolean isUserProfileComplete(User user) {
        if (user == null) {
            return false;
        }

        return StringUtils.hasText(user.getNickname())
            && StringUtils.hasText(user.getAvatar())
            && StringUtils.hasText(user.getPhone());
    }

    /**
     * 将 WxUser 转换为 User
     */
    private User convertToUser(WxUser wxUser) {
        if (wxUser == null) {
            return null;
        }

        User user = new User();
        user.setId(wxUser.getId());
        user.setOpenId(wxUser.getOpenId());
        user.setUnionId(wxUser.getUnionId());
        user.setNickname(wxUser.getNickname());
        user.setAvatar(wxUser.getAvatarUrl());
        user.setPhone(wxUser.getPhone());
        user.setGender(wxUser.getGender());
        user.setIsNewUser(wxUser.getIsNewUser());

        return user;
    }
}

网关过滤器实现 (可选)

如果使用了Spring Cloud Gateway可以添加JWT验证过滤器

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter implements GlobalFilter {

    private final JwtService jwtService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();

        // 白名单路径不需要token验证
        if (isWhiteListPath(request.getPath().toString())) {
            return chain.filter(exchange);
        }

        // 获取token
        String token = getTokenFromRequest(request);
        if (token == null) {
            return onError(exchange, "未授权", HttpStatus.UNAUTHORIZED);
        }

        // 验证token
        if (!jwtService.validateToken(token)) {
            return onError(exchange, "token无效或已过期", HttpStatus.UNAUTHORIZED);
        }

        // 获取用户ID并设置到请求头
        Long userId = jwtService.getUserIdFromToken(token);
        ServerHttpRequest mutatedRequest = request.mutate()
                .header("X-User-ID", userId.toString())
                .build();

        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    private boolean isWhiteListPath(String path) {
        List<String> whiteList = Arrays.asList(
            "/api/v1/auth/login",
            "/api/v1/auth/wechat/login",
            "/api/v1/auth/wechat/mini-login",
            "/api/v1/auth/wechat/phone-login",
            "/api/v1/auth/refresh-token"
        );

        return whiteList.stream().anyMatch(path::startsWith);
    }

    private String getTokenFromRequest(ServerHttpRequest request) {
        List<String> authHeaders = request.getHeaders().get("Authorization");
        if (authHeaders != null && !authHeaders.isEmpty()) {
            String auth = authHeaders.get(0);
            if (auth.startsWith("Bearer ")) {
                return auth.substring(7);
            }
        }
        return null;
    }

    private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(status);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);

        Map<String, Object> result = new HashMap<>();
        result.put("code", status.value());
        result.put("message", message);

        byte[] bytes = new ObjectMapper().writeValueAsBytes(result);
        DataBuffer buffer = response.bufferFactory().wrap(bytes);
        return response.writeWith(Mono.just(buffer));
    }
}

安全建议

  1. 生产环境中使用HTTPS保护API通信
  2. 敏感信息(如SessionKey)不要存储在前端
  3. 为JWT密钥使用足够强度的随机字符串
  4. 实现令牌黑名单机制处理注销和令牌泄露情况
  5. 考虑实现令牌自动续期机制
  6. 定期清理过期的会话记录
  7. 记录关键操作的审计日志

测试

使用Postman或其他API测试工具测试以下接口

  1. /api/v1/auth/wechat/login - 微信简单登录
  2. /api/v1/auth/wechat/phone-login - 微信手机号登录
  3. /api/v1/auth/check-session - 检查会话有效性
  4. /api/v1/auth/refresh-token - 刷新令牌
  5. /api/v1/auth/logout - 登出