
SpringBoot后端用 MyBatis 做持久层配合 MySQL 存储业务数据前端走 Vue 单页应用前后端通过 JSON 接口通信。听起来就是个常规管理系统但真正落地的时候细节远比框架本身复杂。这篇文章我按实际开发流程把这个项目的完整搭建过程、技术选型逻辑、踩过的坑和关键代码设计思路全部捋一遍。如果你正准备做类似的项目或者刚接手一套 SpringBootVue 的代码不知道怎么下手这篇文章应该能省你好几天摸索的时间。1. 项目整体设计与业务拆解1.1 答疑系统的核心角色与功能边界先把这个系统的业务边界划清楚。企业级课程答疑系统重点在“企业级”三个字不是校园里那种随便能用就行的演示项目而是要考虑多人同时在线提问、老师批量回复、问题状态流转、历史记录追溯这些真实场景。系统里的用户角色大致分三类学生提问者、讲师答疑者、系统管理员运营维护者。学生登录后能发起问题、查看自己提过的问题列表、对回复进行确认或追问。讲师端看到的是待回答问题池可以按课程分类筛选、按时间排序、批量回复还能把高频问题标记为典型问答。管理员则负责用户管理、课程分类管理、敏感词过滤配置、答疑数据的统计分析。这三类角色对应的权限边界必须清晰。我见过不少项目把权限写死在页面里按钮隐藏就以为安全了其实后端接口完全裸奔。正确的做法是后端每个接口都校验角色权限前端只是控制展示层。这个系统里我用的是基于拦截器的角色校验配合自定义注解实现细粒度控制。1.2 技术栈选型逻辑为什么是这套组合SpringBoot Vue MyBatis MySQL 这个组合在中小型企业管理系统中属于绝对主流选它不是因为“大家都这么用”而是每一环都有明确理由。SpringBoot 解决的是 Java 后端配置繁琐的问题内置 Tomcat、自动装配、起步依赖管理让开发者把精力放在业务逻辑而不是配置文件上。相比 SSM 时代那一堆 XML 配置SpringBoot 的项目洁简太多。而且它的生态成熟无论是后续接 Redis、RabbitMQ 还是分布式任务都有非常成熟的方案可以直接整合。前端用 Vue 是因为它学习曲线相对平缓响应式数据绑定配合组件化开发构建这种交互较多的后台管理页面非常顺手。Vue 的生态里 Element Plus 这类组件库可以直接提供表格、表单、弹窗等后台常用的界面元素省去从零写 CSS 的时间。而且 Vue 的逻辑是“数据和视图双向绑定”你在页面上改了表单内容数据对象同步更新比原生 DOM 操作直观得多。MyBatis 是个半自动 ORM 框架SQL 自己写映射关系通过 XML 或注解管理。这在这个项目里特别实用因为答疑系统的查询条件非常灵活——按课程查、按状态查、按关键字查、按时间范围查组合起来可能有好几种情况。用 MyBatis 的动态 SQL 可以直接把这些条件拼装逻辑写清楚代码可读性比字符串拼接强太多。而且 SQL 自己掌控遇到性能问题可以直接分析语句不存在黑盒优化这种尴尬事。MySQL 就不用多说了开源、稳定、使用成本低对中小型系统的并发量和数据量来说完全够用。配合 InnoDB 引擎的事务支持和行级锁多用户同时提交问题、回复问题的数据一致性也有保障。1.3 前后端分离的工程结构划分前后端分离是这个项目的骨架后端只提供 RESTful API前端只负责页面渲染和交互两边通过 JSON 通信。这种架构下前后端可以并行开发——后端定义好接口文档前端用 mock 数据先跑页面等接口联调时再替换真实数据。后端的工程结构我按经典的分层模式组织Controller 层接收请求、Service 层处理业务逻辑、Mapper 层操作数据库。不要觉得这种分层是老生常谈当你面对几十个接口、十几张表的时候没有清晰的分层代码就是一团乱麻。Controller 里只做参数接收和结果封装不能出现业务判断Service 里处理事务和业务规则不能在方法里写一长串 SQL 查询Mapper 层就是纯粹的数据库访问SQL 写在 XML 里统一管理。前端结构相对简洁按页面模块划分目录views 下放页面组件components 放公共组件api 目录集中管理所有接口请求router 配置路由表store 放全局状态。每个模块的页面文件、样式文件、逻辑文件就近放在一起维护起来比传统的文件类型集中管理更符合人的直觉。公共组件这块值得多说一句。答疑列表中的“问题卡片”用了组件复用学生端、讲师端、管理后台三处都引用了同一个组件只是根据角色传参控制显示不同操作按钮。这种复用能极大减少重复代码量后续改样式或者加字段只需要改一处。2. 后端核心模块实现SpringBoot 与 MyBatis 的整合细节2.1 项目初始化与依赖管理的实操要点新建 SpringBoot 项目直接用 IDEA 的 Spring Initializr 就能完成关键是要注意版本对应关系。SpringBoot 2.7.x 是目前兼容性最好的版本段既能稳定支持 JDK8也能跑在 JDK11 上MyBatis Starter 的适配问题最少。不要盲目追求最新版我曾经用 SpringBoot 3.0 折腾过一次MyBatis 的 starters 一度没跟上各种配置类不兼容排错排到怀疑人生。核心依赖就这几项spring-boot-starter-web 提供 Web 能力mybatis-spring-boot-starter 用于集成 MyBatisMySQL 驱动只需要一个 mysql-connector-java如果是做权限校验再加一个 JWT 相关依赖接口文档可以引入 springdoc-openapi自动生成 Swagger 文档前后端联调对文档的需求量很大这个必须有。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency启动类上没有太多花活标准写法就是 SpringBootApplication MapperScan 扫描 Mapper 接口所在的包。这里有个容易被忽略的细节MapperScan 的包路径必须写得准写宽了没问题写窄了所有 Mapper 都注入不了启动直接报错。我习惯把 Mapper 接口放在固定包下然后用一个常量配置管理这个路径。2.2 application.yml 配置里的那些坑配置文件的写法直接决定你和数据库对话是否顺畅。先看核心配置server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/qa_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.qasystem.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里几个配置项藏着不少坑。第一MySQL 8.x 的驱动类必须是 com.mysql.cj.jdbc.Driver旧版的 com.mysql.jdbc.Driver 在 8.x 下会直接启动失败。第二URL 里必须带 useSSLfalse否则高版本 MySQL 默认启用 SSL 连接本地开发环境经常因为证书问题报错。第三serverTimezoneAsia/Shanghai 必须设置否则日期字段的读写会出现整整 8 小时的偏差。第四allowPublicKeyRetrievaltrue 是给 MySQL 8 用的本地连接密码登录模式没有它会报 Public Key Retrieval 错误。这四条缺一条你就会在启动或者第一次请求时看到报警信息白折腾半小时是常有的事。MyBatis 的配置里map-underscore-to-camel-case 这个开关尤其重要。数据库字段命名习惯是 user_name 这种下划线风格Java 属性是 userName 驼峰风格打开这个开关后 MyBatis 自动完成映射不用在 XML 里写一堆 resultMap。另外开发阶段配置 log-impl 输出 SQL 日志这是排查问题的关键手段——你连 SQL 都看不到数据查不出来只能干瞪眼。生产环境务必关掉这个日志不然控制台刷 SQL 刷到爆。2.3 Mapper 层设计思路与动态 SQL 实战Mapper 层是这个项目里最有技术含量的部分。答疑系统的查询场景非常复杂我举一个学生端问题列表的例子按课程筛选、按状态筛选、按关键字搜索这三个条件任意组合还可能带分页。这类需求用动态 SQL 是完美解法select idselectQuestionPage resultTypeQuestionVO SELECT q.id, q.title, q.content, q.course_id, c.course_name, q.status, q.create_time, u.nickname AS creator_name FROM question q LEFT JOIN course c ON q.course_id c.id LEFT JOIN user u ON q.creator_id u.id where if testcourseId ! null AND q.course_id #{courseId} /if if teststatus ! null AND q.status #{status} /if if testkeyword ! null and keyword ! AND (q.title LIKE CONCAT(%, #{keyword}, %) OR q.content LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY q.create_time DESC /select这个 标签是 MyBatis 的精华所在。它会智能处理 AND 前缀——如果第一个 if 不生效后面的条件不会残留多余的 WHERE 或 AND 关键字。像这种三四个条件的组合查询如果不用动态 SQL你得在代码里做判断拼接 SQL 字符串又丑又容易漏条件。LIKE 模糊查询这里用 CONCAT 拼接而不是直接在 Java 里拼好传进去原因是防止 SQL 注入。虽然 MyBatis 的 #{} 自带预编译防注入但如果你在 Java 代码里拼好“%关键字%”再通过 #{} 传入这一层安全就已经被破坏了。直接在 SQL 里用 CONCAT 拼参数让参数始终作为预编译的占位符存在是最稳妥的做法。分页这里我直接用了 PageHelper加一个拦截器依赖就行效果就是查询前调用 PageHelper.startPage(pageNum, pageSize)后面紧跟的查询就是分页查询部分方言适配都自动处理好了不用手写 LIMIT。2.4 统一响应体与全局异常处理架构接口返回的格式必须统一否则前端处理起来就是灾难。我定的标准结构是{ code: 200, message: success, data: { } }code 为 200 代表请求成功非 200 表示各种异常情况。这里的关键是全局异常处理器它负责把后端抛出的异常统一转换为这个格式。如果你不这么干SpringBoot 默认返回的错误格式五花八门前端要到处做兼容判断。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public Result? handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public Result? handleValidException(MethodArgumentNotValidException e) { String msg e.getBindingResult().getFieldError().getDefaultMessage(); return Result.error(400, msg); } ExceptionHandler(Exception.class) public Result? handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后重试); } }这里的 RestControllerAdvice 是 Spring 的利器不用改任何一个 Controller就能把所有接口的异常处理逻辑集中收口。业务异常专门做了 BusinessException 类参数校验失败的提示也统一格式。这样做的直接好处是前端 Axios 拦截器只需要判断 code 是否等于 200其他全部走进统一的错误处理函数不用每个页面写一大堆 try-catch。2.5 鉴权方案JWT 拦截器组合疑问系统这种多角色项目鉴权环节不能省。我在项目里用 JWT 做登录态管理流程是这样的用户登录成功后后端生成包含用户 ID 和角色的 token 返回前端前端每次请求在 Header 带上 Authorization: Bearer xxx后端拦截器解析 token拿到用户信息放入 ThreadLocal业务代码直接从 ThreadLocal 取当前用户。拦截器实现 HandlerInterceptor 接口在 preHandle 里校验 tokenpublic class AuthInterceptor implements HandlerInterceptor { 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); UserContext.set(claims.get(userId).toString(), claims.get(role).toString()); return true; } catch (Exception e) { // 跳过进入下方拒绝逻辑 } } response.setStatus(401); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }这里有个经验分享ThreadLocal 用完一定要在 afterCompletion 里清除否则线程池复用的情况下用户 A 的数据可能被用户 B 读到这是典型的线上事故。另外生产环境建议把 token 过期时间定为 2 小时快过期时前端通过刷新接口换新的避免用户在答疑中途突然被踢下线。3. 前端交互层Vue 工程化实践3.1 用 Vite 构建 Vue 项目与目录规划前端我用 Vite Vue3 组合。Vite 的启动速度比 Webpack 快了一个数量级开发体验提升巨大创建项目就一条命令。npm create vitelatest qa-web -- --template vue创建完之后打开 src 目录结构我习惯这样规划api 文件夹放所有请求接口每个页面模块对应一个文件assets 放静态资源components 放公共组件router 放路由配置文件store 放全局状态用的 Piniaviews 放页面组件。如果组件多还会加一个 composables 目录放可复用的逻辑函数。跟后端联调时代理配置是必须的。Vite 的 vite.config.js 里设置 proxy让前端请求的 /api 路径转发到后端 8080 端口这样就不会有跨域问题。有人图省事在后端写了 CrossOrigin 注解全局允许跨域我是强烈不建议的——开发调试还可以生产环境把后端暴露给任何域名的请求都是安全隐患。3.2 路由权限控制与动态菜单答疑系统的三种角色看到的路由应该是不同的。管理员能看到用户管理页讲师能看到问题管理后台学生看不到这些。前端不能只做隐藏必须在路由层面隔离。实现方式使用了路由守卫用户登录成功后后端返回当前用户的角色和菜单权限前端根据权限在完整路由表里过滤出可访问的路由动态追加到 router全局前置守卫里检查访问路径是否在已注册的路由中不在则重定向到 404router.beforeEach((to, from, next) { const store useUserStore(); if (!store.token) { if (to.path /login) return next(); return next(/login); } if (store.role !store.menuGenerated) { const accessibleRoutes generateRoutes(store.role); accessibleRoutes.forEach(route router.addRoute(route)); store.menuGenerated true; return next({ ...to, replace: true }); } next(); });这种方式比后端把完整路由表发给前端再渲染要安全得多因为敏感页面根本没有注册到路由里即使有人手动改 URL 也进不去守卫那一步就直接拦住了。3.3 答疑组件拆解列表、提问弹窗、回复区答疑界面是整个系统用得最多的地方组件拆分的合理性直接关系开发效率和后续维护成本。问题列表我拆成一个 QuestionList 组件接收筛选条件作为 props内部使用 Element Plus 的 el-table 渲染。每行数据有个状态标签待回答是红色、已回复是蓝色、已关闭是灰色。列表右侧操作区根据当前用户角色动态展示学生看到“追问”按钮讲师看到“回复”按钮。提问弹窗单独拆成 AskQuestionDialog 组件内部包裹一个 el-dialog表单里有课程下拉选择、标题输入框、富文本内容编辑区。表单校验用 Element Plus 的 rules 配置标题必填、内容长度限制在 500 字内。提交成功后触发一个事件让父组件刷新列表这种自定义事件的交互模式是 Vue 组件通信的标准玩法。回复区是 QuestionReply 组件接收 questionId 参数内部负责加载该问题下所有回复列表并提供回复输入框。讲师回复后状态自动流转为“已回复”学生的追问会再次把状态改回“待回答”。这种状态机的变化逻辑放前端还是后端我要强调一下必须放后端。前端只负责展示和触发接口状态变更后的判断都在 Service 层完成防止学生通过修改请求参数绕过业务规则。3.4 Axios 封装与接口联调细节Axios 封装是个前端项目的基建工程。我不建议在每个页面里直接调用 axios.get一旦接口地址变更、需要统一加 token 或者全局处理错误码你会想把自己埋了。我的统一封装思路是这样的const service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 200) { return res.data; } if (res.code 401) { router.push(/login); return Promise.reject(new Error(登录已过期)); } ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); }, error { ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );注意几个细节拦截器里统一处理后端返回的 code 而非 HTTP 状态码500 系错误统一弹 ElMessage401 时自动跳转登录页并清空本地存储。这个封装做完每个页面请求数据就是简单的一个函数调用没有任何重复逻辑。如果后端接口开发进度慢于前端还有个技巧搞定联调阻塞——用后端的 Swagger 文档生成 mock 数据或者干脆在 Vite 里配置本地 mock server。实际项目中前端先按接口文档开发后端同步实现联调时再把请求代理切到真实后端这样整个团队能并行推进。4. 数据库设计与查询性能优化4.1 核心表结构设计分析数据库设计决定了一个系统能不能扛住业务发展。答疑系统的核心就是用户表、问题表、回复表这三张表外围再关联课程表、消息通知表。用户表字段不多但都有讲究id 主键、username 唯一索引、password 加密存储用的是 BCrypt不要用 MD5、nickname 显示名、avatar 头像 URL、role 角色枚举、status 账号状态。密码加密这是底线问题明文存储等于裸奔。问题表是核心业务表字段包括id、title、content、course_id 关联课程、creator_id 关联用户、status 状态、view_count 浏览数、reply_count 回复数、create_time、update_time。这里我加了一个 answer_time 字段记录首次回复时间用于后续统计平均响应时长的数据分析需求。回复表相对简单id、question_id 关联问题、replyer_id 关联回复人、content 回复内容、parent_id 用于回复内的嵌套追问、create_time。外键这块业务量小的时候可以建 FOREIGN KEY 保证完整性。但在企业级场景下我建议不要建物理外键用逻辑关联配合索引代替。物理外键在数据量大时影响插入和更新性能而且各种级联删除容易引发隐藏问题。保持表间关系清晰业务层去维护一致性即可。4.2 索引设计的最佳实践索引是整个系统查询速度的基石。问题表最常见的查询模式是按照 course_id 筛选、按 status 筛选、按 create_time 排序。我给这三者的组合建立了联合索引 (course_id, status, create_time)。联合索引的字段顺序有讲究最左前缀原则下把区分度最高的字段放在最左边。course_id 的区分度通常高于 status所以放在前面。这样无论是单独查 course_id还是查 course_id status都能命中索引。还有一类查询是学生查看自己提的问题列表这个用 creator_id 建单列索引就够了。回复表则要对 question_id 建索引否则查看一个问题的所有回复时会全表扫描。排查慢查询有两个最实用的手段一是 MySQL 的慢查询日志开启后周期性去 slow.log 里看记录二是用 EXPLAIN 关键字分析 SQL 执行计划。EXPLAIN 看到 type 为 ALL 或 rows 数值异常高基本就是索引没建好需要立即调整。4.3 典型 SQL 优化案例连表查询与统计场景答疑系统里有一个统计大屏场景管理员想看到每门课程的提问数量、平均回复时间、待回答数量。如果直接在代码里循环调接口统计N 门课程就是 N 次数据库查询性能非常差。我用一条 SQL 直接聚合查出来SELECT c.course_name, COUNT(q.id) AS total_question, SUM(CASE WHEN q.status 0 THEN 1 ELSE 0 END) AS pending_count, AVG(TIMESTAMPDIFF(MINUTE, q.create_time, q.answer_time)) AS avg_reply_minutes FROM course c LEFT JOIN question q ON c.id q.course_id GROUP BY c.id这条 SQL 利用 GROUP BY 结合 CASE WHEN 做条件统计一条语句算完所有课程维度的核心指标。LEFT JOIN 保证没有问题的课程也能显示出来统计数为 0。如果需要按时间范围统计再加一个 WHERE 条件即可。这个优化带来的效果非常明显——原先几十次循环查询变成了一条 SQL接口响应时间从秒级降到几十毫秒。4.4 连接池与会话配置MySQL 连接池的参数不容忽视。SpringBoot 默认使用的是 HikariCP性能在同类产品中处于顶尖水平。核心参数按这个基准调maximum-pool-size 设为 20空闲连接 minimum-idle 设 5连接超时 connection-timeout 设置 30000 毫秒最长生命周期 max-lifetime 分钟限制在 30 分钟。这里有个容易中招的坑max-lifetime 一定要比 MySQL 服务器的 wait_timeout 短。MySQL 默认 wait_timeout 一般是 8 小时如果连接在池里待了 8 小时以上被服务端断开客户端不知道还在用这个失效连接请求就会报通讯异常。HikariCP 的 max-lifetime 默认就是 30 分钟你只需要确认最小值别被自己改成超过 8 小时就行。5. 常见问题排查与避坑实录5.1 问题排查方法我做这套系统的时候遇到不少疑难杂症这里挑典型的整理成表方便你遇到类似问题时快速定位。问题特征可能原因排查方式启动报 Failed to configure a DataSourcedatasource 配置没生效或 URL 写错检查 application.yml 的 url 是否能直接连接排除空格和转义字符问题接口返回 500 Table doesnt existMapper XML 中表名和实际表名不一致看 MyBatis 日志中的完整 SQL复制到 Navicat 里执行对比前端请求跨域报错Vite 代理未配置或后端未允许来源检查前端代理配置 network 标签中的请求 URL 是否走代理路径日期字段显示少 8 小时JDBC URL 缺 serverTimezone 参数对比数据库时区和应用时区加上 GMT8 配置登录成功后其他接口仍 401拦截器未放行 OPTIONS 请求拦截器 preHandle 中单独处理 HttpMethod.OPTIONS 预检请求密码加密后无法登录BCrypt 加密随机盐导致每次结果不同用 matches 方法校验而非直接 equals分页数据重复或丢失PageHelper 只对第一条查询生效确保 startPage 后面紧跟着要分页的查询语句不要插入其他逻辑每一个问题后面都是一个真实场景。跨域那个尤其经典前端用的是 5173 端口后端是 8080浏览器默认的 CORS 策略会拦截这类请求。Vite 的 proxy 一键搞定但要让 OPTIONS 预检请求通过拦截器不然浏览器先发送预检被后端 401 挡回来了真实请求根本不会发出去。5.2 常见问题排查技巧第一个口诀任何运行期不确定的问题先开 MyBatis SQL 日志。配置了 log-impl 之后控制台会打印完整 SQL 语句和参数基本 70% 的数据问题都能从这里找到答案。参数值没传对、状态码类型不匹配、LIMIT 条件不对一眼就能看出来。第二个经验前端 401 和后端 401 是两回事。有时候你看到控制台打印“401 Unauthorized”以为是后端拦截器挡的其实可能是 Nginx 层就已经拒绝了未带 Host 头的请求。排查时先从 Browser 开发者工具里确认是哪个 URL 返回的 401再逐层定位。第三个技巧联调阶段的接口错误不要只看响应体。Axios 拦截器把错误吞掉后弹了个 ElMessage 就完了很多细节都被盖住了。我在拦截器里把错误对象打印到控制台保持 console.error这样排错时可以看到完整的错误堆栈——这个习惯能极大提升排错效率。等系统稳定了再关掉 console开发阶段多打日志永远不吃亏。第四个忠告表结构变更后一定先清 MyBatis 缓存。改 SQL、改表字段后第一次测试结果还是旧的排查半天发现是缓存惹的祸。另外二级缓存默认不开启实际项目中我建议你就别开——答疑这种实时性要求高的系统一个用户的问题刚提交完另一个用户看到的是缓存里的旧数据这个体验是很糟糕的。第五个经验讲一个具体案例。生产环境出现过一次问题某个接口偶尔超时日志里能看到小概率的全表扫描数据规模才几万条但 SQL 查询耗时却到了 1 秒以上。EXPLAIN 分析发现 WHERE 条件里出现了隐式类型转换——表里的字段是 varchar因为当初设计了字符串类型的问题编号但 Java 代码传的是 Long 类型MySQL 自动把字段 CAST 成数字索引完全失效。改成随身传字符串后查询瞬间回到毫秒级。这类隐式转换的坑其实很常见联查关联字段的类型一致性要尽早核对。5.3 项目部署的验证清单本地跑通了不代表生产环境能跑通部署环节有几个高频问题帮你们提前规避。网站配置了 context-path反向代理转发需要对应修改 nginx 配置中的 location 路径多加了一个 /api 前缀代理规则却仍然指向根路径页面白屏。前端打包之后的静态资源默认是相对路径放到子目录部署时 CSS、JS 加载不到。在 vite.config.js 的 build 节点把 base 设置为 ./ 就能解决原理是让所有资源的引用都基于当前路径。生产环境要检查是否配置了日志级别和文件输出。如果 still 用开发环境的控制台输出日志文件完全没有出错后排查非常痛苦。定时任务记得区分环境另一套系统上线后加了个晚上的统计任务结果开发环境也跑了起来生成了大量测试脏数据干扰业务。如果项目是按单体应用部署我建议前端构建后的 dist 目录可以直接拷贝进 SpringBoot 的 static 目录让后端统一托管静态资源这样就不需要额外配置 Nginx 去做静态资源转发少一个环节就少一类问题。但如果你有独立域名、需要做多个应用统一入口那还是拆分部署更合理。5.4 写代码之外的一些体会整套系统完整做完实际编码量并不是最耗时的最磨人的是业务逻辑的边界梳理和联调中的细节。像状态机流转规则、权限边界、并发情况下同一问题被两个讲师同时回复怎么处理我加了乐观锁用 update_time 做版本字段控制这些属于交互文档里很难完全描述清楚的部分真正跑起来才会浮现。场景层面对你说一句遇到问题先想数据流再想代码。很多 Bug 的根本原因是数据在某个节点发生了预期之外的流转——状态没更新、字段传错、时区没有对齐、索引失效。当你把注意力放在数据的整个生命周期上分析问题会准很多修复的效率也高不少。你现在手头如果有类似的课程答疑系统要做或者你正在维护一套用 SpringBootVue 写的后台管理代码把上面列举的模块逐一对照检查一下。这中间大部分设计思路和排错经验都是通用可复用的直接拿过去就能省掉一大半绕路的时间。