博客基础模块开发(登录注册)
目录
- 一、方案编写
- 二、基础环境搭建
- 三、快速体验框架使用
- 四、数据库表构建
- 五、后端API接口实现与测试
- 六、前端代码环境搭建和编写
- 七、前后端联调
一、方案编写
1.1 功能概述
实现博客系统的用户登录和注册功能,包含基础的用户认证、会话管理和安全验证。
1.2 功能需求明细
1.2.1 用户注册功能
| 用户名注册 | 用户设置唯一用户名 | 4-20位字母/数字/下划线 |
| 邮箱注册 | 绑定邮箱用于找回密码 | 有效邮箱格式 |
| 手机号注册 | 可选,绑定手机号 | 11位手机号 |
| 密码设置 | 用户设置登录密码 | 8-20位,字母+数字+特殊字符 |
| 确认密码 | 二次输入确认 | 与密码一致 |
| 验证码 | 邮箱/手机验证码 | 6位数字,有效期5分钟 |
| 图形验证码 | 防止机器注册 | 4位字母数字,忽略大小写 |
| 用户协议 | 同意协议才能注册 | 必须勾选 |
1.2.2 用户登录功能
| 账号登录 | 用户名/邮箱/手机号 + 密码 | 支持多种登录方式 |
| 验证码登录 | 手机号 + 验证码 | 免密码登录 |
| 记住我 | 保持登录状态 | Token有效期7天/30天 |
| 图形验证码 | 登录失败3次后出现 | 防止暴力破解 |
| 错误提示 | 友好的错误提示 | 不暴露具体信息 |
1.2.3 系统优化需求
验证码忽略大小写
// 验证码校验工具类
public class CaptchaValidator {
/**
* 验证验证码(忽略大小写)
* @param inputCode 用户输入的验证码
* @param storeCode 实际存储的验证码
* @return 是否通过验证
*/
public static boolean validateIgnoreCase(String inputCode, String storeCode) {
if (inputCode == null || storeCode == null) {
return false;
}
return inputCode.equalsIgnoreCase(storeCode);
}
/**
* 生成验证码(统一转为大写存储)
*/
public static String generateCode(int length) {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
}
字体配置更新
# application.yml
blog:
font:
path: /usr/share/fonts/blog/ # Linux服务器路径
# path: C:/Windows/Fonts/blog/ # Windows开发环境
default: Microsoft YaHei.ttf
title-font: FZZJ-HBJW.ttf # 标题字体
content-font: SimSun.ttf # 内容字体
网站基础配置
# application.yml
blog:
site:
title: 我的技术博客
subtitle: 分享编程经验和技术干货
keywords: Java,Spring Boot,Redis,博客
description: 一个专注于后端技术的个人博客
icp: 京ICP备xxxxxx号
author: Your Name
email: your@email.com
seo:
baidu-verify: xxxxxx
google-verify: xxxxxx
个性化主题-追悼模式
/* 追悼模式样式 theme/mourning.css */
.mourning-mode {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
-moz-filter: grayscale(100%);
-ms-filter: grayscale(100%);
-o-filter: grayscale(100%);
}
.mourning-mode .navbar {
background-color: #333 !important;
}
.mourning-mode .footer {
background-color: #222 !important;
}
.mourning-mode a:hover {
color: #999 !important;
}
// 主题切换逻辑
const ThemeManager = {
// 启用追悼模式
enableMourningMode() {
document.body.classList.add('mourning-mode');
localStorage.setItem('theme', 'mourning');
// 通知后端记录设置
axios.post('/api/user/theme', { theme: 'mourning' });
},
// 禁用追悼模式
disableMourningMode() {
document.body.classList.remove('mourning-mode');
localStorage.setItem('theme', 'normal');
axios.post('/api/user/theme', { theme: 'normal' });
},
// 切换追悼模式
toggleMourningMode() {
if (document.body.classList.contains('mourning-mode')) {
this.disableMourningMode();
} else {
this.enableMourningMode();
}
},
// 初始化主题
initTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'mourning') {
document.body.classList.add('mourning-mode');
}
// 特殊日期自动开启(清明节等)
const today = new Date();
if (this.isMourningDate(today)) {
this.enableMourningMode();
}
},
// 判断是否追悼日期
isMourningDate(date) {
const month = date.getMonth() + 1;
const day = date.getDate();
// 清明节(4月4日或5日)
return (month === 4 && (day === 4 || day === 5)) ||
// 512汶川地震纪念日
(month === 5 && day === 12) ||
// 南京大屠杀纪念日
(month === 12 && day === 13);
}
};
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', () => {
ThemeManager.initTheme();
});
1.3 技术选型
| Spring Boot | 后端框架 | 2.7.x |
| Spring Security | 安全认证 | 2.7.x |
| MyBatis Plus | ORM框架 | 3.5.x |
| MySQL | 数据库 | 8.0+ |
| Redis | 缓存/会话 | 7.x |
| JWT | Token认证 | 0.11.x |
| Vue.js | 前端框架 | 3.x |
| Element Plus | UI组件库 | 2.x |
| Axios | HTTP客户端 | 1.x |
二、基础环境搭建
2.1 Ubuntu 环境搭建
2.1.1 安装 JDK 17
# 更新软件源
sudo apt update
# 安装 OpenJDK 17
sudo apt install openjdk-17-jdk -y
# 验证安装
java -version
# 设置 JAVA_HOME
echo 'export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64' >> ~/.bashrc
echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
2.1.2 安装 MySQL 8.0
# 安装 MySQL
sudo apt install mysql-server-8.0 -y
# 启动 MySQL
sudo systemctl start mysql
sudo systemctl enable mysql
# 安全配置
sudo mysql_secure_installation
# 登录 MySQL
sudo mysql -u root -p
# 创建数据库和用户
CREATE DATABASE blog_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'blog_user'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON blog_db.* TO 'blog_user'@'localhost';
FLUSH PRIVILEGES;
2.1.3 安装 Redis
# 安装 Redis
sudo apt install redis-server -y
# 修改配置
sudo nano /etc/redis/redis.conf
# 设置密码(找到 requirepass 取消注释)
requirepass your_redis_password
# 重启 Redis
sudo systemctl restart redis
sudo systemctl enable redis
# 验证安装
redis-cli –version
redis-cli -a your_redis_password ping
2.1.4 安装 Node.js 和 npm
# 使用 nvm 安装(推荐)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
# 安装 Node.js 18
nvm install 18
nvm use 18
nvm alias default 18
# 验证安装
node –version
npm –version
2.1.5 安装 Maven
# 安装 Maven
sudo apt install maven -y
# 验证安装
mvn –version
# 配置阿里云镜像(加速)
sudo nano /etc/maven/settings.xml
# 在 mirrors 标签内添加:
<mirror>
<id>aliyunmaven</id>
<mirrorOf>*</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
2.1.6 安装 Git
# 安装 Git
sudo apt install git -y
# 配置 Git
git config –global user.name "Your Name"
git config –global user.email "your@email.com"
# 生成 SSH 密钥
ssh-keygen -t rsa -b 4096 -C "your@email.com"
cat ~/.ssh/id_rsa.pub
# 复制公钥添加到 GitHub/Gitee
2.2 Windows 环境搭建
2.2.1 安装 JDK 17
- 右键"此电脑" → 属性 → 高级系统设置 → 环境变量
- 新建系统变量 JAVA_HOME = C:\\Program Files\\Java\\jdk-17
- 编辑 Path 变量,添加 %JAVA_HOME%\\bin
2.2.2 安装 MySQL 8.0
CREATE DATABASE blog_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'blog_user'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON blog_db.* TO 'blog_user'@'localhost';
FLUSH PRIVILEGES;
2.2.3 安装 Redis for Windows
requirepass your_redis_password
redis-server –service-install redis.windows.conf
redis-server –service-start
2.2.4 安装 Node.js
node –version
npm –version
2.2.5 安装 Maven
- 新建 MAVEN_HOME = C:\\Maven
- 编辑 Path 添加 %MAVEN_HOME%\\bin
2.2.6 安装 Git
git config –global user.name "Your Name"
git config –global user.email "your@email.com"
ssh-keygen -t rsa -b 4096 -C "your@email.com"
cat ~/.ssh/id_rsa.pub
2.2.7 安装开发工具
# IntelliJ IDEA(推荐)
# 下载地址:https://www.jetbrains.com/idea/download/
# VS Code
# 下载地址:https://code.visualstudio.com/
# Navicat/DBeaver(数据库工具)
# DBeaver 免费版:https://dbeaver.io/download/
三、快速体验框架使用
3.1 框架官网示例
3.1.1 克隆示例项目
# 创建项目目录
mkdir -p ~/projects/blog
cd ~/projects/blog
# 克隆示例项目(以若依为例,可根据实际框架调整)
git clone https://gitee.com/y_project/RuoYi-Vue.git ruoyi-demo
cd ruoyi-demo
3.1.2 导入数据库
# 创建数据库
mysql -u root -p
CREATE DATABASE ry-vue CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ry-vue;
source /path/to/ruoyi-demo/sql/ry_2024xxxx.sql;
3.1.3 修改配置文件
# 编辑配置文件
nano ruoyi-admin/src/main/resources/application-druid.yml
# 修改数据库连接
spring:
datasource:
url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: root
password: your_password
# 修改Redis配置
nano ruoyi-admin/src/main/resources/application.yml
redis:
host: localhost
port: 6379
password: your_redis_password
3.1.4 启动后端
# 编译打包
cd ruoyi-demo
mvn clean package
# 启动服务
cd ruoyi-admin/target
java -jar ruoyi-admin.jar
# 或使用 IDEA 启动
# 打开项目,运行 RuoYiApplication.java
3.1.5 启动前端
# 进入前端目录
cd ruoyi-ui
# 安装依赖(使用淘宝镜像)
npm install –registry=https://registry.npmmirror.com
# 启动开发服务器
npm run dev
# 访问 http://localhost:80
# 默认账号: admin 密码: admin123
3.2 快速理解框架结构
3.2.1 后端结构
ruoyi-demo/
├── ruoyi-admin # 启动模块
├── ruoyi-common # 通用工具类
├── ruoyi-framework # 框架核心
├── ruoyi-generator # 代码生成
├── ruoyi-quartz # 定时任务
├── ruoyi-system # 系统模块
└── ruoyi-ui # 前端模块
3.2.2 前端结构
ruoyi-ui/
├── src/
│ ├── api/ # API接口
│ ├── assets/ # 静态资源
│ ├── components/ # 通用组件
│ ├── layout/ # 布局组件
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理
│ ├── utils/ # 工具类
│ └── views/ # 页面视图
├── public/ # 公共资源
└── package.json # 依赖配置
3.3 生成第一个CRUD
3.3.1 使用代码生成器
- 包名:com.blog.modules
- 模块名:article
- 功能名:文章管理
- 作者:Your Name
3.3.2 导入生成的代码
# 解压下载的代码
unzip ruoyi.zip -d generated-code
# 复制后端代码
cp -r generated-code/main/* src/main/
# 复制前端代码
cp -r generated-code/vue/* ruoyi-ui/src/
# 执行SQL
mysql -u root -p blog_db < generated-code/sql/xxx_menu.sql
3.3.3 测试生成的CRUD
四、数据库表构建
4.1 用户表设计
4.1.1 用户主表 (blog_user)
CREATE TABLE `blog_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`salt` varchar(50) DEFAULT NULL COMMENT '密码盐',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`avatar` varchar(500) DEFAULT NULL COMMENT '头像地址',
`nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
`real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
`gender` tinyint(1) DEFAULT '0' COMMENT '性别(0:未知 1:男 2:女)',
`birthday` date DEFAULT NULL COMMENT '生日',
`signature` varchar(500) DEFAULT NULL COMMENT '个性签名',
`status` tinyint(1) DEFAULT '1' COMMENT '状态(0:禁用 1:正常)',
`login_ip` varchar(50) DEFAULT NULL COMMENT '最后登录IP',
`login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除(0:正常 1:删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`),
UNIQUE KEY `idx_email` (`email`),
UNIQUE KEY `idx_phone` (`phone`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='博客用户表';
4.1.2 用户登录记录表 (blog_user_login_log)
CREATE TABLE `blog_user_login_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`login_type` tinyint(1) DEFAULT '1' COMMENT '登录类型(1:密码登录 2:验证码登录)',
`ip` varchar(50) DEFAULT NULL COMMENT '登录IP',
`location` varchar(100) DEFAULT NULL COMMENT '登录地点',
`device` varchar(100) DEFAULT NULL COMMENT '登录设备',
`browser` varchar(50) DEFAULT NULL COMMENT '浏览器',
`os` varchar(50) DEFAULT NULL COMMENT '操作系统',
`status` tinyint(1) DEFAULT '1' COMMENT '状态(1:成功 0:失败)',
`message` varchar(255) DEFAULT NULL COMMENT '消息',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户登录日志表';
4.1.3 验证码记录表 (blog_captcha)
CREATE TABLE `blog_captcha` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`uuid` varchar(100) NOT NULL COMMENT '唯一标识',
`code` varchar(10) NOT NULL COMMENT '验证码',
`type` tinyint(1) DEFAULT '1' COMMENT '类型(1:图形验证码 2:短信验证码 3:邮箱验证码)',
`target` varchar(100) DEFAULT NULL COMMENT '目标(手机号/邮箱)',
`expire_time` datetime NOT NULL COMMENT '过期时间',
`status` tinyint(1) DEFAULT '1' COMMENT '状态(1:未使用 0:已使用)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_uuid` (`uuid`),
KEY `idx_target` (`target`),
KEY `idx_expire_time` (`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='验证码记录表';
4.1.4 用户配置表 (blog_user_config)
CREATE TABLE `blog_user_config` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`theme` varchar(50) DEFAULT 'default' COMMENT '主题(default:默认 dark:暗黑 mourning:追悼)',
`font_size` tinyint(2) DEFAULT '14' COMMENT '字体大小',
`font_family` varchar(50) DEFAULT 'Microsoft YaHei' COMMENT '字体',
`language` varchar(10) DEFAULT 'zh-CN' COMMENT '语言',
`notification` tinyint(1) DEFAULT '1' COMMENT '通知(1:开启 0:关闭)',
`privacy` tinyint(1) DEFAULT '0' COMMENT '隐私模式(0:公开 1:私密)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户配置表';
4.1.5 用户角色表 (blog_role)
CREATE TABLE `blog_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_code` varchar(50) NOT NULL COMMENT '角色编码',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`sort` int(5) DEFAULT '0' COMMENT '排序',
`status` tinyint(1) DEFAULT '1' COMMENT '状态(0:禁用 1:正常)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
— 初始化角色数据
INSERT INTO `blog_role` (`role_code`, `role_name`, `description`, `sort`) VALUES
('admin', '管理员', '系统管理员', 1),
('editor', '编辑', '内容编辑', 2),
('user', '普通用户', '普通注册用户', 3),
('vip', 'VIP用户', '付费用户', 4);
4.1.6 用户角色关联表 (blog_user_role)
CREATE TABLE `blog_user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_role` (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
4.2 数据库构建方式
4.2.1 方式一:手动执行SQL脚本
# 创建数据库
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS blog_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 导入SQL文件
mysql -u root -p blog_db < ~/projects/blog/sql/blog_schema.sql
mysql -u root -p blog_db < ~/projects/blog/sql/blog_data.sql
4.2.2 方式二:使用Flyway自动迁移
<!– pom.xml 添加依赖 –>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>9.16.0</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<version>9.16.0</version>
</dependency>
# application.yml 配置
spring:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
table: flyway_schema_history
创建迁移文件:
# 创建目录
mkdir -p src/main/resources/db/migration
# 创建初始版本 V1__init_schema.sql
nano src/main/resources/db/migration/V1__init_schema.sql
— V1__init_schema.sql
— 用户表
CREATE TABLE IF NOT EXISTS `blog_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
— … 其他字段
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='博客用户表';
— 创建索引
CREATE INDEX idx_username ON blog_user(username);
CREATE INDEX idx_email ON blog_user(email);
# 创建V2__init_data.sql
nano src/main/resources/db/migration/V2__init_data.sql
— V2__init_data.sql
— 初始化角色数据
INSERT INTO `blog_role` (`role_code`, `role_name`, `description`) VALUES
('admin', '管理员', '系统管理员'),
('user', '普通用户', '普通注册用户');
— 初始化管理员账号(密码: admin123)
INSERT INTO `blog_user` (`username`, `password`, `salt`, `email`, `status`) VALUES
('admin', '加密后的密码', '随机salt', 'admin@blog.com', 1);
4.2.3 使用MyBatis-Plus代码生成器
// CodeGenerator.java
package com.blog.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.Collections;
public class CodeGenerator {
public static void main(String[] args) {
String projectPath = System.getProperty("user.dir");
FastAutoGenerator.create("jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8",
"root", "your_password")
.globalConfig(builder -> {
builder.author("Your Name") // 作者
.outputDir(projectPath + "/src/main/java") // 输出路径
.dateType(DateType.ONLY_DATE) // 日期类型
.commentDate("yyyy-MM-dd") // 注释日期
.disableOpenDir(); // 禁止打开输出目录
})
.packageConfig(builder -> {
builder.parent("com.blog") // 父包名
.moduleName("modules") // 模块名
.entity("entity") // 实体类包名
.service("service") // service包名
.serviceImpl("service.impl") // serviceImpl包名
.mapper("mapper") // mapper包名
.controller("controller") // controller包名
.xml("mapper.xml") // mapper.xml包名
.pathInfo(Collections.singletonMap(OutputFile.xml, projectPath + "/src/main/resources/mapper")); // mapper.xml路径
})
.strategyConfig(builder -> {
builder.addInclude("blog_user", "blog_role", "blog_user_role") // 表名
.addTablePrefix("blog_") // 表前缀
.entityBuilder()
.enableLombok() // 启用Lombok
.enableChainModel() // 链式模型
.enableTableFieldAnnotation() // 启用字段注解
.controllerBuilder()
.enableRestStyle() // Rest风格
.enableHyphenStyle() // 连字符风格
.mapperBuilder()
.enableBaseResultMap() // 启用BaseResultMap
.enableBaseColumnList(); // 启用BaseColumnList
})
.templateEngine(new FreemarkerTemplateEngine()) // 模板引擎
.execute();
}
}
五、后端API接口实现与测试
5.1 项目结构
blog-backend/
├── src/main/java/com/blog/
│ ├── BlogApplication.java # 启动类
│ ├── common/ # 公共模块
│ │ ├── constant/ # 常量定义
│ │ ├── exception/ # 异常处理
│ │ ├── result/ # 统一返回结果
│ │ ├── util/ # 工具类
│ │ └── validator/ # 验证器
│ ├── config/ # 配置类
│ │ ├── SecurityConfig.java # 安全配置
│ │ ├── RedisConfig.java # Redis配置
│ │ ├── SwaggerConfig.java # API文档配置
│ │ └── WebConfig.java # Web配置
│ ├── modules/ # 功能模块
│ │ └── auth/ # 认证模块
│ │ ├── controller/ # 控制器
│ │ ├── service/ # 服务层
│ │ ├── mapper/ # 数据访问
│ │ ├── entity/ # 实体类
│ │ ├── dto/ # 数据传输对象
│ │ └── vo/ # 视图对象
│ └── security/ # 安全相关
│ ├── filter/ # 过滤器
│ ├── handler/ # 处理器
│ └── utils/ # 安全工具类
└── src/main/resources/
├── application.yml # 主配置文件
├── application-dev.yml # 开发环境配置
└── mapper/ # MyBatis XML文件
5.2 依赖配置
<!– pom.xml –>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.14</version>
</parent>
<groupId>com.blog</groupId>
<artifactId>blog-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.3</mybatis-plus.version>
<jwt.version>0.11.5</jwt.version>
<fastjson.version>2.0.32</fastjson.version>
</properties>
<dependencies>
<!– Spring Boot Starters –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!– Database –>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!– JWT –>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<!– Tools –>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
<!– API文档 –>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<!– Test –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
5.3 配置文件
# application.yml
spring:
profiles:
active: dev
application:
name: blog–backend
—
# application-dev.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 30000
connection-timeout: 30000
redis:
host: localhost
port: 6379
password: your_redis_password
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
mail:
host: smtp.163.com
port: 465
username: your_email@163.com
password: your_authorization_code
protocol: smtp
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.starttls.enable: true
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.blog.modules.*.entity
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
jwt:
secret: your_jwt_secret_key_here_minimum_32_bytes_long
expiration: 86400000 # 24小时
refresh-expiration: 604800000 # 7天
header: Authorization
token-prefix: Bearer
blog:
site:
title: 我的技术博客
icp: 京ICP备xxxxxx号
font:
path: /usr/share/fonts/blog/
default: Microsoft YaHei.ttf
captcha:
width: 120
height: 40
length: 4
expire: 300 # 5分钟
5.4 统一返回结果
// common/result/Result.java
package com.blog.common.result;
import lombok.Data;
import java.io.Serializable;
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
private int code;
private String msg;
private T data;
private long timestamp;
private Result() {
this.timestamp = System.currentTimeMillis();
}
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> success(String msg, T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMsg(msg);
result.setData(data);
return result;
}
public static <T> Result<T> error() {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg("操作失败");
return result;
}
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMsg(msg);
return result;
}
public static <T> Result<T> error(int code, String msg) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMsg(msg);
return result;
}
public static <T> Result<T> unauthorized() {
Result<T> result = new Result<>();
result.setCode(401);
result.setMsg("未授权");
return result;
}
public static <T> Result<T> forbidden() {
Result<T> result = new Result<>();
result.setCode(403);
result.setMsg("禁止访问");
return result;
}
public boolean isSuccess() {
return this.code == 200;
}
}
// common/result/ResultCode.java
package com.blog.common.result;
public interface ResultCode {
int SUCCESS = 200;
int FAIL = 500;
int UNAUTHORIZED = 401;
int FORBIDDEN = 403;
int NOT_FOUND = 404;
int VALIDATE_FAILED = 400;
String SUCCESS_MSG = "操作成功";
String FAIL_MSG = "操作失败";
String UNAUTHORIZED_MSG = "未授权,请先登录";
String FORBIDDEN_MSG = "没有权限访问";
String NOT_FOUND_MSG = "资源不存在";
String VALIDATE_FAILED_MSG = "参数校验失败";
}
5.5 实体类
// modules/auth/entity/User.java
package com.blog.modules.auth.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
@Data
@Accessors(chain = true)
@TableName("blog_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String salt;
private String email;
private String phone;
private String avatar;
private String nickname;
private String realName;
private Integer gender;
private LocalDate birthday;
private String signature;
private Integer status;
private String loginIp;
private LocalDateTime loginTime;
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
private String updateBy;
@TableField(fill = FieldFill.UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
// 非数据库字段
@TableField(exist = false)
private List<Role> roles;
@TableField(exist = false)
private String token;
}
// modules/auth/dto/LoginRequest.java
package com.blog.modules.auth.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class LoginRequest {
@NotBlank(message = "账号不能为空")
private String account; // 可以是用户名/邮箱/手机号
@NotBlank(message = "密码不能为空")
private String password;
private String captcha;
private String captchaKey;
private Boolean rememberMe;
}
// modules/auth/dto/RegisterRequest.java
package com.blog.modules.auth.dto;
import lombok.Data;
import javax.validation.constraints.*;
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Pattern(regexp = "^[a-zA-Z0-9_]{4,20}$", message = "用户名必须是4-20位字母、数字或下划线")
private String username;
@NotBlank(message = "密码不能为空")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[@#$%^&+=])(?=\\\\S+$).{8,20}$",
message = "密码必须包含字母、数字和特殊字符,长度8-20位")
private String password;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
@Email(message = "邮箱格式不正确")
private String email;
@Pattern(regexp = "^1[3-9]\\\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
private String captcha;
@NotBlank(message = "验证码Key不能为空")
private String captchaKey;
@AssertTrue(message = "必须同意用户协议")
private Boolean agreement;
}
5.6 工具类
// security/utils/JwtUtil.java
package com.blog.security.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Slf4j
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username, expiration);
}
public String generateRefreshToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username, refreshExpiration);
}
private String createToken(Map<String, Object> claims, String subject, Long expiration) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
log.error("Token已过期: {}", e.getMessage());
throw e;
} catch (JwtException e) {
log.error("Token解析失败: {}", e.getMessage());
throw e;
}
}
private Boolean isTokenExpired(String token) {
try {
Date expiration = extractExpiration(token);
return expiration.before(new Date());
} catch (ExpiredJwtException e) {
return true;
}
}
public Boolean validateToken(String token, String username) {
try {
String extractedUsername = extractUsername(token);
return (extractedUsername.equals(username) && !isTokenExpired(token));
} catch (Exception e) {
log.error("Token验证失败: {}", e.getMessage());
return false;
}
}
public Boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
public Long getRemainingTime(String token) {
Date expiration = extractExpiration(token);
long remaining = expiration.getTime() – System.currentTimeMillis();
return remaining > 0 ? remaining : 0;
}
}
// common/util/CaptchaUtil.java
package com.blog.common.util;
import com.wf.captcha.ArithmeticCaptcha;
import com.wf.captcha.GifCaptcha;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class CaptchaUtil {
@Value("${blog.captcha.width:120}")
private int width;
@Value("${blog.captcha.height:40}")
private int height;
@Value("${blog.captcha.length:4}")
private int length;
@Value("${blog.captcha.expire:300}")
private int expire;
private final StringRedisTemplate redisTemplate;
public CaptchaUtil(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 生成图形验证码(标准类型)
*/
public CaptchaInfo generateSpecCaptcha() {
SpecCaptcha captcha = new SpecCaptcha(width, height, length);
return saveCaptcha(captcha);
}
/**
* 生成图形验证码(GIF类型)
*/
public CaptchaInfo generateGifCaptcha() {
GifCaptcha captcha = new GifCaptcha(width, height, length);
return saveCaptcha(captcha);
}
/**
* 生成算术验证码
*/
public CaptchaInfo generateArithmeticCaptcha() {
ArithmeticCaptcha captcha = new ArithmeticCaptcha(width, height);
captcha.setLen(2); // 运算位数
return saveCaptcha(captcha);
}
/**
* 保存验证码到Redis
*/
private CaptchaInfo saveCaptcha(Captcha captcha) {
String key = UUID.randomUUID().toString().replace("-", "");
String code = captcha.text();
// 统一转为大写存储,实现忽略大小写验证
code = code.toUpperCase();
// 存储到Redis
redisTemplate.opsForValue().set(
"captcha:" + key,
code,
expire,
TimeUnit.SECONDS
);
CaptchaInfo info = new CaptchaInfo();
info.setKey(key);
info.setImage(captcha.toBase64());
info.setExpire(expire);
log.debug("验证码生成成功, key: {}, code: {}", key, code);
return info;
}
/**
* 验证验证码(忽略大小写)
*/
public boolean validate(String key, String code) {
if (key == null || code == null) {
return false;
}
String cacheCode = redisTemplate.opsForValue().get("captcha:" + key);
if (cacheCode == null) {
log.debug("验证码已过期或不存在, key: {}", key);
return false;
}
// 删除已使用的验证码
redisTemplate.delete("captcha:" + key);
// 忽略大小写验证
boolean result = cacheCode.equalsIgnoreCase(code);
log.debug("验证码验证, key: {}, input: {}, cache: {}, result: {}",
key, code, cacheCode, result);
return result;
}
/**
* 验证码信息类
*/
@Data
public static class CaptchaInfo {
private String key;
private String image;
private int expire;
}
}
// common/util/PasswordEncoder.java
package com.blog.common.util;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.BCrypt;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class PasswordEncoder {
/**
* 生成BCrypt加密密码
*/
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt());
}
/**
* 验证BCrypt密码
*/
public boolean matches(String rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.isEmpty()) {
return false;
}
return BCrypt.checkpw(rawPassword, encodedPassword);
}
/**
* 生成MD5加密密码(带盐)
*/
public String encodeMd5(String rawPassword, String salt) {
return SecureUtil.md5(rawPassword + salt);
}
/**
* 生成随机盐
*/
public String generateSalt() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
/**
* 验证MD5密码
*/
public boolean matchesMd5(String rawPassword, String salt, String encodedPassword) {
String newEncoded = encodeMd5(rawPassword, salt);
return newEncoded.equals(encodedPassword);
}
}
5.7 Service层实现
// modules/auth/service/UserService.java
package com.blog.modules.auth.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.blog.modules.auth.dto.LoginRequest;
import com.blog.modules.auth.dto.RegisterRequest;
import com.blog.modules.auth.entity.User;
import com.blog.modules.auth.vo.LoginVO;
public interface UserService extends IService<User> {
/**
* 用户注册
*/
User register(RegisterRequest request);
/**
* 用户登录
*/
LoginVO login(LoginRequest request);
/**
* 刷新Token
*/
String refreshToken(String refreshToken);
/**
* 退出登录
*/
void logout(String token);
/**
* 根据用户名查询用户
*/
User getByUsername(String username);
/**
* 根据邮箱查询用户
*/
User getByEmail(String email);
/**
* 根据手机号查询用户
*/
User getByPhone(String phone);
/**
* 检查用户名是否存在
*/
boolean checkUsernameExists(String username);
/**
* 检查邮箱是否存在
*/
boolean checkEmailExists(String email);
/**
* 检查手机号是否存在
*/
boolean checkPhoneExists(String phone);
/**
* 更新最后登录信息
*/
void updateLoginInfo(Long userId, String ip);
/**
* 修改密码
*/
boolean changePassword(Long userId, String oldPassword, String newPassword);
/**
* 重置密码
*/
boolean resetPassword(String email, String newPassword);
}
// modules/auth/service/impl/UserServiceImpl.java
package com.blog.modules.auth.service.impl;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blog.common.exception.BusinessException;
import com.blog.common.util.CaptchaUtil;
import com.blog.common.util.PasswordEncoder;
import com.blog.modules.auth.dto.LoginRequest;
import com.blog.modules.auth.dto.RegisterRequest;
import com.blog.modules.auth.entity.User;
import com.blog.modules.auth.entity.UserLoginLog;
import com.blog.modules.auth.mapper.UserMapper;
import com.blog.modules.auth.service.UserLoginLogService;
import com.blog.modules.auth.service.UserService;
import com.blog.modules.auth.vo.LoginVO;
import com.blog.security.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final CaptchaUtil captchaUtil;
private final StringRedisTemplate redisTemplate;
private final UserLoginLogService loginLogService;
private final HttpServletRequest request;
@Override
@Transactional(rollbackFor = Exception.class)
public User register(RegisterRequest req) {
log.info("用户注册: {}", req.getUsername());
// 验证码校验
if (!captchaUtil.validate(req.getCaptchaKey(), req.getCaptcha())) {
throw new BusinessException("验证码错误");
}
// 密码一致性校验
if (!req.getPassword().equals(req.getConfirmPassword())) {
throw new BusinessException("两次输入的密码不一致");
}
// 检查用户名是否存在
if (checkUsernameExists(req.getUsername())) {
throw new BusinessException("用户名已存在");
}
// 检查邮箱是否存在
if (StrUtil.isNotBlank(req.getEmail()) && checkEmailExists(req.getEmail())) {
throw new BusinessException("邮箱已被注册");
}
// 检查手机号是否存在
if (StrUtil.isNotBlank(req.getPhone()) && checkPhoneExists(req.getPhone())) {
throw new BusinessException("手机号已被注册");
}
// 创建新用户
User user = new User();
user.setUsername(req.getUsername());
user.setPassword(passwordEncoder.encode(req.getPassword()));
user.setEmail(req.getEmail());
user.setPhone(req.getPhone());
user.setNickname(req.getUsername());
user.setStatus(1); // 正常状态
user.setCreateTime(LocalDateTime.now());
// 保存用户
save(user);
// 分配默认角色(普通用户)
// userRoleService.assignDefaultRole(user.getId());
log.info("用户注册成功: {}, id: {}", req.getUsername(), user.getId());
return user;
}
@Override
public LoginVO login(LoginRequest req) {
log.info("用户登录: {}", req.getAccount());
// 登录失败次数检查
String loginFailKey = "login:fail:" + req.getAccount();
String failCountStr = redisTemplate.opsForValue().get(loginFailKey);
int failCount = failCountStr == null ? 0 : Integer.parseInt(failCountStr);
// 失败超过3次需要验证码
if (failCount >= 3) {
if (StrUtil.isBlank(req.getCaptcha()) || StrUtil.isBlank(req.getCaptchaKey())) {
throw new BusinessException("请输入验证码");
}
if (!captchaUtil.validate(req.getCaptchaKey(), req.getCaptcha())) {
throw new BusinessException("验证码错误");
}
}
// 查询用户
User user = null;
if (req.getAccount().contains("@")) {
user = getByEmail(req.getAccount());
} else if (req.getAccount().matches("^1[3-9]\\\\d{9}$")) {
user = getByPhone(req.getAccount());
} else {
user = getByUsername(req.getAccount());
}
// 用户不存在或密码错误
if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPassword())) {
// 记录失败次数
redisTemplate.opsForValue().set(loginFailKey, String.valueOf(failCount + 1), 30, TimeUnit.MINUTES);
throw new BusinessException("用户名或密码错误");
}
// 检查用户状态
if (user.getStatus() != 1) {
throw new BusinessException("账号已被禁用");
}
// 登录成功,清除失败记录
redisTemplate.delete(loginFailKey);
// 生成Token
String token = jwtUtil.generateToken(user.getUsername());
String refreshToken = jwtUtil.generateRefreshToken(user.getUsername());
// 更新登录信息
String ip = getClientIp();
updateLoginInfo(user.getId(), ip);
// 记录登录日志
UserLoginLog loginLog = new UserLoginLog();
loginLog.setUserId(user.getId());
loginLog.setUsername(user.getUsername());
loginLog.setIp(ip);
loginLog.setStatus(1);
loginLog.setCreateTime(LocalDateTime.now());
loginLogService.save(loginLog);
// 返回登录信息
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(jwtUtil.getRemainingTime(token));
vo.setUser(user);
log.info("用户登录成功: {}, id: {}", req.getAccount(), user.getId());
return vo;
}
@Override
public String refreshToken(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new BusinessException("刷新Token无效或已过期");
}
String username = jwtUtil.extractUsername(refreshToken);
User user = getByUsername(username);
if (user == null || user.getStatus() != 1) {
throw new BusinessException("用户不存在或已被禁用");
}
return jwtUtil.generateToken(username);
}
@Override
public void logout(String token) {
// 将Token加入黑名单
if (StrUtil.isNotBlank(token)) {
long remaining = jwtUtil.getRemainingTime(token);
if (remaining > 0) {
redisTemplate.opsForValue().set("blacklist:token:" + token, "1", remaining, TimeUnit.MILLISECONDS);
}
}
log.info("用户退出登录");
}
@Override
public User getByUsername(String username) {
return getOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username)
.eq(User::getDeleted, 0));
}
@Override
public User getByEmail(String email) {
return getOne(new LambdaQueryWrapper<User>()
.eq(User::getEmail, email)
.eq(User::getDeleted, 0));
}
@Override
public User getByPhone(String phone) {
return getOne(new LambdaQueryWrapper<User>()
.eq(User::getPhone, phone)
.eq(User::getDeleted, 0));
}
@Override
public boolean checkUsernameExists(String username) {
return count(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username)) > 0;
}
@Override
public boolean checkEmailExists(String email) {
return count(new LambdaQueryWrapper<User>()
.eq(User::getEmail, email)) > 0;
}
@Override
public boolean checkPhoneExists(String phone) {
return count(new LambdaQueryWrapper<User>()
.eq(User::getPhone, phone)) > 0;
}
@Override
public void updateLoginInfo(Long userId, String ip) {
User user = new User();
user.setId(userId);
user.setLoginIp(ip);
user.setLoginTime(LocalDateTime.now());
updateById(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changePassword(Long userId, String oldPassword, String newPassword) {
User user = getById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new BusinessException("原密码错误");
}
user.setPassword(passwordEncoder.encode(newPassword));
return updateById(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean resetPassword(String email, String newPassword) {
User user = getByEmail(email);
if (user == null) {
throw new BusinessException("邮箱不存在");
}
user.setPassword(passwordEncoder.encode(newPassword));
return updateById(user);
}
/**
* 获取客户端IP
*/
private String getClientIp() {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
5.8 Controller层实现
// modules/auth/controller/AuthController.java
package com.blog.modules.auth.controller;
import com.blog.common.result.Result;
import com.blog.common.util.CaptchaUtil;
import com.blog.modules.auth.dto.LoginRequest;
import com.blog.modules.auth.dto.RegisterRequest;
import com.blog.modules.auth.service.UserService;
import com.blog.modules.auth.vo.LoginVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
@Slf4j
@Api(tags = "认证管理")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final CaptchaUtil captchaUtil;
@ApiOperation("获取验证码")
@GetMapping("/captcha")
public Result<CaptchaUtil.CaptchaInfo> getCaptcha() {
CaptchaUtil.CaptchaInfo captcha = captchaUtil.generateSpecCaptcha();
return Result.success(captcha);
}
@ApiOperation("用户注册")
@PostMapping("/register")
public Result<?> register(@Valid @RequestBody RegisterRequest request) {
userService.register(request);
return Result.success("注册成功");
}
@ApiOperation("用户登录")
@PostMapping("/login")
public Result<LoginVO> login(@Valid @RequestBody LoginRequest request) {
LoginVO vo = userService.login(request);
return Result.success("登录成功", vo);
}
@ApiOperation("刷新Token")
@PostMapping("/refresh")
public Result<String> refreshToken(@RequestParam String refreshToken) {
String newToken = userService.refreshToken(refreshToken);
return Result.success(newToken);
}
@ApiOperation("退出登录")
@PostMapping("/logout")
public Result<?> logout(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
userService.logout(token);
return Result.success("退出成功");
}
@ApiOperation("检查用户名是否存在")
@GetMapping("/check/username")
public Result<Boolean> checkUsername(@RequestParam String username) {
boolean exists = userService.checkUsernameExists(username);
return Result.success(exists);
}
@ApiOperation("检查邮箱是否存在")
@GetMapping("/check/email")
public Result<Boolean> checkEmail(@RequestParam String email) {
boolean exists = userService.checkEmailExists(email);
return Result.success(exists);
}
@ApiOperation("检查手机号是否存在")
@GetMapping("/check/phone")
public Result<Boolean> checkPhone(@RequestParam String phone) {
boolean exists = userService.checkPhoneExists(phone);
return Result.success(exists);
}
}
5.9 测试用例
// src/test/java/com/blog/modules/auth/AuthControllerTest.java
package com.blog.modules.auth;
import com.alibaba.fastjson.JSON;
import com.blog.modules.auth.dto.LoginRequest;
import com.blog.modules.auth.dto.RegisterRequest;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Slf4j
@SpringBootTest
@AutoConfigureMockMvc
public class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testGetCaptcha() throws Exception {
MvcResult result = mockMvc.perform(get("/api/auth/captcha"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.key").exists())
.andExpect(jsonPath("$.data.image").exists())
.andReturn();
log.info("验证码响应: {}", result.getResponse().getContentAsString());
}
@Test
public void testRegister() throws Exception {
// 先获取验证码
MvcResult captchaResult = mockMvc.perform(get("/api/auth/captcha"))
.andReturn();
String captchaResponse = captchaResult.getResponse().getContentAsString();
// 解析验证码key和图片(实际测试时需要从响应中提取)
String captchaKey = "test-key"; // 这里应该是实际获取的key
String captchaCode = "1234"; // 这里应该是实际验证码
RegisterRequest request = new RegisterRequest();
request.setUsername("testuser");
request.setPassword("Test@123456");
request.setConfirmPassword("Test@123456");
request.setEmail("test@example.com");
request.setCaptchaKey(captchaKey);
request.setCaptcha(captchaCode);
request.setAgreement(true);
MvcResult result = mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(JSON.toJSONString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
log.info("注册响应: {}", result.getResponse().getContentAsString());
}
@Test
public void testLogin() throws Exception {
LoginRequest request = new LoginRequest();
request.setAccount("testuser");
request.setPassword("Test@123456");
request.setRememberMe(true);
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(JSON.toJSONString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.token").exists())
.andReturn();
log.info("登录响应: {}", result.getResponse().getContentAsString());
}
@Test
public void testCheckUsername() throws Exception {
MvcResult result = mockMvc.perform(get("/api/auth/check/username")
.param("username", "testuser"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
log.info("检查用户名响应: {}", result.getResponse().getContentAsString());
}
}
5.10 使用Postman测试
// 1. 获取验证码
GET http://localhost:8080/api/auth/captcha
// 响应示例
{
"code": 200,
"msg": "操作成功",
"data": {
"key": "a1b2c3d4e5f6",
"image": "data:image/png;base64,iVBORw0KGgo…",
"expire": 300
},
"timestamp": 1701234567890
}
// 2. 用户注册
POST http://localhost:8080/api/auth/register
Content–Type: application/json
{
"username": "john123",
"password": "Pass@123456",
"confirmPassword": "Pass@123456",
"email": "john@example.com",
"phone": "13800138000",
"captchaKey": "a1b2c3d4e5f6",
"captcha": "ABCD",
"agreement": true
}
// 3. 用户登录
POST http://localhost:8080/api/auth/login
Content–Type: application/json
{
"account": "john123",
"password": "Pass@123456",
"rememberMe": true
}
// 响应示例
{
"code": 200,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9…",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9…",
"expiresIn": 86400000,
"user": {
"id": 1,
"username": "john123",
"email": "john@example.com",
"phone": "13800138000",
"nickname": "john123",
"avatar": null,
"status": 1
}
},
"timestamp": 1701234567890
}
// 4. 刷新Token
POST http://localhost:8080/api/auth/refresh?refreshToken=eyJhbGciOiJIUzI1NiJ9…
// 5. 退出登录
POST http://localhost:8080/api/auth/logout
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…
六、前端代码环境搭建和编写
6.1 前端项目初始化
# 创建项目目录
mkdir -p ~/projects/blog-frontend
cd ~/projects/blog-frontend
# 使用Vite创建Vue3项目
npm create vite@latest . — –template vue
# 安装依赖
npm install
# 安装Element Plus
npm install element-plus
# 安装路由
npm install vue-router@4
# 安装状态管理
npm install pinia
# 安装HTTP客户端
npm install axios
# 安装工具库
npm install dayjs
npm install lodash-es
npm install @vueuse/core
# 安装验证码组件
npm install vue3-verify-code
# 安装开发依赖
npm install -D sass
npm install -D @types/node
6.2 项目结构
blog-frontend/
├── public/ # 静态资源
│ ├── favicon.ico # 网站图标
│ └── index.html # 入口HTML
├── src/
│ ├── api/ # API接口
│ │ ├── auth.js # 认证相关
│ │ ├── user.js # 用户相关
│ │ └── request.js # axios配置
│ ├── assets/ # 资源文件
│ │ ├── fonts/ # 字体文件
│ │ ├── images/ # 图片
│ │ └── styles/ # 全局样式
│ │ ├── index.scss # 入口样式
│ │ ├── theme.scss # 主题样式
│ │ └── mourning.scss # 追悼模式样式
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件
│ │ │ ├── Captcha.vue # 验证码组件
│ │ │ └── ThemeSwitch.vue # 主题切换
│ │ └── layout/ # 布局组件
│ │ ├── Header.vue # 头部
│ │ ├── Footer.vue # 底部
│ │ └── Sidebar.vue # 侧边栏
│ ├── composables/ # 组合式函数
│ │ ├── useTheme.js # 主题管理
│ │ ├── useAuth.js # 认证管理
│ │ └── useCaptcha.js # 验证码管理
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── stores/ # Pinia状态
│ │ ├── user.js # 用户状态
│ │ └── theme.js # 主题状态
│ ├── utils/ # 工具函数
│ │ ├── auth.js # 认证工具
│ │ ├── storage.js # 存储工具
│ │ └── validate.js # 验证工具
│ ├── views/ # 页面视图
│ │ ├── auth/ # 认证相关
│ │ │ ├── Login.vue # 登录页
│ │ │ └── Register.vue # 注册页
│ │ ├── home/ # 首页
│ │ │ └── Index.vue
│ │ └── user/ # 用户中心
│ │ ├── Profile.vue
│ │ └── Settings.vue
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── index.html # 模板文件
├── vite.config.js # Vite配置
└── package.json # 依赖配置
6.3 环境变量配置
# .env.development
VITE_APP_TITLE=博客系统-开发版
VITE_APP_BASE_API=/api
VITE_APP_BASE_URL=http://localhost:8080
VITE_APP_CAPTCHA_IGNORE_CASE=true
# .env.production
VITE_APP_TITLE=我的技术博客
VITE_APP_BASE_API=/api
VITE_APP_BASE_URL=https://api.yourblog.com
VITE_APP_CAPTCHA_IGNORE_CASE=true
6.4 入口文件配置
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './assets/styles/index.scss'
// 创建应用
const app = createApp(App)
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
size: 'default',
zIndex: 3000
})
// 挂载应用
app.mount('#app')
6.5 路由配置
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/',
name: 'home',
component: () => import('@/views/home/Index.vue')
},
{
path: '/auth',
name: 'auth',
redirect: '/auth/login',
children: [
{
path: 'login',
name: 'login',
component: () => import('@/views/auth/Login.vue'),
meta: { requiresGuest: true }
},
{
path: 'register',
name: 'register',
component: () => import('@/views/auth/Register.vue'),
meta: { requiresGuest: true }
}
]
},
{
path: '/user',
name: 'user',
component: () => import('@/views/user/Profile.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'profile',
name: 'profile',
component: () => import('@/views/user/Profile.vue')
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/user/Settings.vue')
}
]
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const token = userStore.token
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} – ${import.meta.env.VITE_APP_TITLE}`
} else {
document.title = import.meta.env.VITE_APP_TITLE
}
// 需要认证的页面
if (to.meta.requiresAuth && !token) {
next({ name: 'login', query: { redirect: to.fullPath } })
}
// 需要游客的页面(已登录用户不能访问)
else if (to.meta.requiresGuest && token) {
next({ name: 'home' })
}
else {
next()
}
})
export default router
6.6 状态管理
// src/stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login, register, logout, getInfo } from '@/api/auth'
import { removeToken, setToken, getToken } from '@/utils/auth'
export const useUserStore = defineStore('user', () => {
// state
const token = ref(getToken())
const userInfo = ref(null)
const roles = ref([])
const permissions = ref([])
// getters
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.username || '')
const avatar = computed(() => userInfo.value?.avatar || '/default-avatar.png')
// actions
function setUserToken(userToken) {
token.value = userToken
setToken(userToken)
}
async function loginAction(loginData) {
try {
const res = await login(loginData)
const { token, refreshToken, user } = res.data
setUserToken(token)
userInfo.value = user
return res
} catch (error) {
return Promise.reject(error)
}
}
async function registerAction(registerData) {
return await register(registerData)
}
async function logoutAction() {
await logout()
token.value = null
userInfo.value = null
roles.value = []
permissions.value = []
removeToken()
}
async function getUserInfo() {
try {
const res = await getInfo()
userInfo.value = res.data.user
roles.value = res.data.roles || []
permissions.value = res.data.permissions || []
return res
} catch (error) {
return Promise.reject(error)
}
}
return {
token,
userInfo,
roles,
permissions,
isLoggedIn,
username,
avatar,
loginAction,
registerAction,
logoutAction,
getUserInfo,
setUserToken
}
})
// src/stores/theme.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const useThemeStore = defineStore('theme', () => {
// state
const theme = ref(localStorage.getItem('theme') || 'light')
const mourningMode = ref(localStorage.getItem('mourningMode') === 'true')
// 监听主题变化
watch(theme, (newTheme) => {
localStorage.setItem('theme', newTheme)
applyTheme(newTheme)
})
watch(mourningMode, (newValue) => {
localStorage.setItem('mourningMode', newValue)
applyMourningMode(newValue)
})
// actions
function setTheme(newTheme) {
theme.value = newTheme
}
function toggleMourningMode() {
mourningMode.value = !mourningMode.value
}
function enableMourningMode() {
mourningMode.value = true
}
function disableMourningMode() {
mourningMode.value = false
}
// 应用主题
function applyTheme(themeName) {
document.documentElement.setAttribute('data-theme', themeName)
}
// 应用追悼模式
function applyMourningMode(enabled) {
if (enabled) {
document.body.classList.add('mourning-mode')
} else {
document.body.classList.remove('mourning-mode')
}
}
// 初始化主题
function initTheme() {
applyTheme(theme.value)
applyMourningMode(mourningMode.value)
// 检查是否追悼日期
checkMourningDate()
}
// 检查追悼日期
function checkMourningDate() {
const today = new Date()
const month = today.getMonth() + 1
const day = today.getDate()
// 清明节(4月4日或5日)
const isQingming = month === 4 && (day === 4 || day === 5)
// 512汶川地震纪念日
const isWenchuan = month === 5 && day === 12
// 南京大屠杀纪念日
const isNanjing = month === 12 && day === 13
if (isQingming || isWenchuan || isNanjing) {
enableMourningMode()
}
}
return {
theme,
mourningMode,
setTheme,
toggleMourningMode,
enableMourningMode,
disableMourningMode,
initTheme
}
})
6.7 API配置
// src/api/request.js
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
// 创建axios实例
const request = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_URL + import.meta.env.VITE_APP_BASE_API,
timeout: 30000,
withCredentials: true
})
// 请求拦截器
request.interceptors.request.use(
config => {
const userStore = useUserStore()
const token = userStore.token
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = { …config.params, _t: Date.now() }
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
response => {
const res = response.data
// 如果响应不是200,说明出错了
if (res.code !== 200) {
ElMessage({
message: res.msg || '系统繁忙,请稍后重试',
type: 'error',
duration: 5 * 1000
})
// 401: 未登录或Token过期
if (res.code === 401) {
ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const userStore = useUserStore()
userStore.logoutAction()
router.push('/auth/login')
})
}
return Promise.reject(new Error(res.msg || 'Error'))
} else {
return res
}
},
error => {
console.log('err' + error)
let message = error.message || '请求失败'
if (error.response) {
switch (error.response.status) {
case 400:
message = '请求错误'
break
case 401:
message = '未授权,请登录'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址不存在'
break
case 408:
message = '请求超时'
break
case 500:
message = '服务器内部错误'
break
default:
message = `连接错误${error.response.status}`
}
}
ElMessage({
message: message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default request
// src/api/auth.js
import request from './request'
// 登录
export function login(data) {
return request({
url: '/auth/login',
method: 'post',
data
})
}
// 注册
export function register(data) {
return request({
url: '/auth/register',
method: 'post',
data
})
}
// 退出登录
export function logout() {
return request({
url: '/auth/logout',
method: 'post'
})
}
// 获取验证码
export function getCaptcha() {
return request({
url: '/auth/captcha',
method: 'get'
})
}
// 刷新Token
export function refreshToken(refreshToken) {
return request({
url: '/auth/refresh',
method: 'post',
params: { refreshToken }
})
}
// 检查用户名是否存在
export function checkUsername(username) {
return request({
url: '/auth/check/username',
method: 'get',
params: { username }
})
}
// 检查邮箱是否存在
export function checkEmail(email) {
return request({
url: '/auth/check/email',
method: 'get',
params: { email }
})
}
// 检查手机号是否存在
export function checkPhone(phone) {
return request({
url: '/auth/check/phone',
method: 'get',
params: { phone }
})
}
6.8 工具函数
// src/utils/auth.js
const TokenKey = 'Admin-Token'
export function getToken() {
return localStorage.getItem(TokenKey)
}
export function setToken(token) {
return localStorage.setItem(TokenKey, token)
}
export function removeToken() {
return localStorage.removeItem(TokenKey)
}
// src/utils/validate.js
// 验证用户名
export function validateUsername(rule, value, callback) {
const reg = /^[a-zA-Z0-9_]{4,20}$/
if (value && !reg.test(value)) {
callback(new Error('用户名必须是4-20位字母、数字或下划线'))
} else {
callback()
}
}
// 验证密码
export function validatePassword(rule, value, callback) {
const reg = /^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[@#$%^&+=])(?=\\S+$).{8,20}$/
if (value && !reg.test(value)) {
callback(new Error('密码必须包含字母、数字和特殊字符,长度8-20位'))
} else {
callback()
}
}
// 验证确认密码
export function validateConfirmPassword(password) {
return (rule, value, callback) => {
if (value !== password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
}
// 验证邮箱
export function validateEmail(rule, value, callback) {
const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/
if (value && !reg.test(value)) {
callback(new Error('邮箱格式不正确'))
} else {
callback()
}
}
// 验证手机号
export function validatePhone(rule, value, callback) {
const reg = /^1[3-9]\\d{9}$/
if (value && !reg.test(value)) {
callback(new Error('手机号格式不正确'))
} else {
callback()
}
}
6.9 组合式函数
// src/composables/useAuth.js
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { login, register, getCaptcha } from '@/api/auth'
export function useAuth() {
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
// 获取验证码
const fetchCaptcha = async () => {
try {
const res = await getCaptcha()
captchaKey.value = res.data.key
captchaImage.value = res.data.image
} catch (error) {
console.error('获取验证码失败:', error)
}
}
// 登录
const handleLogin = async (loginForm) => {
loading.value = true
try {
await userStore.loginAction(loginForm)
ElMessage.success('登录成功')
router.push('/')
return true
} catch (error) {
// 刷新验证码
await fetchCaptcha()
return false
} finally {
loading.value = false
}
}
// 注册
const handleRegister = async (registerForm) => {
loading.value = true
try {
await userStore.registerAction(registerForm)
ElMessage.success('注册成功,请登录')
router.push('/auth/login')
return true
} catch (error) {
// 刷新验证码
await fetchCaptcha()
return false
} finally {
loading.value = false
}
}
return {
loading,
captchaKey,
captchaImage,
fetchCaptcha,
handleLogin,
handleRegister
}
}
// src/composables/useCaptcha.js
import { ref } from 'vue'
import { getCaptcha, verifyCaptcha } from '@/api/auth'
export function useCaptcha() {
const captchaKey = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const loading = ref(false)
// 刷新验证码
const refreshCaptcha = async () => {
loading.value = true
try {
const res = await getCaptcha()
captchaKey.value = res.data.key
captchaImage.value = res.data.image
captchaCode.value = ''
} catch (error) {
console.error('刷新验证码失败:', error)
} finally {
loading.value = false
}
}
// 验证验证码
const validateCaptcha = async () => {
if (!captchaCode.value) {
return false
}
try {
const res = await verifyCaptcha({
key: captchaKey.value,
code: captchaCode.value
})
return res.data
} catch (error) {
return false
}
}
return {
captchaKey,
captchaImage,
captchaCode,
loading,
refreshCaptcha,
validateCaptcha
}
}
6.10 登录页面
<!– src/views/auth/Login.vue –>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>{{ siteTitle }}</h2>
<p>欢迎回来,请登录您的账号</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
@submit.prevent
>
<el-form-item label="账号" prop="account">
<el-input
v-model="loginForm.account"
placeholder="请输入用户名/邮箱/手机号"
:prefix-icon="User"
size="large"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<!– 验证码(登录失败3次后显示) –>
<el-form-item v-if="showCaptcha" label="验证码" prop="captcha">
<div class="captcha-container">
<el-input
v-model="loginForm.captcha"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
/>
<div class="captcha-image" @click="refreshCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码">
<span v-else>点击刷新</span>
</div>
</div>
</el-form-item>
<el-form-item>
<div class="form-options">
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码?</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中…' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>还没有账号?</span>
<el-link type="primary" @click="goToRegister">立即注册</el-link>
</div>
<!– 追悼模式开关 –>
<div class="theme-switch">
<el-switch
v-model="mourningMode"
active-text="追悼模式"
inactive-text="正常模式"
@change="toggleMourningMode"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Key } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { useAuth } from '@/composables/useAuth'
import { validatePassword } from '@/utils/validate'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const { loading, captchaKey, captchaImage, fetchCaptcha, handleLogin: authLogin } = useAuth()
// 站点标题
const siteTitle = import.meta.env.VITE_APP_TITLE
// 追悼模式
const mourningMode = computed({
get: () => themeStore.mourningMode,
set: (val) => {
if (val) {
themeStore.enableMourningMode()
} else {
themeStore.disableMourningMode()
}
}
})
// 是否显示验证码
const showCaptcha = ref(false)
const loginFailCount = ref(0)
// 登录表单
const loginFormRef = ref()
const loginForm = reactive({
account: '',
password: '',
captcha: '',
captchaKey: '',
rememberMe: true
})
// 登录规则
const loginRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 50, message: '长度在 4 到 50 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePassword, trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 4, message: '验证码长度为4位', trigger: 'blur' }
]
}
// 刷新验证码
const refreshCaptcha = async () => {
await fetchCaptcha()
loginForm.captchaKey = captchaKey.value
}
// 登录处理
const handleLogin = async () => {
await loginFormRef.value.validate()
// 如果显示验证码,验证是否已填写
if (showCaptcha.value && !loginForm.captcha) {
ElMessage.warning('请输入验证码')
return
}
const success = await authLogin(loginForm)
if (!success) {
loginFailCount.value++
if (loginFailCount.value >= 3) {
showCaptcha.value = true
await refreshCaptcha()
}
}
}
// 跳转到注册
const goToRegister = () => {
router.push('/auth/register')
}
// 切换追悼模式
const toggleMourningMode = (val) => {
if (val) {
themeStore.enableMourningMode()
} else {
themeStore.disableMourningMode()
}
}
// 页面加载时检查登录状态
onMounted(() => {
if (userStore.isLoggedIn) {
router.push('/')
}
// 初始化验证码(如果需要)
// refreshCaptcha()
})
</script>
<style scoped lang="scss">
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 450px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
position: relative;
.login-header {
text-align: center;
margin-bottom: 30px;
h2 {
font-size: 28px;
color: #333;
margin-bottom: 8px;
}
p {
color: #666;
font-size: 14px;
}
}
.captcha-container {
display: flex;
gap: 10px;
.el-input {
flex: 1;
}
.captcha-image {
width: 120px;
height: 40px;
background: #f0f2f5;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #999;
font-size: 12px;
}
}
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.el-button {
width: 100%;
}
.login-footer {
text-align: center;
margin-top: 20px;
font-size: 14px;
color: #666;
.el-link {
font-size: 14px;
}
}
.theme-switch {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 12px;
}
}
// 追悼模式样式
:global(.mourning-mode) {
.login-card {
background: rgba(51, 51, 51, 0.95);
.login-header {
h2, p {
color: #999;
}
}
.el-form-item__label {
color: #999;
}
}
}
</style>
6.11 注册页面
<!– src/views/auth/Register.vue –>
<template>
<div class="register-container">
<div class="register-card">
<div class="register-header">
<h2>{{ siteTitle }}</h2>
<p>注册新账号,开启技术之旅</p>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-position="top"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="registerForm.username"
placeholder="4-20位字母、数字或下划线"
:prefix-icon="User"
size="large"
clearable
@blur="checkUsername"
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱"
:prefix-icon="Message"
size="large"
clearable
@blur="checkEmail"
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号(可选)"
:prefix-icon="Iphone"
size="large"
clearable
@blur="checkPhone"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="8-20位,包含字母、数字和特殊字符"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请再次输入密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item label="验证码" prop="captcha" required>
<div class="captcha-container">
<el-input
v-model="registerForm.captcha"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
/>
<div class="captcha-image" @click="refreshCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码">
<span v-else>点击刷新</span>
</div>
</div>
</el-form-item>
<el-form-item prop="agreement">
<el-checkbox v-model="registerForm.agreement">
我已阅读并同意
<el-link type="primary" :underline="false">《用户协议》</el-link>
和
<el-link type="primary" :underline="false">《隐私政策》</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleRegister"
>
{{ loading ? '注册中…' : '注册' }}
</el-button>
</el-form-item>
</el-form>
<div class="register-footer">
<span>已有账号?</span>
<el-link type="primary" @click="goToLogin">立即登录</el-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Message, Iphone, Key } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { useAuth } from '@/composables/useAuth'
import {
validateUsername,
validatePassword,
validateConfirmPassword,
validateEmail,
validatePhone
} from '@/utils/validate'
import { checkUsername, checkEmail, checkPhone } from '@/api/auth'
const router = useRouter()
const userStore = useUserStore()
const { loading, captchaKey, captchaImage, fetchCaptcha, handleRegister: authRegister } = useAuth()
// 站点标题
const siteTitle = import.meta.env.VITE_APP_TITLE
// 注册表单
const registerFormRef = ref()
const registerForm = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
captcha: '',
captchaKey: '',
agreement: false
})
// 注册规则
const registerRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ validator: validateUsername, trigger: 'blur' },
{ validator: async (rule, value, callback) => {
if (value) {
try {
const res = await checkUsername(value)
if (res.data) {
callback(new Error('用户名已存在'))
} else {
callback()
}
} catch (error) {
callback()
}
} else {
callback()
}
}, trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ validator: validateEmail, trigger: 'blur' },
{ validator: async (rule, value, callback) => {
if (value) {
try {
const res = await checkEmail(value)
if (res.data) {
callback(new Error('邮箱已被注册'))
} else {
callback()
}
} catch (error) {
callback()
}
} else {
callback()
}
}, trigger: 'blur' }
],
phone: [
{ validator: validatePhone, trigger: 'blur' },
{ validator: async (rule, value, callback) => {
if (value) {
try {
const res = await checkPhone(value)
if (res.data) {
callback(new Error('手机号已被注册'))
} else {
callback()
}
} catch (error) {
callback()
}
} else {
callback()
}
}, trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePassword, trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword(registerForm.password), trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ min: 4, max: 4, message: '验证码长度为4位', trigger: 'blur' }
],
agreement: [
{ required: true, message: '请同意用户协议', trigger: 'change' }
]
}
// 刷新验证码
const refreshCaptcha = async () => {
await fetchCaptcha()
registerForm.captchaKey = captchaKey.value
}
// 检查用户名
const checkUsername = async () => {
if (registerForm.username) {
try {
const res = await checkUsername(registerForm.username)
if (res.data) {
ElMessage.warning('用户名已存在')
}
} catch (error) {
console.error('检查用户名失败:', error)
}
}
}
// 检查邮箱
const checkEmail = async () => {
if (registerForm.email) {
try {
const res = await checkEmail(registerForm.email)
if (res.data) {
ElMessage.warning('邮箱已被注册')
}
} catch (error) {
console.error('检查邮箱失败:', error)
}
}
}
// 检查手机号
const checkPhone = async () => {
if (registerForm.phone) {
try {
const res = await checkPhone(registerForm.phone)
if (res.data) {
ElMessage.warning('手机号已被注册')
}
} catch (error) {
console.error('检查手机号失败:', error)
}
}
}
// 注册处理
const handleRegister = async () => {
await registerFormRef.value.validate()
const success = await authRegister(registerForm)
if (success) {
// 注册成功,跳转到登录页
router.push('/auth/login')
}
}
// 跳转到登录
const goToLogin = () => {
router.push('/auth/login')
}
// 页面加载时获取验证码
onMounted(() => {
if (userStore.isLoggedIn) {
router.push('/')
}
refreshCaptcha()
})
</script>
<style scoped lang="scss">
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.register-card {
width: 100%;
max-width: 500px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
.register-header {
text-align: center;
margin-bottom: 30px;
h2 {
font-size: 28px;
color: #333;
margin-bottom: 8px;
}
p {
color: #666;
font-size: 14px;
}
}
.captcha-container {
display: flex;
gap: 10px;
.el-input {
flex: 1;
}
.captcha-image {
width: 120px;
height: 40px;
background: #f0f2f5;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #999;
font-size: 12px;
}
}
}
.el-button {
width: 100%;
}
.register-footer {
text-align: center;
margin-top: 20px;
font-size: 14px;
color: #666;
.el-link {
font-size: 14px;
}
}
}
:deep(.el-form-item__label) {
font-weight: 500;
}
:deep(.el-checkbox__label) {
font-size: 14px;
color: #666;
}
</style>
七、前后端联调
7.1 启动后端服务
# 进入后端项目目录
cd ~/projects/blog-backend
# 使用Maven打包
mvn clean package
# 启动服务
java -jar target/blog-backend-1.0.0.jar
# 或者使用IDEA启动
# 运行 BlogApplication.java
7.2 启动前端服务
# 进入前端项目目录
cd ~/projects/blog-frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 访问 http://localhost:5173
7.3 联调测试流程
7.3.1 测试注册流程
7.3.2 测试登录流程
7.3.3 测试验证码功能
7.3.4 测试追悼模式
7.4 常见问题排查
7.4.1 跨域问题
// 后端添加跨域配置
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}
7.4.2 Token失效问题
// 前端添加Token自动刷新
let isRefreshing = false
let requests = []
request.interceptors.response.use(
response => response,
async error => {
const { config, response } = error
const originalRequest = config
if (response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise(resolve => {
requests.push(token => {
originalRequest.headers['Authorization'] = 'Bearer ' + token
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const refreshToken = localStorage.getItem('Refresh-Token')
const res = await refreshToken(refreshToken)
const newToken = res.data
setToken(newToken)
originalRequest.headers['Authorization'] = 'Bearer ' + newToken
requests.forEach(cb => cb(newToken))
requests = []
return request(originalRequest)
} catch (err) {
// 刷新失败,跳转到登录页
const userStore = useUserStore()
await userStore.logoutAction()
router.push('/auth/login')
return Promise.reject(err)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
7.4.3 验证码忽略大小写验证
// 前端验证码输入自动转为大写
const captcha = ref('')
watch(captcha, (newVal) => {
if (newVal) {
captcha.value = newVal.toUpperCase()
}
})
7.5 部署准备
7.5.1 后端打包
# 打包成jar
mvn clean package -Pprod
# 查看打包结果
ls -lh target/*.jar
7.5.2 前端打包
# 构建生产版本
npm run build
# 查看构建结果
ls -lh dist/
7.5.3 部署脚本
#!/bin/bash
# deploy.sh
# 后端部署
scp target/blog-backend-1.0.0.jar user@your-server:/app/blog/
ssh user@your-server "cd /app/blog && ./restart.sh"
# 前端部署
scp -r dist/* user@your-server:/var/www/blog/
文档版本记录
| v1.0 | 2024-01-01 | 初始版本,完成登录注册模块 | Your Name |
| v1.1 | 2024-01-02 | 添加追悼模式功能 | Your Name |
| v1.2 | 2024-01-03 | 完善验证码忽略大小写功能 | Your Name |



