数据库课程设计:用户登录系统从建表到前后端联调的完整落地指南
简介面向数据库课程设计的完整源码项目聚焦前端与后端协同实现用户登录及关联操作适合正在完成课程设计或想熟悉Java Web全流程的学生与开发者。压缩包共49个文件、约64KB以Java源文件、JSP页面和XML配置为主体配套HTML/CSS/JavaScript前端页面、properties配置和README说明等包内目录结构清晰Java代码对应后端逻辑JSP负责动态页面XML多为框架或依赖配置便于按层查阅。项目涵盖用户登录的前端表单验证、AJAX请求发起、后端身份校验、数据库用户表设计与密码哈希存储等关键环节可直接参考核心代码理解从前端页面到数据库的完整调用链路。包内含Maven相关配置便于导入IDE后运行调试对学习Servlet/JSP、数据库连接和基础安全实践均有实际帮助。已有3956人学习适合用于课程设计说明撰写、代码讲解或二次功能扩展。1. 数据库课程设计选“用户登录”这个题到底是做一套什么系统学期末交数据库课程设计最常听到的翻车现场是PPT 画了三张表代码里却只有一张表登录功能用明文密码比对老师当场打开数据库一查就露馅答辩演示时后端服务起不来前端页面白屏。为什么会这样因为很多人把“用户登录”理解成了“一个输入框加一个按钮”忽略了它背后是一整套数据建模、接口设计、状态管理和前后端联调流程。这个标题拆开看其实是三条线数据库负责用户数据的建模与持久化后端负责身份校验和会话控制前端负责表单交互和登录态展示。三者用一套接口串起来就构成一个可演示、可扩展的课程设计闭环。别小看它把登录做得规范等于同时交付了建表、增删改查、接口鉴权和前端交互四样东西这是课程设计里性价比最高的题目之一。这套思路也恰好是前后端分离项目中面试常问的 token 管理、密码安全、跨域配置的起点。下面按我自己的落地顺序从数据库设计一路讲到前端联调。2. 先建表再写代码用户表与操作日志表的设计与建表语句2.1 为什么课程设计要避开 Session 方案直接选 JWT 做登录态课程设计里的登录方案主流只有两条路Session-Cookie 和 TokenJWT。Session 方案需要后端维护会话存储前端要处理 Cookie 的同源策略跨域时还要额外配置 credentials这些对答辩演示并不友好。JWT 是无状态的——用户登录成功后后端签发一段带签名和过期时间的令牌前端后续请求携带这个令牌后端验证签名即可不需要查会话表。从课程设计的评分角度看JWT 方案的加分点很直观代码里能讲清楚“签名、负载、过期时间”三个概念并且把 spring-boot-starter-validation、jjwt、MyBatis-Plus 这些实际工程里常用的依赖都用上。这比 Session 方案更容易延展到“权限控制”和“操作审计”这些高阶话题。我一般不会在课程设计里引入 Redis 做 session 共享因为单机部署的演示环境里JWT 已经足够还省去了额外服务的配置成本。2.2 用户表字段设计哪些字段必须有哪些字段别瞎加用户表是这套系统的核心。设计字段时遵循一个原则能不为空就不为空能加唯一约束就加唯一约束。下面是我常用的表结构字段不多但正好覆盖课程设计需要的演示点。CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL COMMENT 登录用户名, password VARCHAR(100) NOT NULL COMMENT bcrypt加盐哈希后的密码, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称前端展示用, status TINYINT NOT NULL DEFAULT 1 COMMENT 账号状态1正常 0禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;关键参数说明username加唯一索引是为了在注册接口里用数据库约束兜底避免并发下重复用户名穿透业务校验password字段长度设 100 而不是 45因为 bcrypt 哈希后的密文是 60 个字符加上前缀和未来算法升级的余量太短会直接截断数据时间字段统一用DATETIME配合 MySQL 8 的默认值语法插入数据时不需要手动填时间。status字段平时可以不用但答辩时如果要演示“禁用用户无法登录”它就是现成的扩展点。2.3 操作日志表让“一系列操作”变成可查的记录课程设计评审老师很吃“审计”这一套。光有登录演示两分钟就结束了加上操作日志就能展示“谁在什么时间做了什么”。我在用户表之外额外设计了一张operation_log表用来记录登录之后的敏感操作。CREATE TABLE operation_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 日志主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 操作用户ID, action VARCHAR(50) NOT NULL COMMENT 操作类型LOGIN/USER_UPDATE/PASSWORD_CHANGE, detail VARCHAR(255) DEFAULT NULL COMMENT 操作详情如修改前IP、备注, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 操作时间, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;这张表的要义是分离查询路径user_id只做普通索引而不是唯一索引因为一个用户会有多条日志。detail字段存 JSON 字符串比如{ip:127.0.0.1,device:Chrome}需要时再展开解析不需要刻意为它单独建表。实际操作里我会在登录成功、修改密码、退出登录三个点各写一条日志演示的时候直接跑一个分页查询接口把日志列表返回到前端表格里视觉上就是一个完整的“系统”。提示user是 MySQL 的保留字建表时如果直接执行会语法报错。我习惯用反引号包住表名或者改成sys_user这类前缀命名避免换环境导入时出现问题。2.4 数据库连接串与连接池参数一门心思强调的两项配置建好表之后第一步不是写接口而是确认数据库连接能稳定跑通。课程设计最常见的暗坑是时区和编码问题报错信息不会直接说“你编码错了”而是显示Incorrect string value: \xE4\xB8\xAD...或者时间查出来差 8 小时。spring: datasource: url: jdbc:mysql://localhost:3306/db_course_design?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000characterEncodingutf8解决插入中文变问号的问题serverTimezoneAsia/Shanghai解决时间字段差 8 小时的问题useSSLfalse是本地开发的标准配置——课程设计场景没有生产环境的数据加密传输需求开着 SSL 反而可能因为证书问题连不上。HikariCP 是 Spring Boot 默认的连接池连接数设 10 就够不用调大连接池的作用是复用连接而不是扛并发。3. 后端登录 API注册、登录、鉴权拦截器与密码加密3.1 技术选型与项目结构为什么我偏爱 Spring Boot MyBatis-Plus后端框架我用 Spring Boot这是课程设计里一致性最高的选择文档多、答辩时老师也熟悉。持久层用 MyBatis-Plus因为它把单表 CRUD 的 SQL 全省了——selectById、insert、updateById开箱即用不需要写 XML登录功能需要的只是单表查询手写 SQL 反而容易出低级错误。版本上直接 Boot 3.x MyBatis-Plus 3.5.xJDK 用 17语法上区别不大老师如果用的是旧环境退到 Boot 2.7 也能无缝迁移。src/main/java/com/course/design ├── common │ ├── Result.java │ └── JwtUtil.java ├── config │ ├── WebMvcConfig.java │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ └── LogController.java ├── entity │ ├── User.java │ └── OperationLog.java ├── mapper │ ├── UserMapper.java │ └── OperationLogMapper.java ├── service │ ├── UserService.java │ └── OperationLogService.java └── interceptor └── JwtInterceptor.java这个分包方式是按“职责”而不是按“功能模块”划分的common放所有接口共用的返回体和工具类interceptor独立出来是因为后面要讲清楚“哪些接口不需要登录就能访问哪些必须登录”。模块与模块之间没有循环依赖的问题结构上也能在答辩时快速讲明白。3.2 注册接口bcrypt 加盐哈希替代明文密码注册接口是整套系统的第一个入口。现在很多课程设计代码里还在用 MD5 加固定盐这已经不能代表安全实践了MD5 查表攻击成本极低固定盐又让同密码用户得到相同哈希。我全部换成 bcrypt它每次生成的哈希都不同因为内部自带随机盐验证明文时再用算法比对不需要自己额外存盐。Service public class UserServiceImpl implements UserService { Resource private UserMapper userMapper; Override public Result register(RegisterRequest req) { // 1. 业务层校验用户名是否存在数据库唯一索引兜底 Long count userMapper.selectCount( new LambdaQueryWrapperUser().eq(User::getUsername, req.getUsername()) ); if (count 0) { return Result.error(用户名已存在); } // 2. bcrypt 加密每次加密结果不同但 matches 方法可验证 String hashed BCrypt.hashpw(req.getPassword(), BCrypt.gensalt()); User user new User(); user.setUsername(req.getUsername()); user.setPassword(hashed); user.setNickname(req.getNickname()); userMapper.insert(user); return Result.success(注册成功); } }逻辑很简单但有一个容易被忽略的细节BCrypt.gensalt()有一个可选参数代表加密强度默认是 10。调成 12 会更安全但登录验证时每次要消耗几百毫秒课程设计部署的机器可能背着 IDE、数据库同时跑性能本来就不宽裕默认值足够。selectCount先查一次再加上数据库唯一索引兜底这两个机制缺一不可——只靠代码校验并发请求下会有两条重复用户名同时通过检查只靠数据库约束报错信息不友好用户看到的是 500 错误。3.3 登录接口JWT 的签发与三要素说明登录接口负责验证身份并签发令牌。我用 jjwt 0.11.5 这个版本API 已经稳定网上资料也很多。令牌里放userId和username过期时间设为 2 小时这个长度对课程设计演示很从容——答辩现场聊半小时token 不会中途失效又能在代码讲解中体现“无状态会话”的过期概念。Component public class JwtUtil { Value(${jwt.secret:course-design-secret-key}) private String secret; Value(${jwt.expire-hours:2}) private Long expireHours; public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireHours * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } }PostMapping(/login) public Result login(RequestBody Valid LoginRequest req) { User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getUsername, req.getUsername()) ); if (user null || !BCrypt.checkpw(req.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } if (user.getStatus() ! 1) { return Result.error(账号已被禁用请联系管理员); } String token jwtUtil.generateToken(user.getId(), user.getUsername()); return Result.success(new HashMap() {{ put(token, token); put(nickname, user.getNickname()); }}); }执行流程是查用户 → 校验密码 → 校验状态 → 签发令牌。注意逻辑顺序先查用户再比对密码可以让“用户不存在”和“密码错误”返回一样的信息避免接口暴露已注册用户名。Valid注解配合 DTO 里的NotBlank做参数校验省去手写 if 判断的啰嗦代码。登录成功返回的 token 要交给前端后续所有接口都在Authorization请求头里带这个令牌后端再解析它。3.4 拦截器注册白名单之外的地址全部验 tokenJWT 的核心运作机制是“后端验签前端带牌”。我把拦截器注册成统一的配置只放行注册、登录和前端预检请求其他路径必须要有效 token 才能进入。Component public class JwtInterceptor implements HandlerInterceptor { Resource private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (HttpMethod.OPTIONS.equals(request.getMethod())) { return true; // 预检请求直接放行否则前端跨域请求会被卡住 } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser().setSigningKey(jwtUtil.getSecret()).parseClaimsJws(token).getBody(); request.setAttribute(userId, Long.valueOf(claims.getSubject())); return true; } catch (Exception e) { // 签名错误或过期都会走到这里 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\token 无效或已过期\}); return false; } }Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }一个很容易忽略的点放行OPTIONS请求是前后端分离联调的隐形门槛。浏览器跨域请求会先发送一个不带 Authorization 头的OPTIONS预检如果拦截器把它拦了前端看到的就是“跨域失败”。还有 token 过期时不要返回 200——很多代码图省事返回{code:401}但不设置 HTTP 状态码前端 axios 的response.use错误拦截器就不会触发。拦截器签名里我手动设置response.setStatus(401)是为了让前端统一走错误分支这也符合 REST API 的语义。3.5 修改密码与分页查询接口演示“一系列操作”的完整性登录接口之外课程设计还要有“后续操作”来充实演示我一般会加两个修改密码和分页查询操作日志。修改密码要验证旧密码和确认新密码并清掉当前 token这点很多代码会漏——密码都改了旧 token 却还能用逻辑上就不闭环。PostMapping(/change-password) public Result changePassword(RequestBody ChangePasswordRequest req) { Long userId (Long) request.getAttribute(userId); User user userMapper.selectById(userId); if (!BCrypt.checkpw(req.getOldPassword(), user.getPassword())) { return Result.error(原密码错误); } if (!req.getNewPassword().equals(req.getConfirmPassword())) { return Result.error(两次输入的新密码不一致); } User update new User(); update.setId(userId); update.setPassword(BCrypt.hashpw(req.getNewPassword(), BCrypt.gensalt())); userMapper.updateById(update); return Result.success(修改成功请重新登录); }修改密码的鉴权来自拦截器里设置的request.getAttribute(userId)——前端只带 token后端从 token 里解析出用户身份而不是让前端传userId上来这是接口安全的基本习惯。分页查询日志用 MyBatis-Plus 的Page对象拼接userId条件后按时间倒序前端拿到分页数据渲染成表格整套系统闭环就完整了。4. 前端登录页与登录态管理Vue3 Element Plus 的实现方式4.1 登录页做得像样不等于表单校验做对了前端我选 Vue3 Vite Element Plus这三件套是现在课程设计的主流组合。登录页的难点不是长得好看而是处理三件事表单校验规则、登录请求的发送、token 的本地持久化。很多错例把 token 存在 Vuex / Pinia 里一刷新页面登录态就没了因为 Pinia 默认不持久化。template el-form refloginFormRef :modelloginForm :rulesloginRules label-width80px el-form-item label用户名 propusername el-input v-modelloginForm.username placeholder请输入用户名 / /el-form-item el-form-item label密码 proppassword el-input v-modelloginForm.password typepassword show-password placeholder请输入密码 / /el-form-item el-form-item el-button typeprimary :loadingloading clickhandleLogin登 录/el-button /el-form-item /el-form /template script setup import { ref, reactive } from vue import { useRouter } from vue-router import { loginApi } from ../api/auth const loginFormRef ref() const router useRouter() const loginForm reactive({ username: , password: }) const loginRules { username: [ { required: true, message: 用户名不能为空, trigger: blur }, { min: 3, max: 20, message: 用户名长度应为 3 到 20 位, trigger: blur } ], password: [ { required: true, message: 密码不能为空, trigger: blur }, { min: 6, max: 30, message: 密码长度应为 6 到 30 位, trigger: blur } ] } async function handleLogin() { await loginFormRef.value.validate() const res await loginApi(loginForm) localStorage.setItem(token, res.data.token) localStorage.setItem(nickname, res.data.nickname) router.push(/dashboard) } /scriptshow-password属性是 Element Plus 内置的密码可见切换演示时比截图更直观。校验规则里的trigger: blur表示输入框失焦时校验漏掉这个属性的话表单只有点提交时才报错交互上差一大截。await loginFormRef.value.validate()这一步是先校验后请求如果校验失败会抛异常handleLogin后面的请求就不会执行——在实际敲代码时要确认 Element Plus 的 validate 方法 reject 后不会再往下走否则会出现“校验失败但弹了登录接口”的怪异情况。4.2 axios 封装与请求拦截器统一塞 token统一处理 401前端如果每个请求都手动从 localStorage 取 token 再拼请求头代码会很散且容易漏。我在api/request.js里封装 axios 实例请求拦截器统一加 token响应拦截器统一处理 401 跳转登录页。import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { // 后端统一返回 Result 结构code 不为 200 时也走业务错误分支 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) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request响应拦截器的设计重点是业务错误和 HTTP 错误分开处理。后端返回code: 500时比如修改密码时旧密码不对HTTP 状态码还是 200所以要在第一个函数里通过业务 code 判断只有 token 失效时后端才返回 HTTP 401这时候才需要清 token 跳登录页。如果这两类错误混在一起处理会出现“输错密码也被踢回登录页”的尴尬。4.3 路由守卫没有 token 就进不了主界面不少课程设计代码里用户把/login改成/dashboard页面直接能进。原因是没有在路由层做守卫。Vue Router 的beforeEach钩子里判断是否存在 token是前端登录态控制的最后一道闸。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { return next() } if (!token) { ElMessage.warning(请先登录) return next(/login) } next() })守卫逻辑只有三行但位置很严格必须判断“目标路径是否为 /login”和“是否有 token”两层。如果反过来先判断 token那已经登录的用户就永远跳不回登录页如果完全不判断路径登录页也会被守卫拦着。课程设计里有人把它写成“只有 /dashboard 才校验”其他新加的路由全部裸奔这种写法在答辩时很容易被追问。4.4 前端跨域配置Vite 代理与后端 CORS 二选一开发环境下前端跑在localhost:5173后端跑在localhost:8080直接发请求必然会触发跨域。常见解决方案是后端加 CORS 配置或者在 Vite 里配置代理。我更推荐 Vite 代理它只在开发环境生效部署时由 Nginx 做同样的转发前后端代码都不用改。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })代理配置的关键点是changeOrigin: true——不加这个参数转发请求时Host头还是前端的地址部分后端框架会对 Host 做校验导致请求 403。开发时浏览器地址栏里始终是 5173 端口看不到 8080这部分在和老师联调时很容易被当成“前端自己录的假数据”要主动说明代理链路。5. 用户登录系统的常见问题与避坑排查5 条踩坑记录5.1 中文数据插入报错Incorrect string value 与 utf8mb4 的关系现象是注册用户时插入中文昵称控制台报Incorrect string value: \xE7\x94\xA8\xE6\x88\xB7英文昵称却一切正常。原因在于建表时用了utf8字符集而 MySQL 的utf8实际只能存 3 字节编码的字符中文昵称里的生僻字和 emoji 都是 4 字节。解决方法是把库、表、连接串全部统一到utf8mb4建表语句里CHARSETutf8mb4只是第一步如果库级别设置了单表也要确认因为表会继承库的设置但单表可以覆盖库的设置。5.2 浏览器请求后端返回 401后端日志却显示放行了 OPTIONS现象是前端登录接口能通带 token 的接口全部 401DevTools 里看到请求路径是http://localhost:8080/api/user/info但多了一个OPTIONS预检请求。原因在拦截器配置addPathPatterns(/api/**)把预检请求也拦了OPTIONS请求没有 Authorization 头被拦截器判为无效 token。解决方法是拦截器第一行判断请求方法是OPTIONS就直接返回 true。跨域预检是浏览器行为不是用户发起的业务请求拦截器对它放行是标准做法。5.3 密码字段长度改为 100 之前bcrypt 报 Data truncation现象是注册用户时提示Data truncation: Data too long for column password但看数据长度明明只有 60 个字符。原因是有些自动建表工具生成的password varchar(45)或者课程设计题目给出的建表模板里预留长度不够。bcrypt 哈希固定 60 字符但varchar(45)存不下MySQL 在非严格模式下可能静默截断导致以后登录永远验证失败。解决方法是建表时把password长度设为 100 或更大而不是刚好 60如果你已经建表了用ALTER TABLE user MODIFY COLUMN password VARCHAR(100) NOT NULL;改字段类型。5.4 登录成功但页面刷新就掉登录态现象是登录后进入首页按 F5 刷新又跳回登录页。原因基本逃不开两个token 存在了响应式变量而不是 localStorage或者路由守卫在刷新时发现localStorage.getItem(token)为空而存储 token 用的 key 名前后不一致。比如登录时存的是user_tokenaxios 拦截器取的是token永远取不到。排查方法是打开控制台执行localStorage看里面到底有什么 key没有就检查登录成功后的setItem代码。这个问题的难点不在原理而在肉眼很难看出两个字符串的细微差异。5.5 修改密码后旧 token 仍然可以调用接口现象是用户改了密码用刚才登录时拿到的 token 继续请求数据后端照样返回 200。原因是 token 只存在客户端服务端拿到 token 后只做签名验证并不关心密码是否变化。课程设计里最简做法是修改密码接口返回特殊状态码前端收到后主动清 token 跳登录页要想更严谨可以把用户密码的“最后修改时间”编码进 token或者把数据库里的password_version也放进 token 负载拦截器比对版本号不一致就判无效。前一种做法的演示效果已经足够后一种能展示数据版本控制的思路。注意排查这类问题统一从“先看网络请求→再看后端日志→再看数据库当前值”的顺序走。很多问题是自己写代码时把变量名拼错导致的数据不一致和框架本身无关先怀疑自己再去翻框架的配置。6. 把课程设计推出“演示可用”圈账户体系安全性的两个进阶改造登录功能做完只是万里长征第一步课程设计答辩时要经得起追问。老师在看过基本功能后最可能问的问题是“你这个系统安全吗”“凭什么是这个方案”。这里我常做两个低成本小改造。第一个是登录限流。不加限流的登录接口可以被人拿字典无限尝试密码我一般会给登录接口加一个简单的计数拦截用 ConcurrentHashMap 存每个 IP 的错误次数5 分钟内错 3 次就锁定该 IP 15 分钟。比引入 Redis 的成熟限流组件简单得多还不用额外起服务。代码实现上就是一个过滤器包一层计数逻辑课程设计答辩时这一讲就能加分。第二个是 token 续期。JWT 一旦签发在过期之前无法吊销实际做法里会加一个刷新令牌机制但完整的 refresh token 方案对课程设计偏重。简化版做法是token 过期前 30 分钟内的请求后端在响应头里返回新的 token前端 axios 响应拦截器检测到后更新本地存储用户无感续期。这样演示时你会发现页面挂半天也不会被踢出去体验上接近真实产品。验证方法上除了 Postman 测接口我习惯在答辩前用浏览器的隐身窗口做一次完整回归注册新用户 → 登录 → 修改密码 → 用旧 token 请求验证 401 → 重新登录 → 查看操作日志。这五步走完整个系统的工序就全部验证过一遍。我自己的教训是课程设计翻车最多的不是功能没实现而是前后端有一处环境配置没对齐——比如数据库密码结尾有个空格、Vite 代理配到了 8081 却以为后端在 8080、或者忘记装 MySQL 8 的驱动 jar。把这些小地方放在最后一晚去核对比临时改代码靠谱得多。最后要说的是这套思路不仅适用于课程设计本身。把上面的登录闭环完整跑下来数据库表设计、接口分层、token 处理这些经验可以直接往毕业设计和实习项目里搬。如果你现在正被课程设计压着先把数据库建好再把后端的登录接口通掉最后让前端把 token 串起来按这三步走每一步都可验证不会出现“代码写完了却跑不起来”的尴尬。希望帮到你。本文还有配套的精品资源点击获取