一、引言
本文档提供了一套完整的解决方案,在 Spring Boot 项目中集成微信扫码登录、阿里云短信验证码登录/绑定和邮箱验证码登录/绑定三大功能。方案遵循安全最佳实践,代码结构清晰。
二、前置准备
2.1 微信开放平台配置
- 在微信开放平台创建'网站应用'。
- 获取 AppID 和 AppSecret。
Spring Boot 项目集成微信扫码、手机号及邮箱验证码三种登录方式。方案涉及微信开放平台、阿里云短信、QQ 邮箱 SMTP 配置及数据库设计。核心实现包括二维码生成与状态轮询、验证码发送频率限制与 Redis 存储、统一登录控制器处理业务逻辑。安全措施涵盖 HTTPS 强制、验证码短效一次性、强随机 SessionID 及敏感信息服务端保护。
本文档提供了一套完整的解决方案,在 Spring Boot 项目中集成微信扫码登录、阿里云短信验证码登录/绑定和邮箱验证码登录/绑定三大功能。方案遵循安全最佳实践,代码结构清晰。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
api.yourdomain.com。您的验证码为:${code},5 分钟内有效。smtp.qq.com,端口为 465。CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL, -- (密码存储应明确推荐使用 BCrypt)
nickname VARCHAR(50),
wechat_open_id VARCHAR(64),
phone VARCHAR(20),
email VARCHAR(100),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_wechat_open_id (wechat_open_id),
INDEX idx_phone (phone),
INDEX idx_email (email)
);
pom.xml)<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 微信 -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-open</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 邮件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 阿里云 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.3</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.3.0</version>
</dependency>
<!-- 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<!-- 其他依赖如 Spring Security、Fastjson 等按需引入 -->
</dependencies>
application.yml)server:
port: 8080
wechat:
open:
app-id: wxXXXXXXXXXXXXXX
app-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
redirect-uri: https://api.yourdomain.com/admin/auth/wechat/callback
aliyun:
sms:
access-key-id: LTAI5tXXXXXX
access-key-secret: your-access-key-secret
region-id: cn-hangzhou
sign-name: YourAppName
template-code: SMS_XXXXXXXX
spring:
redis:
host: localhost
port: 6379
mail:
host: smtp.qq.com
port: 465
username: [email protected]
password: your-email-auth-code
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
ssl:
enable: true
// Result.java (通用返回体)
@Data
@AllArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(200, "成功", data);
}
}
// WechatQrcodeVO.java
@Data
@AllArgsConstructor
public class WechatQrcodeVO {
private String sceneId;
private String qrcodeUrl;
private Integer expireTime;
}
// ScanStatusVO.java
@Data
@AllArgsConstructor
public class ScanStatusVO {
private Integer status; // 0:等待,3:成功,4:过期,5:需绑定
private String token;
private Object userInfo;
}
// SendCodeRequest.java
@Data
public class SendCodeRequest {
private String account; // 手机号或邮箱
private String type; // "PHONE" or "EMAIL"
}
// VerifyCodeRequest.java
@Data
public class VerifyCodeRequest {
private String account;
private String code;
private String bizType; // "WECHAT_BIND", "PHONE_LOGIN", "EMAIL_LOGIN"
private String sceneId; // for binding
}
AliyunSmsService.java)@Service
@Slf4j
public class AliyunSmsService {
@Value("${aliyun.sms.access-key-id}")
private String accessKeyId;
@Value("${aliyun.sms.access-key-secret}")
private String accessKeySecret;
@Value("${aliyun.sms.region-id}")
private String regionId;
@Value("${aliyun.sms.sign-name}")
private String signName;
@Value("${aliyun.sms.template-code}")
private String templateCode;
public void sendSmsCode(String phone, String code) {
DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
IAcsClient client = new DefaultAcsClient(profile);
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(phone);
request.setSignName(signName);
request.setTemplateCode(templateCode);
request.setTemplateParam("{\"code\":\"" + code + "\"}");
try {
SendSmsResponse response = client.getAcsResponse(request);
if (!"OK".equals(response.getCode())) {
throw new RuntimeException("短信发送失败:" + response.getMessage());
}
} catch (ClientException e) {
log.error("调用阿里云短信 API 异常", e);
throw new RuntimeException("服务暂时不可用");
} finally {
client.shutdown();
}
}
}
EmailService.java)@Service
@Slf4j
public class EmailService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String fromEmail;
public void sendEmailCode(String to, String code) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(to);
message.setSubject("【YourApp】邮箱验证码");
message.setText("您的验证码是:" + code + ",5 分钟内有效。如非本人操作,请忽略此邮件。");
try {
mailSender.send(message);
} catch (Exception e) {
log.error("发送邮件失败", e);
throw new RuntimeException("邮件发送失败");
}
}
}
UnifiedLoginController.java)@RestController
@RequestMapping("/admin/auth")
@Slf4j
public class UnifiedLoginController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserService userService;
@Autowired
private JwtService jwtService;
@Autowired
private AliyunSmsService aliyunSmsService;
@Autowired
private EmailService emailService;
@Value("${wechat.open.app-id}")
private String appId;
@Value("${wechat.open.app-secret}")
private String appSecret;
@Value("${wechat.open.redirect-uri}")
private String redirectUri;
// --- 微信相关 ---
@PostMapping("/wechat/qrcode/generate")
public Result<WechatQrcodeVO> generateQrcode() {
String sceneId = "LOGIN_" + System.currentTimeMillis() + "_" + RandomUtil.randomString(16);
String qrcodeUrl = "https://open.weixin.qq.com/connect/qrconnect?"
+ "appid=" + appId + "&redirect_uri=" + UrlUtil.encode(redirectUri)
+ "&response_type=code&scope=snsapi_login"
+ "&state=" + sceneId + "#wechat_redirect";
String redisKey = "login:scene:" + sceneId;
redisTemplate.opsForHash().put(redisKey, "status", "0");
redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);
return Result.success(new WechatQrcodeVO(sceneId, qrcodeUrl, 180));
}
@GetMapping("/qrcode/status")
public Result<ScanStatusVO> checkStatus(@RequestParam String sceneId) {
String redisKey = "login:scene:" + sceneId;
String status = (String) redisTemplate.opsForHash().get(redisKey, "status");
if (status == null) {
return Result.success(new ScanStatusVO(4, null, null));
}
if ("3".equals(status)) {
String token = (String) redisTemplate.opsForHash().get(redisKey, "token");
Long userId = Long.valueOf((String) redisTemplate.opsForHash().get(redisKey, "userId"));
redisTemplate.delete(redisKey);
return Result.success(new ScanStatusVO(3, token, userService.getUserInfo(userId)));
}
return Result.success(new ScanStatusVO(Integer.parseInt(status), null, null));
}
@GetMapping("/wechat/callback")
public String callback(@RequestParam String code, @RequestParam String state) {
String sceneId = state;
String redisKey = "login:scene:" + sceneId;
if (!redisTemplate.hasKey(redisKey)) {
return "<script>alert('二维码已过期');window.close();</script>";
}
try {
String accessTokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?"
+ "appid=" + appId + "&secret=" + appSecret + "&code=" + code + "&grant_type=authorization_code";
String resp = HttpUtil.get(accessTokenUrl);
JSONObject json = JSONUtil.parseObj(resp);
String openId = json.getStr("openid");
User user = userService.findByWechatOpenId(openId);
if (user != null) {
handleLoginSuccess(redisKey, user);
return "<script>alert('扫码成功,请在电脑端查看');window.close();</script>";
} else {
redisTemplate.opsForHash().put(redisKey, "status", "5");
redisTemplate.opsForHash().put(redisKey, "openId", openId);
redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);
return "<script>alert('请在电脑端绑定手机号或邮箱');window.close();</script>";
}
} catch (Exception e) {
log.error("微信回调处理失败", e);
return "<script>alert('系统错误');</script>";
}
}
// --- 验证码相关 ---
@PostMapping("/code/send")
public Result<?> sendCode(@RequestBody SendCodeRequest request) {
String account = request.getAccount();
String type = request.getType();
// 频率限制
String rateLimitKey = "code:rate_limit:" + account;
if (Boolean.TRUE.equals(redisTemplate.hasKey(rateLimitKey))) {
throw new RuntimeException("操作过于频繁,请 60 秒后再试");
}
String code = RandomUtil.randomNumbers(6);
String codeKey = "code:verify:" + account;
if ("PHONE".equals(type)) {
if (!Validator.isMobile(account)) throw new IllegalArgumentException("手机号格式错误");
aliyunSmsService.sendSmsCode(account, code);
} else if ("EMAIL".equals(type)) {
if (!Validator.isEmail(account)) throw new IllegalArgumentException("邮箱格式错误");
emailService.sendEmailCode(account, code);
} else {
throw new IllegalArgumentException("不支持的账号类型");
}
redisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES);
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
return Result.success("验证码发送成功");
}
@PostMapping("/code/verify")
public Result<?> verifyCode(@RequestBody VerifyCodeRequest request) {
String account = request.getAccount();
String inputCode = request.getCode();
String bizType = request.getBizType();
String codeKey = "code:verify:" + account;
String correctCode = redisTemplate.opsForValue().get(codeKey);
if (correctCode == null || !correctCode.equals(inputCode)) {
throw new RuntimeException("验证码错误或已过期");
}
redisTemplate.delete(codeKey); // 一次性使用
if ("WECHAT_BIND".equals(bizType)) {
return handleWechatBind(account, request.getSceneId());
} else if ("PHONE_LOGIN".equals(bizType) || "EMAIL_LOGIN".equals(bizType)) {
return handleAccountLogin(account);
} else {
throw new IllegalArgumentException("不支持的业务类型");
}
}
// --- 私有方法 ---
private void handleLoginSuccess(String redisKey, User user) {
String token = jwtService.generateToken(user);
redisTemplate.opsForHash().put(redisKey, "status", "3");
redisTemplate.opsForHash().put(redisKey, "token", token);
redisTemplate.opsForHash().put(redisKey, "userId", String.valueOf(user.getId()));
redisTemplate.expire(redisKey, 180, TimeUnit.SECONDS);
}
private Result<?> handleWechatBind(String account, String sceneId) {
String redisKey = "login:scene:" + sceneId;
String openId = (String) redisTemplate.opsForHash().get(redisKey, "openId");
if (openId == null) throw new RuntimeException("无效的绑定会话");
User user = userService.bindWechatWithAccount(openId, account);
handleLoginSuccess(redisKey, user);
return Result.success("绑定成功");
}
private Result<ScanStatusVO> handleAccountLogin(String account) {
User user = userService.findByAccount(account);
if (user == null) {
user = userService.registerByAccount(account);
}
String token = jwtService.generateToken(user);
return Result.success(new ScanStatusVO(3, token, userService.getUserInfo(user.getId())));
}
}
RandomUtil.randomString(16) 生成。