从零搭建一个现代化的验证码登录系统:Spring Boot + 阿里云短信实战教程
前言
在当今这个快节奏的数字时代,用户体验已经成为产品成功的关键因素之一。传统的用户名密码登录方式,虽然安全可靠,但对于用户来说,注册流程繁琐、记忆密码困难,往往是用户流失的重灾区。
而手机号验证码登录,凭借其"即用即走"的便利性,已经成为主流应用的首选方案。不用注册、不用记密码、输入手机号收个验证码就能登录,体验简直不要太丝滑!
今天,我就带你从零搭建一个完整的验证码登录系统,使用 Spring Boot 3 + 阿里云短信服务,从后端 API 到前端页面,全流程实战!
项目概览
我们要实现的功能包括:
✅ 发送短信验证码 - 通过阿里云短信服务发送 6 位验证码
✅ 验证码验证 - 验证用户输入的验证码是否正确
✅ 用户注册 - 新用户通过验证码完成注册
✅ 用户登录 - 已注册用户通过验证码登录
✅ 精美界面 - 提供登录、注册、首页三个页面
最终效果预览:
用户流程:
首页 → 登录/注册 → 输入手机号 → 收验证码 → 登录成功 → 返回首页
技术栈选择
工欲善其事,必先利其器。我们选择以下技术栈:
| 技术 | 版本 | 用途 |
|---|---|---|
| Spring Boot | 3.2.5 | 应用框架 |
| Spring Data JPA | 3.2.5 | 数据持久化 |
| H2 Database | - | 嵌入式数据库(演示用) |
| Thymeleaf | 3.2.5 | 模板引擎 |
| 阿里云 dypnsapi | 1.2.3 | 短信服务 SDK |
| Java | 17 | 编程语言 |
为什么选择这些技术?
- Spring Boot 3:最新的 Spring 生态,支持 Java 17+,开发效率极高
- 阿里云短信:国内最成熟的短信服务之一,稳定性有保障
- H2:零配置嵌入式数据库,演示项目的不二之选
- Thymeleaf:自然模板,前后端不分离的项目首选
第一步:项目初始化
1.1 创建 Maven 项目
首先,创建一个标准的 Spring Boot Maven 项目。pom.xml 依赖如下:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<groupId>com.example</groupId>
<artifactId>aliyun-sms-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 数据库 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 模板引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 阿里云短信 SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
<version>1.2.3</version>
</dependency>
<!-- Lombok 减少样板代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
1.2 配置文件
创建 application.yml 配置文件:
yaml
server:
port: 8080
spring:
application:
name: aliyun-sms-demo
datasource:
url: jdbc:h2:file:./data/smsdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
thymeleaf:
cache: false
# 阿里云短信配置(替换为你自己的配置)
aliyun:
sms:
access-key-id: 你的AccessKey ID
access-key-secret: 你的AccessKey Secret
endpoint: dypnsapi.aliyuncs.com
sign-name: 你的短信签名
template-code: 你的模板编码
code-length: 6
code-expire-seconds: 300
send-interval-seconds: 60
注意:这里的阿里云配置需要你去阿里云控制台申请开通短信服务,获取 AccessKey 和模板信息。
第二步:核心代码实现
2.1 配置属性类
使用 Spring Boot 的 @ConfigurationProperties 将配置文件中的配置绑定到 Java 对象:
java
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.sms")
public class SmsProperties {
private String accessKeyId;
private String accessKeySecret;
private String endpoint;
private String signName;
private String templateCode;
private int codeLength = 6;
private long codeExpireSeconds = 300;
private long sendIntervalSeconds = 60;
}
这样,我们就可以在代码中直接注入 SmsProperties 来获取配置,而不是到处写硬编码的配置值。
2.2 用户实体类
定义用户实体,对应数据库中的 users 表:
java
@Data
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String phone;
@Column(nullable = false)
private String nickname;
private LocalDateTime createTime;
private LocalDateTime lastLoginTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
}
}
关键点:
@Column(unique = true):确保手机号唯一@PrePersist:在实体持久化前自动设置创建时间LocalDateTime:JDK 8+ 引入的现代日期时间 API
2.3 数据访问层
使用 Spring Data JPA 的 Repository 模式:
java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone);
boolean existsByPhone(String phone);
}
是的,你没看错!只需要定义接口,不需要写实现。Spring Data JPA 会根据方法名自动生成 SQL。
findByPhone→SELECT * FROM users WHERE phone = ?existsByPhone→SELECT COUNT(*) FROM users WHERE phone = ?
2.4 阿里云短信服务封装
这是核心中的核心!我们来封装阿里云短信服务的调用:
java
@Service
@RequiredArgsConstructor
@Slf4j
public class AliyunSmsService {
private final SmsProperties smsProperties;
@Data
@AllArgsConstructor
public static class SmsResult {
private boolean success;
private String message;
private String bizId;
}
private Client createClient() throws Exception {
Config config = new Config()
.setAccessKeyId(smsProperties.getAccessKeyId())
.setAccessKeySecret(smsProperties.getAccessKeySecret())
.setEndpoint(smsProperties.getEndpoint());
return new Client(config);
}
/**
* 发送短信验证码
*/
public SmsResult sendSms(String phoneNumber, int expireMinutes) {
try {
Client client = createClient();
Map<String, String> templateParam = new HashMap<>();
templateParam.put("code", "##code##");
templateParam.put("min", String.valueOf(expireMinutes));
SendSmsVerifyCodeRequest request = new SendSmsVerifyCodeRequest();
request.setPhoneNumber(phoneNumber);
request.setSignName(smsProperties.getSignName());
request.setTemplateCode(smsProperties.getTemplateCode());
request.setTemplateParam(new Gson().toJson(templateParam));
SendSmsVerifyCodeResponse response = client.sendSmsVerifyCode(request);
if ("OK".equals(response.getBody().getCode())) {
log.info("短信发送成功,手机号: {}", phoneNumber);
return new SmsResult(true, "发送成功",
response.getBody().getModel() != null
? response.getBody().getModel().getBizId()
: null);
} else {
return new SmsResult(false,
String.format("错误码: %s, 错误信息: %s",
response.getBody().getCode(),
response.getBody().getMessage()),
null);
}
} catch (Exception e) {
log.error("短信发送异常", e);
return new SmsResult(false, "发送异常: " + e.getMessage(), null);
}
}
/**
* 验证短信验证码
*/
public SmsResult verifySms(String phoneNumber, String code) {
try {
Client client = createClient();
CheckSmsVerifyCodeRequest request = new CheckSmsVerifyCodeRequest();
request.setPhoneNumber(phoneNumber);
request.setVerifyCode(code);
request.setSchemeName("默认方案");
CheckSmsVerifyCodeResponse response = client.checkSmsVerifyCode(request);
if ("OK".equals(response.getBody().getCode())) {
String verifyResult = response.getBody().getModel() != null
? response.getBody().getModel().getVerifyResult()
: null;
if ("PASS".equals(verifyResult)) {
log.info("短信验证成功,手机号: {}", phoneNumber);
return new SmsResult(true, "验证成功", null);
}
}
return new SmsResult(false, "验证失败", null);
} catch (Exception e) {
log.error("短信验证异常", e);
return new SmsResult(false, "验证异常: " + e.getMessage(), null);
}
}
}
设计要点:
- 统一返回格式 :
SmsResult封装了操作结果、消息和业务ID - 异常处理:所有异常都被捕获,转换为友好的错误信息
- 日志记录:关键操作都有日志输出,便于排查问题
- 方法解耦:发送和验证分开,职责单一
2.5 用户业务服务
处理用户注册、登录的业务逻辑:
java
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
private final UserRepository userRepository;
/**
* 用户注册
*/
public User register(String phone, String nickname) {
if (userRepository.existsByPhone(phone)) {
throw new RuntimeException("该手机号已注册");
}
User user = new User();
user.setPhone(phone);
// 如果没有传昵称,默认使用"用户+手机号后4位"
user.setNickname(nickname != null ? nickname : "用户" + phone.substring(7));
User saved = userRepository.save(user);
log.info("用户注册成功,手机号: {}, 昵称: {}", phone, user.getNickname());
return saved;
}
/**
* 用户登录
*/
public Optional<User> login(String phone) {
Optional<User> userOpt = userRepository.findByPhone(phone);
if (userOpt.isPresent()) {
User user = userOpt.get();
user.setLastLoginTime(LocalDateTime.now());
userRepository.save(user);
log.info("用户登录成功,手机号: {}", phone);
return Optional.of(user);
}
log.warn("用户不存在,手机号: {}", phone);
return Optional.empty();
}
public boolean existsByPhone(String phone) {
return userRepository.existsByPhone(phone);
}
}
亮点设计:
Optional<User>:优雅地处理可能为空的返回值,避免 NullPointerException- 登录时更新
lastLoginTime:自动记录用户最后登录时间 - 默认昵称策略:提升用户体验,不强制用户填写昵称
2.6 API 控制器层
现在,我们需要暴露 RESTful API 给前端调用。
短信服务接口
java
@RestController
@RequestMapping("/api/sms")
@Validated
@RequiredArgsConstructor
@Slf4j
public class SmsController {
private final AliyunSmsService aliyunSmsService;
private final SmsProperties smsProperties;
private final Map<String, Long> lastSendTime = new ConcurrentHashMap<>();
/**
* 发送短信验证码
*/
@PostMapping("/send")
public Map<String, Object> sendCode(
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone) {
Map<String, Object> result = new HashMap<>();
// 频率限制:防止短信轰炸
Long lastSend = lastSendTime.get(phone);
long now = System.currentTimeMillis();
if (lastSend != null && (now - lastSend) < smsProperties.getSendIntervalSeconds() * 1000) {
result.put("success", false);
result.put("message", "发送太频繁,请稍后再试");
return result;
}
int expireMinutes = (int) (smsProperties.getCodeExpireSeconds() / 60);
AliyunSmsService.SmsResult smsResult = aliyunSmsService.sendSms(phone, expireMinutes);
if (smsResult.isSuccess()) {
lastSendTime.put(phone, now);
result.put("success", true);
result.put("message", smsResult.getMessage());
result.put("bizId", smsResult.getBizId());
log.info("验证码已发送,手机号: {}", phone);
} else {
result.put("success", false);
result.put("message", smsResult.getMessage());
}
return result;
}
/**
* 验证短信验证码
*/
@PostMapping("/verify")
public Map<String, Object> verifyCode(
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone,
@RequestParam String code) {
Map<String, Object> result = new HashMap<>();
AliyunSmsService.SmsResult smsResult = aliyunSmsService.verifySms(phone, code);
if (smsResult.isSuccess()) {
lastSendTime.remove(phone);
result.put("success", true);
result.put("message", smsResult.getMessage());
log.info("验证码验证成功,手机号: {}", phone);
} else {
result.put("success", false);
result.put("message", smsResult.getMessage());
}
return result;
}
}
认证服务接口
java
@RestController
@RequestMapping("/api/auth")
@Validated
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final UserService userService;
private final AliyunSmsService aliyunSmsService;
/**
* 检查手机号是否已注册
*/
@PostMapping("/check-phone")
public Map<String, Object> checkPhone(
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone) {
Map<String, Object> result = new HashMap<>();
boolean exists = userService.existsByPhone(phone);
result.put("success", true);
result.put("exists", exists);
result.put("message", exists ? "该手机号已注册" : "该手机号未注册");
return result;
}
/**
* 用户注册
*/
@PostMapping("/register")
public Map<String, Object> register(
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone,
@RequestParam String code,
@RequestParam(required = false) String nickname) {
Map<String, Object> result = new HashMap<>();
// 1. 验证短信验证码
AliyunSmsService.SmsResult verifyResult = aliyunSmsService.verifySms(phone, code);
if (!verifyResult.isSuccess()) {
result.put("success", false);
result.put("message", verifyResult.getMessage());
return result;
}
// 2. 检查手机号是否已注册
if (userService.existsByPhone(phone)) {
result.put("success", false);
result.put("message", "该手机号已注册");
return result;
}
// 3. 执行注册
try {
User user = userService.register(phone, nickname);
result.put("success", true);
result.put("message", "注册成功");
result.put("user", Map.of(
"id", user.getId(),
"phone", user.getPhone(),
"nickname", user.getNickname()
));
} catch (Exception e) {
result.put("success", false);
result.put("message", "注册失败: " + e.getMessage());
}
return result;
}
/**
* 用户登录
*/
@PostMapping("/login")
public Map<String, Object> login(
@RequestParam @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
String phone,
@RequestParam String code) {
Map<String, Object> result = new HashMap<>();
// 1. 验证短信验证码
AliyunSmsService.SmsResult verifyResult = aliyunSmsService.verifySms(phone, code);
if (!verifyResult.isSuccess()) {
result.put("success", false);
result.put("message", verifyResult.getMessage());
return result;
}
// 2. 检查用户是否存在
if (!userService.existsByPhone(phone)) {
result.put("success", false);
result.put("message", "该手机号未注册");
result.put("needRegister", true);
return result;
}
// 3. 执行登录
User user = userService.login(phone).orElse(null);
if (user != null) {
result.put("success", true);
result.put("message", "登录成功");
result.put("user", Map.of(
"id", user.getId(),
"phone", user.getPhone(),
"nickname", user.getNickname()
));
} else {
result.put("success", false);
result.put("message", "登录失败");
}
return result;
}
}
关键设计:
- 参数校验 :使用
@Pattern注解在 Controller 层校验手机号格式 - 频率限制 :使用
ConcurrentHashMap记录发送时间,防止短信轰炸 - 统一格式 :所有 API 返回统一的
Map结构,前端处理更方便 - 业务流程:注册/登录都遵循"验证验证码 → 检查状态 → 执行业务"的流程
2.7 页面控制器
提供页面路由,使用 Thymeleaf 渲染页面:
java
@Controller
public class PageController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/register")
public String register() {
return "register";
}
}
第三步:前端页面实现
后端 API 准备好了,现在来写前端页面。我们会用原生 HTML + CSS + JS,不依赖任何前端框架,保持轻量。
3.1 登录页面
登录页面的核心逻辑:
javascript
// 发送验证码
sendCodeBtn.addEventListener('click', async () => {
const phone = phoneInput.value.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
showMessage('请输入正确的手机号', 'error');
return;
}
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = '发送中...';
try {
const response = await fetch('/api/sms/send?phone=' + phone, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showMessage('验证码已发送', 'success');
startCountdown(); // 60秒倒计时
} else {
showMessage(result.message, 'error');
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '获取验证码';
}
} catch (error) {
showMessage('发送失败,请稍后重试', 'error');
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '获取验证码';
}
});
// 提交登录
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const params = new URLSearchParams();
params.append('phone', phoneInput.value.trim());
params.append('code', codeInput.value.trim());
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});
const result = await response.json();
if (result.success) {
showMessage('登录成功!欢迎回来,' + result.user.nickname, 'success');
localStorage.setItem('user', JSON.stringify(result.user));
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showMessage(result.message, 'error');
}
});
3.2 倒计时功能
这是一个经典的 UX 优化:
javascript
let countdown = 0;
let countdownTimer = null;
function startCountdown() {
countdown = 60;
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = countdown + 's 后重试';
sendCodeBtn.classList.add('sending');
countdownTimer = setInterval(() => {
countdown--;
if (countdown <= 0) {
clearInterval(countdownTimer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = '获取验证码';
sendCodeBtn.classList.remove('sending');
} else {
sendCodeBtn.textContent = countdown + 's 后重试';
}
}, 1000);
}
3.3 页面样式
使用渐变背景和圆角卡片,营造现代化感:
css
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
width: 100%;
max-width: 400px;
}
.btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
padding: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
第四步:运行测试
4.1 启动项目
bash
mvn spring-boot:run
4.2 访问地址
| 页面 | 地址 |
|---|---|
| 首页 | http://localhost:8080/ |
| 登录页 | http://localhost:8080/login |
| 注册页 | http://localhost:8080/register |
| H2 控制台 | http://localhost:8080/h2-console |
4.3 测试流程
- 访问
/register→ 输入手机号 → 点击"获取验证码" - 收到短信验证码 → 输入验证码和昵称 → 点击"注册"
- 注册成功后跳转到
/login→ 用同样的手机号登录 - 登录成功后跳转到首页,显示用户信息
系统架构总结
让我们用一张图来回顾整个系统的架构:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 前端页面 │───▶│ Controller │───▶│ Service │
│ index.html │ │ SmsController│ │ AliyunSms │
│ login.html │ │ AuthController│ │ UserService │
│register.html│ └─────────────┘ └──────┬──────┘
└─────────────┘ │
▼
┌─────────────────┐
│ Repository │
│ UserRepository │
└────────┬────────┘
│
▼
┌─────────────────┐
│ H2 Database │
│ users表 │
└─────────────────┘
安全与优化建议
生产环境必做
- 替换数据库:H2 → MySQL/PostgreSQL
- 密钥管理:使用环境变量或配置中心管理阿里云密钥
- 分布式限流:接入 Redis,避免单实例内存限流的问题
- HTTPS:生产环境必须启用 SSL
- 日志脱敏:手机号、验证码等敏感信息在日志中需要脱敏
可选优化
- 图形验证码:在发送短信前增加图形验证码,防止机器刷短信
- 登录态管理:接入 Spring Security + JWT,实现真正的会话管理
- 限流策略升级:支持按 IP 限流、单日发送次数限制等
- 监控告警:接入 Prometheus,监控短信发送成功率
完整 API 列表
| 方法 | 路径 | 功能 |
|---|---|---|
| POST | /api/sms/send |
发送短信验证码 |
| POST | /api/sms/verify |
验证短信验证码 |
| POST | /api/auth/check-phone |
检查手机号是否已注册 |
| POST | /api/auth/register |
用户注册 |
| POST | /api/auth/login |
用户登录 |
写在最后
通过本篇教程,我们从零搭建了一个完整的验证码登录系统。回顾一下我们做了什么:
- ✅ 项目初始化和依赖配置
- ✅ 阿里云短信服务集成
- ✅ 用户实体和数据访问层
- ✅ 业务服务层实现
- ✅ RESTful API 设计
- ✅ 前端页面开发
- ✅ 完整的用户登录/注册流程
这个系统的核心价值在于:
- 用户体验:无需密码,一键登录
- 代码质量:分层清晰,职责单一
- 可扩展性:易于替换短信服务商、数据库等
- 安全性:内置频率限制、参数校验等安全机制
参考资源:
如果这篇文章对你有帮助,欢迎点赞、收藏、关注!有任何问题欢迎在评论区留言交流。