
1. 项目整体设计与技术选型1.1 为什么用 SpringBoot Vue3 这套组合先交代一下背景。这是我做的一个全栈实战系列项目目标是搭建一套完整的权限后台管理系统。第一期的核心任务就是先把用户注册、登录这两个最基础也最关键的模块做扎实为后续的功能扩展打地基。技术栈选 SpringBoot Vue3不是说跟风哪个火就选哪个。我个人的判断标准很简单一是团队招人好不好招二是踩坑资料多不多三是生态完不完善。SpringBoot 在 Java 后端领域已经属于事实标准不管是企业级项目还是个人毕设用它写接口的效率都很高Vue3 经过这几年的迭代Composition API 加 Vite 的开发体验已经非常成熟而且 Element Plus 这种组件库直接能把后台管理页面的 UI 成本压到极低。这套组合解决的核心问题有两块第一后端用 SpringBoot 快速把 RESTful 接口定义清楚配合统一返回结构和 JWT 做身份认证第二前端用 Vue3 Vite 搭起工程化开发流把注册、登录页面和接口调用串起来再通过路由守卫控制页面访问权限。这个系列适合谁看如果你是有一定 Java 基础但没完整做过全栈项目的学生或者刚转行想了解前后端怎么协作的初级开发又或者是想找一个现成模板直接二次开发的个人开发者这篇文章都能帮你少走不少弯路。后面我会把每一行代码、每一个配置都拆开讲清楚包括我踩过的坑和排查思路。1.2 技术选型的几个关键权衡点光说选型不够还得说清楚为什么这么选。有几个点我在前期犹豫过这里直接摊开讲。版本问题是最先要拍板的。SpringBoot 目前 3.x 已经是主流但如果你用的是 JDK 8那只能用 2.7.x。这个坑在热搜里也反复出现很多人直接 new 一个 3.x 项目发现编译都过不了。我的建议是生产环境求稳就 SpringBoot 2.7.18 JDK 8 MyBatis-Plus 3.5.x这套组合经过大量项目验证网上资料也最全想尝鲜或者新项目不限制 JDK 版本那就 SpringBoot 3.2.x JDK 17但是要注意 MyBatis-Plus 得用 3.5.3 以上版本否则会报兼容性错误。我这个系列用的是 2.7.18主要是照顾大部分读者的本地环境。ORM 框架选型Spring Data JPA 和 MyBatis-Plus 我都用过。JPA 在单表 CRUD 上确实省事但一旦涉及多表关联、复杂 SQL那个调试成本真的让人头大。MyBatis-Plus 的优势在于 SQL 可控性强代码生成器一套下来能省很多体力活而且社区活跃度极高。我最终选了 MyBatis-Plus后面会具体演示。前端构建工具Vue3 官方推荐 Vite这个没什么好说的。Webpack 配置繁琐是共识Vite 的冷启动速度基本是秒开热更新体验舒服得多。唯一要注意的是 Vite 5 要求 Node.js 18如果你还在用 Node 16先升级。UI 组件库Element Plus 我用得最多文档完善、组件覆盖全面、风格统一非常适合后台管理系统。你非要选 Ant Design Vue 或者 Naive UI 也完全可以核心逻辑是一样的。2. 后端接口设计与数据库建模2.1 用户表结构设计别忽略这些隐蔽字段设计用户表之前先明确一个原则注册登录模块的表结构不要只想着存账号密码要为后续的功能扩展预留好空间。很多新手一上来就建三五个字段后面加个昵称头像就要改表麻烦得很。我的用户表结构如下CREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 用户名登录账号, password varchar(100) NOT NULL COMMENT 密码加密存储, nickname varchar(50) DEFAULT NULL COMMENT 昵称, email varchar(100) DEFAULT NULL COMMENT 邮箱, phone varchar(20) DEFAULT NULL COMMENT 手机号, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;有几个细节值得说一下第一username 必须加唯一索引。这是防重的最底层保障。虽然你在业务代码里也会做重复校验但并发场景下两个请求同时判断不存在然后同时插入唯一索引就是最后一道防线。第二deleted 字段做逻辑删除。不是真的把数据删掉而是打标记。这样数据可追溯以后做统计分析也有依据。MyBatis-Plus 对逻辑删除有原生支持配置一下就行后面代码部分我会演示。第三create_time 和 update_time 建议数据库自动填充。MyBatis-Plus 的字段自动填充功能可以做到插入时自动写 create_time更新时自动写 update_time不用在业务代码里手动 set。2.2 注册与登录接口定义URL 设计和参数约定接口设计这块我遵循的是 RESTful 风格。虽然互联网上对 REST 的争论很多但我们的目标是让接口语义清楚、便于前后端协作沟通不需要在理论上过于较真。注册接口POST /api/auth/register Content-Type: application/json { username: zhangsan, password: 123456, confirmPassword: 123456, nickname: 张三 }登录接口POST /api/auth/login Content-Type: application/json { username: zhangsan, password: 123456 }获取当前用户信息登录后携带 token 访问GET /api/user/info Authorization: Bearer token这里我用了/api/auth/前缀来区分认证相关的接口后面业务模块用/api/user、/api/system分区管理。注意登录接口不要用 GET密码放在 URL 上会被浏览器历史记录、Nginx 日志等地方记录下来安全隐患太大。另外注册接口里如果前端传入了 confirmPassword后端只需要校验两个密码是否一致不需要把这个字段存到实体里所以 DTO 和 Entity 必须分离。参数校验这块我强烈建议用 Spring Boot 的Validated注解 NotBlank、Size这类约束注解同时自定义异常处理器返回统一的错误消息。不要靠自己在每个 Controller 里手动 if 判断代码冗余不说还容易漏。2.3 统一返回结构与全局异常处理前后端联调最烦的是什么就是每个接口返回的数据格式不一致前端取个字段都要看一眼文档。我在项目里预定义了一个统一的返回体Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }这样前端不管拿到什么接口统一先判断 code 是不是 200再决定取 message 还是 data。省心。那异常怎么处理如果代码里出现异常比如用户名已存在、验证码错误、token 过期我不希望它抛出一个 500 让前端看到一堆堆栈。所以我会配合自定义业务异常BusinessException加一个全局异常处理器RestControllerAdvice来统一拦截。这样一来前端拿到的永远是结构一致的 JSON方便做统一交互提示。2.4 密码加密绝对不允许明文入库密码加密这块我说得直接一点任何正经系统都不允许把明文密码存进数据库。哪怕是你自己练手的项目也得养成这个习惯。一个用户在各个平台用同样密码的概率极高一旦你的数据库泄露用户在其他平台的账号也跟着遭殃这个责任你承担不起。Spring Security 的BCryptPasswordEncoder是我推荐的选择。它内部会自动生成随机盐每次加密结果都不同哪怕两个用户密码一样密文也不一样能有效对抗彩虹表攻击。而且它的校验是内置的不需要你自己比对。// 注册时加密 String encodedPwd passwordEncoder.encode(user.getPassword()); user.setPassword(encodedPwd); // 登录时校验 boolean isMatch passwordEncoder.matches(rawPassword, user.getPassword());顺便说一句不要用 MD5 加不加盐来搞。MD5 本身是摘要算法不是为密码存储设计的算力稍微高一点的机器跑字典不要太快。既然 BCrypt 用起来就几行代码没必要在这个地方省事。3. 后端核心功能完整实现3.1 SpringBoot 工程初始化与核心依赖先用 IDEA 自带的 Spring Initializr 创建项目或者直接去 start.spring.io 生成。为了方便讲解我直接列出我用的pom.xml中关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency注意几个细节第一MySQL 8 以后驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。第二JJWT 这个库要引入三个包很多新手只引了 api 包结果运行的时候才发现缺类。第三MyBatis-Plus 3.5.3 之后的版本对 SpringBoot 2.x 和 3.x 的支持方式不一样别下错版本。3.2 配置文件与 MyBatis-Plus 基础配置application.yml是后端的咽喉要道很多问题都出在这。我用的是 YAML 格式看着清晰server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/blog_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 id-type: auto这里我想强调两个配置细节map-underscore-to-camel-case设成 trueMyBatis-Plus 就能自动把数据库的create_time映射到实体的createTime字段否则你得写一堆TableField手动对应。logic-delete-field: deleted是逻辑删除的全局配置。配好之后MyBatis-Plus 执行删除操作时会自动转成UPDATE t_user SET deleted 1 WHERE id ?查询时自动加AND deleted 0非常省事。还有server.servlet.context-path: /api这会让所有接口统一加上/api前缀。这样一来 Controller 里写PostMapping(/auth/login)实际请求地址就是/api/auth/login和前端 axios 的 baseURL 对接更清晰。3.3 实体类、Mapper 与 Service 实现实体类我直接用 Lombok 简化代码Data TableName(t_user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private String password; private String nickname; private String email; private String phone; private String avatar; private Integer status; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }MyBatis-Plus 的 BaseMapper 已经提供了selectOne、insert、updateById这些通用方法所以 Mapper 接口我基本是空壳public interface UserMapper extends BaseMapperUser { }Service 层的核心逻辑是这样的注册时先查数据库判断用户名是否已存在存在就抛业务异常不存在就加密密码再插入登录时先查用户再比对密码。这里我特别处理了status字段用户被禁用时不能登录。Override public void register(RegisterDTO dto) { Long count userMapper.selectCount( new LambdaQueryWrapperUser() .eq(User::getUsername, dto.getUsername())); if (count 0) { throw new BusinessException(用户名已被注册); } User user new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); user.setNickname(dto.getNickname()); user.setStatus(1); userMapper.insert(user); }这里用LambdaQueryWrapper而不是字符串拼条件我觉得是 MyBatis-Plus 最好用的点之一。编译期就能检查字段名重构的时候不怕改错推荐你也这样写。3.4 JWT 登录认证与拦截器实现登录认证的方案有很多种Session、Token、OAuth2这个项目我用的是 JWT。为什么选 JWT因为它是无状态的后端不需要存储会话信息接口要做水平扩展的时候非常方便。前端保存 token每次请求放在请求头里带过来后端验签通过就放行。JWT 工具类这里我只贴一下生成和解析的核心方法Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }secret不要硬编码在代码里放到application.yml或者环境变量中。这里对安全性要求高的话可以用更长的密钥HS256 算法要求至少 256 位。我在配置里放了一串长度足够的随机字符串实际项目建议用环境变量注入。拦截器是整个认证过程的核心位置。其实就是一个 HandlerInterceptor在请求进入 Controller 之前校验 tokenComponent public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { // token 无效或过期 } } response.setContentType(application/json;charsetUTF-8); response.setStatus(401); response.getWriter().write({\code\:401,\message\:\未登录或token已过期\}); return false; } }注册和登录接口必须放行不然用户根本没法访问。这个用 WebMvcConfigurer 配置拦截器路径时排除掉就行Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns(/auth/login, /auth/register); } }注意我这里开发阶段用了简化的 CORS 配置直接把跨域放开。生产环境你需要单独配置允许的域名别偷懒。3.5 注册登录接口的完整 Controller 实现Controller 层应该保持薄做参数接收和结果封装就够了不写业务逻辑RestController RequestMapping(/auth) public class AuthController { Autowired private AuthService authService; PostMapping(/register) public ResultVoid register(Validated RequestBody RegisterDTO dto) { authService.register(dto); return Result.success(null); } PostMapping(/login) public ResultLoginVO login(Validated RequestBody LoginDTO dto) { return Result.success(authService.login(dto)); } }DTO 里的校验注解我习惯写在字段上Data public class RegisterDTO { NotBlank(message 用户名不能为空) Size(min 3, max 20, message 用户名长度需在3-20之间) private String username; NotBlank(message 密码不能为空) Size(min 6, max 20, message 密码长度需在6-20之间) private String password; NotBlank(message 确认密码不能为空) private String confirmPassword; private String nickname; }对了confirmPassword的比对放在 Service 里做如果两次不一致直接抛业务异常提示两次输入的密码不一致。我当时还给 email 加了一个Email注解但需求里 EMAIL 不是必填所以只在非空时校验。这里要看你的用户注册字段设计来决定规则。4. 前端环境搭建与页面实现4.1 Vue3 项目创建与 Element Plus 集成前端这边环境不用多复杂Node.js 18 以上 npm 就行。创建项目直接走官方脚手架npm create vitelatest blog-frontend -- --template vue cd blog-frontend npm install装完基础依赖再把 Element Plus、axios、vue-router、pinia 装上npm install element-plus axios vue-router pinia这里提醒一句Vite 5 创建的项目默认就是 Vue3.4 版本。如果你还想用vue/composition-api这种补丁包说明你可能是从 Vue2 迁移的需要想清楚装的是 vite 插件还是 vue 插件。Element Plus 的使用方式有全量引入和按需引入两种。项目刚起步图省心我建议全量引入把main.js配好就行import { createApp } from vue import App from ./App.vue import ElementPlus from element-plus import element-plus/dist/index.css import router from ./router import { createPinia } from pinia const app createApp(App) app.use(ElementPlus) app.use(router) app.use(createPinia()) app.mount(#app)4.2 Axios 请求封装统一处理 token 和错误码后端接口已经统一返回结构了前端 axios 也必须做一层统一封装。不然每个页面里都要写一遍获取 token、设置请求头、处理错误码的逻辑那也太原始了。我在src/utils/request.js创建了一个实例import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理结果和错误码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request这段代码里的baseURL: /api前端开发环境需要通过 Vite 的 proxy 把请求转发给后端8080端口解决跨域问题。这也是我在 Vite 的vite.config.js里要配置的东西export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置好 proxy 之后前端开发时请求/api/auth/login会被 Vite 代理转发到后端的http://localhost:8080/api/auth/login。这比在浏览器端开启 CORS 更贴近线上环境因为线上部署时 Nginx 也是这么反代的。生产环境构建后就是把前端静态文件丢到 Nginx再把/api路径反向代理到后端服务思路和这个是完全一致的。4.3 注册页面实现表单校验与密码可见性切换注册页面的核心不只是把表单画出来还要让用户输入体验顺畅校验信息准确可读。我用的是 Element Plus 的el-form组件通过rules属性配置校验规则template div classauth-container el-card classauth-card h2用户注册/h2 el-form refregisterFormRef :modelregisterForm :rulesrules label-width80px sizelarge el-form-item label用户名 propusername el-input v-modelregisterForm.username placeholder请输入用户名 clearable / /el-form-item el-form-item label昵称 propnickname el-input v-modelregisterForm.nickname placeholder请输入昵称可选 clearable / /el-form-item el-form-item label密码 proppassword el-input v-modelregisterForm.password typepassword placeholder请输入密码6-20位 show-password / /el-form-item el-form-item label确认密码 propconfirmPassword el-input v-modelregisterForm.confirmPassword typepassword placeholder请再次输入密码 show-password / /el-form-item el-form-item el-button typeprimary :loadingloading clickhandleRegister 注册 /el-button el-button clickrouter.push(/login)已有账号去登录/el-button /el-form-item /el-form /el-card /div /templateshow-password属性是 Element Plus 内置的密码可见性切换不用自己写小眼睛图标省了很多事。校验规则里有一个关键点确认密码的验证是这样写的const validateConfirmPassword (rule, value, callback) { if (value ! registerForm.password) { callback(new Error(两次输入的密码不一致)) } else { callback() } } const rules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 3, max: 20, message: 用户名长度需在3-20之间, trigger: blur } ], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, max: 20, message: 密码长度需在6-20之间, trigger: blur } ], confirmPassword: [ { required: true, message: 请再次输入密码, trigger: blur }, { validator: validateConfirmPassword, trigger: blur } ] }这里有个小技巧trigger不要只看blur如果输入过程中就实时校验体验会更好可以在用户名输入框用change触发。不过要注意过度校验会导致用户还没输完就报错反而恼人所以这个看具体场景权衡。提交注册的代码也比较常规const handleRegister () { registerFormRef.value.validate(async valid { if (!valid) return loading.value true try { await request.post(/auth/register, registerForm.value) ElMessage.success(注册成功请登录) router.push(/login) } finally { loading.value false } }) }4.4 登录页面实现回车提交与登录态持久化登录页面和注册的思路大同小异但有一个细节我特意处理了支持回车提交。很多用户习惯输完密码直接按回车如果页面不支持体验会很糟糕。我把提交逻辑封装成一个函数el-input上加keyup.enter监听即可。登录成功后拿到了 token 和后端返回的用户信息我用 Pinia 做了一个用户状态仓库。Pinia 是 Vue3 官方推荐的状态管理库对比 Vuex 来说 API 简洁得多不需要 mutation、getter 这些繁琐概念。// src/stores/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null) }), actions: { setLoginInfo(data) { this.token data.token this.userInfo data.userInfo localStorage.setItem(token, data.token) localStorage.setItem(userInfo, JSON.stringify(data.userInfo)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })为什么登录信息要同时存 Pinia 和 localStoragePinia 是内存态刷新页面就没了localStorage 是持久态刷新后还能恢复。两者配合既保证了组件间状态共享又保证了刷新不掉线。如果不用 Pinia你会发现每次刷新页面都要重新请求用户信息接口或者从 localStorage 一遍遍地取体验很割裂。4.5 路由守卫未登录禁止访问后台页面登录注册模块做完路由守卫是最后一道关卡。后台管理系统的核心诉求就是没登录的人不能访问需要登录才能看的页面登录的人不应该再看登录页。在router/index.js里我给需要登录的页面路由加一个meta.requiresAuth标记然后在全局前置守卫中处理router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { ElMessage.warning(请先登录) next(/login) } else if (to.path /login token) { next(/) } else { next() } })这个逻辑简单直白但却是整个登录注册闭环里必不可少的一环。没有它用户把 URL 一改/home、/admin照样能访问那前面做的登录就形同虚设了。注意一点路由守卫只能控制前端页面可见性真正安全性要靠后端的 JWT 拦截器来保障。前端可以绕过后端接口必须做鉴权校验二者互为补充缺一不可。5. 常见问题与排查技巧实录5.1 前端报错 404检查你的 context-path 和 proxy联调阶段最常见的错误就是 404。仔细想想你前端 axios 请求的地址是/api/auth/login但后端的拦截器只拦截了/auth/login接口没匹配上自然就 404 了。排查思路我提供一个链路先打开浏览器 F12 看请求完整的 URL 长什么样再对照后端 Controller 请求映射和后端配置的context-path。如果请求确实到了后端但 404大概率是路径对不上如果请求根本没到后端的日志里那就是前端的 proxy 没配置好或者后端服务没启动。5.2 数据库插入中文乱码中文乱码这个问题很经典排查点其实就三个数据库表字符集、连接参数、前端请求编码。我建表时统一用utf8mb4连接参数里配了characterEncodingutf8前端 axios 默认就是 UTF-8一般就稳了。要注意的是如果数据库本身是旧库表是 latin1 编码那你改连接参数是救不回来的必须把表字符集改掉。5.3 登录后刷新页面就跳回登录页这个现象也经常遇到。原因是登录信息存在 Pinia 里刷新页面内存清空localStorage 还没读取路由守卫看到没有 token 就直接跳转了。我在前面已经说过解法初始化 state 时从 localStorage 恢复数据。还有一个相关的问题localStorage 存 token 本身有 XSS 风险生产环境要求高的话可以换成 httpOnly cookie但那样就要处理 CSRF复杂度会上升这个大家按自己的实际情况取舍。5.4 token 过期处理JWT 是无状态的服务端不记录 token 状态所以过期时间设多长是拍脑袋决定的。常规做法是看业务类型后台管理系统一般 30 分钟到 2 小时不等。我在响应拦截器里对 401 做了统一处理清除本地登录信息、弹提示、跳转登录页。这样用户操作到一半过期了会被友好地引导回登录页而不是看到一个报错白屏。5.5 常见问题速查表我这里把开发过程中容易踩的坑整理成一张表方便你排查问题现象可能原因排查步骤解决方案启动报错找不到主类JDK 版本不匹配 SpringBoot 3.x检查 java -version换 JDK 17 或降级 SpringBoot 2.7.x接口返回 401token 缺失或过期F12 Network 看请求头 Authorization 是否带上了检查 axios 请求拦截器是否在每次请求中设置 token登录接口能通但注册接口 404context-path 和 Controller 路径没对齐查看后端日志中请求路径映射统一用/api前缀最好用 context-path 配合 Controller 简写数据库查询慢缺少索引EXPLAIN 查看执行计划username 字段加唯一索引前端页面样式无效Element Plus 版本和 Vue 版本不对应npm ls element-plus vueElement Plus 2.x 需要 Vue3.x不能用在 Vue2跨域问题前端 5173 访问后端 8080未配置代理或 CORS浏览器控制台看 CORS 错误信息开发用 Vite proxy生产用 Nginx 反代不要依赖前端开启 CORS密码加密后长度不够数据库 password 字段长度小于 60检查表结构BCrypt 加密结果是 60 位字段要设 varchar(100)逻辑删除失效实体类没有 TableLogic 注解检查实体类加 TableLogic 注解或配置全局 logic-delete-field5.6 排错工具与调试技巧后端接口调试我习惯用 Apifox比 Postman 轻而且支持把接口文档直接生成和分享做个人项目或者小组协作都方便。后端看日志是一个必备能力。application.yml里把 MyBatis-Plus 的log-impl配成 StdOutImpl就能在控制台看到每一条 SQL排查数据问题效率高很多。前端的 Vue Devtools 插件一定要装查看组件层级和 Pinia 状态特别方便定位为什么页面没渲染出数据这类问题时可以快速判断是数据源没有还是组件取错字段。6. 个人踩坑经验与后续优化建议到这里SpringBoot Vue3 用户注册登录模块的功能已经完整走通了。我记得第一次做全栈项目的时候前后端联调阶段被各种小问题折磨得够呛后来总结出几个对自己很有帮助的习惯顺便在这里分享一下。第一接口文档一定要在动手写代码之前定义清楚。我当时习惯先和前端同事约好接口的参数、返回结构和错误码谁先写完谁就按这个文档 mock 或自测。现在一个人全栈开发我也会先把接口设计写进 README避免写着写着把自己绕晕。第二每完成一个功能点就提交一次代码commit message 写清楚改动内容。方便回溯问题也避免改崩了没法回滚。第三数据库变更要留迁移脚本。不要随手在数据库工具里改个表结构就完事因为你的同事、或者部署到服务器的新环境都需要这些变更记录。这个系列后续的规划也很清晰。登录注册只是地基接下来要做的是用户信息管理、权限控制RBAC 模型、后台管理页面、文件上传、日志记录与导出。等到整个后台管理系统成型我还会写一篇基于 Nginx 的前后端分离部署实战把构建、部署、HTTPS 证书配置这些生产细节都过一遍。如果你在跟着这篇文章实操的过程中遇到了任何问题或者发现了更优的实现方案欢迎在评论区留言。踩过坑的人才知道哪些地方容易被套路互相交流才能进步得快。下一篇文章我们继续往后做。