资讯动态

SpringBoot+Vue3+MyBatis前后端分离项目开发实战

发布时间:2026/10/21 21:36:03 来源:尧图企业网站定制
上个月刚帮某单位做完一套党员学习交流平台前后端加起来开发周期三周多。技术栈就是标题里那套SpringBoot Vue3 MyBatis数据库用的 MySQL前后端完全分离。核心功能覆盖党员在线学习、考试测验、积分排行、论坛交流还有后台管理。老实说这套系统的技术难度不算高但它把权限设计、数据一致性、文件上传、批量导入、部署交付这些实际项目里最磨人的东西全部走了一遍非常适合想完整跑一个前后端分离项目的开发者参考。我尽量把设计过程和踩坑经历写清楚希望你们少走弯路。1. 先说清楚需求再谈技术选型这套系统到底要解决什么问题1.1 原始工作方式里的三个痛点项目启动之前我调研过他们现有的管理模式问题相当典型。第一是学习资料分发靠微信群。每次发一份PDF或者视频链接几天之后就淹没在聊天记录里想回看只能往上翻新加入的成员更是找不到入口。第二是学习进度靠Excel统计。党群干事月底手动收集各支部的表格几千行数据来回传格式五花八门汇总一次要折腾好几天。第三是交流互动只能依赖线下或者临时群聊主题分散讨论内容沉淀不下来。所以这套平台的核心目标非常明确把资料分发—在线学习—进度统计—交流反馈这条链路全部线上化让党员随时能看、管理员实时能统计、交流内容有沉淀。这决定了业务的几个关键模块组织用户管理、学习资料管理、学习记录与积分、考试测验、论坛交流、后台统计看板。1.2 技术选型对比为什么不是 SSM、JPA 或者微服务这个系统是典型的中小型内部管理系统预估并发量不高但功能模块多、权限要求细。我在选型时做了几组对比。选型点备选方案最终选择理由后端框架Spring Boot vs 传统 SSMSpring BootSpring Boot 自动配置省掉大量 XML 配置内嵌 Tomcat 方便部署生态成熟持久层MyBatis vs JPAMyBatis这个项目有大量统计报表和动态条件查询MyBatis 的 SQL 完全可控排查性能问题也直观数据库MySQL vs PostgreSQLMySQL单位已有 MySQL 运维经验5.7/8.0 完全够用没必要引入新运维负担前端框架Vue3 vs Vue2 vs ReactVue3 组合式 API团队对 Vue 更熟Vue3 的 setup 语法适合复用复杂表单逻辑Element Plus 组件库直接补齐后台界面架构形态前后端分离 vs 单体模板渲染前后端分离后期要对接 App 和外部系统的接口从一开始就按接口化做更省事有几个选型点值得展开讲。关于 MyBatis 而不是 JPA如果项目全是标准 CRUDJPA 写起来确实快。但学习统计这块比如 统计每个支部本月人均学习时长、按积分排行、筛选已完成某门课程的党员 这类查询SQL 相当复杂还要考虑索引命中。用 MyBatis 可以把 SQL 沉淀在 XML 里review 的时候直接看 SQL 就能判断性能出了问题也好定位。JPA 虽然可以用 Query 注解写 JPQL但复杂查询到后期基本绕不开原生 SQL反而两头不讨好。关于没有用 Redis不是不能用是没必要。当时评估过积分排行和在线状态这些场景用户量几百单机 MySQL 完全扛得住。引入 Redis 意味着多一套运维和缓存一致性问题。当然如果你做的是几千人甚至上万人的平台Redis 可以做排行缓存和热点文章缓存这个放在后面扩展里说。关于没用微服务全家桶这是我最想提醒的一点。很多人一上来就想 Spring Cloud Alibaba、Nacos、Gateway 全上但这个项目就是一个单体应用拆微服务除了增加部署复杂度和分布式事务问题没有任何收益。架构的复杂度应该跟着业务复杂度走而不是跟着简历复杂度走。1.3 理清楚用户角色和总体功能地图系统里的角色我最终收敛成三种系统管理员负责用户管理、部门/支部管理、角色权限配置。运营管理员党群干事负责发布学习资料、创建考试、审核帖子、导出统计报表。普通党员浏览学习资料、观看视频、参加考试和自测、发帖交流、查看个人积分和排行。之后你会发现权限模型如果设计得足够灵活系统管理员 和 运营管理员 也可以用同一套 RBAC 模型来区分菜单权限和按钮权限。具体怎么落地我放在后端部分讲。功能地图按模块划分就是首页学习大厅、课程/资料详情、在线考试、个人中心学习记录、积分明细、我的帖子、论坛广场、后台管理用户、部门、资料、考试、帖子、统计。到这里需求已经清楚了下一步直接设计数据库。2. 数据库设计是第一道关卡用三块域把表拆清楚这个项目的表大概有十几张我先说一个我在设计时坚持的思路不要一上来就细化字段先把业务拆成三块域——组织用户域、学习域、交流域。域和域之间只通过必要的关联字段衔接各自内部的表保持高内聚。这样的好处是后面加功能比如加一个考试模块时不用来回改已经稳定的表结构。2.1 组织用户域从一开始就要支持用户属于某个支部用户表 sys_user 是最核心的表。字段包括 id、username、password、real_name、department_id、avatar、phone、status、create_time。密码字段我用 BCrypt 加密存储长度直接设 64 或者 255别图省事设成 45因为 BCrypt 输出长度固定 60但预留一点空间没有坏处。支部/部门表 sys_department 用树形结构字段是 id、parent_id、dept_name、sort。这里没有用嵌套集模型因为内部单位层级不会太深parent_id 这种邻接表模型足够而且查询子树时可以在 Java 内存里递归组装树没必要用复杂的数据库模型。用户和角色的关系用 sys_user_role 关联表。为什么不用直接在 sys_user 里加一个 role_code 字段因为虽然现在看起来每个用户只有一个角色但说不定以后就有 支部管理员兼运营员 这种组合身份RBAC 的标准做法本来就是用户-角色多对多。用关联表多花不了多少时间却能让权限模型真正灵活起来。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(255) NOT NULL, real_name VARCHAR(50) DEFAULT NULL, department_id BIGINT DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;2.2 学习域学习记录表是整个模块的心脏学习资料表 learn_material 不需要太复杂关键是 type 字段要设计清楚。我在项目里把资料分成文章、图片、视频、附件四种用 type 字段区分video_url 单独留一个字段附件上传路径统一放 file_url。如果只留一个 media_url 字段文章类型存内容、视频类型存链接、附件类型存路径全塞在一起后续写前端渲染逻辑会非常别扭经常要根据 type 判断字段有没有值容易出错。真正需要细心设计的是学习记录表 study_record它要支撑这个人学过哪些资料、学了多久、学完了没有、学习进度多少这些查询。CREATE TABLE study_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, material_id BIGINT NOT NULL, study_duration INT DEFAULT 0 COMMENT 累计学习时长/秒, progress INT DEFAULT 0 COMMENT 进度百分比0-100, finish_flag TINYINT DEFAULT 0 COMMENT 是否完成 1完成, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_material (user_id, material_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习记录表;这里有个关键设计userId 和 materialId 建唯一联合索引。学习记录表在项目里是写入频率最高的表之一视频播放进度几乎每隔一段时间就要上报一次。如果不加唯一索引同一用户对同一资料很容易出现多条记录统计时长时要么重复计算要么还需要先查主键再更新。加了唯一索引之后可以直接用 INSERT ... ON DUPLICATE KEY UPDATE 实现 有记录就累加时长没有就创建一条 SQL 搞定不用先查后更。积分流水表 score_record 单独拆出来也是一个正确的决定。积分规则以后肯定会频繁调整如果把积分余额直接写在用户表的 score 字段里每次调整规则都要刷数据。我设计成积分流水表每一条变动都留有 scene_type 字段学习、考试、发帖、评论、被点赞等需要的时候随时按用户聚合出来。用户表里保留 total_score 字段只是为了列表展示和排行查询不把它当作唯一数据源。学习域里还有一个考试模块。第一版我把考试做得相对轻量exam_paper 存放试卷、exam_question 存放题目、exam_record 记录每次答题结果。题目类型只支持单选和多选提交答案时在前端判分、后端重新计算目的是防止有人直接改前端数据刷分。2.3 交流域帖子、评论、点赞注意冗余计数论坛模块就是典型的帖子-评论-点赞三件套。forum_post 表除了基本信息外我特意加了 view_count 和 comment_count 两个冗余计数字段。如果列表页每次都去 COUNT 评论表数据量稍微大一点查询就会变慢而冗余计数在点赞和评论时可以顺手 UPDATE 累加。帖子表的 status 字段用来表示待审核/已发布/已隐藏。因为平台对内容有审核要求运营管理员需要能审核和隐藏帖子。评论表 forum_comment 用 parent_id 支持一级回复没有做深层嵌套因为二级以上嵌套逻辑复杂度上升但用户体验提升有限论坛第一版完全够用。点赞表 forum_like 直接用联合主键post_id, user_id天然防重复点赞不需要再单独做幂等判断。CREATE TABLE forum_post ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL, content LONGTEXT NOT NULL, category_id BIGINT DEFAULT NULL, view_count INT DEFAULT 0, comment_count INT DEFAULT 0, like_count INT DEFAULT 0, top_flag TINYINT DEFAULT 0 COMMENT 是否置顶, status TINYINT DEFAULT 1 COMMENT 1待审 2已发布 3已隐藏, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT帖子表;2.4 索引设计只看实际查询不看理论我在这块想多说一句表结构出来之后索引不是越多越好而是根据实际查询语句去反推。系统里高频查询有这么几类学习大厅列表按分类、按发布日期排序、我的学习记录按 userId 查、积分排行按 total_score 排序、帖子列表按分类 分页 按创建时间排序。所以我在学习记录表建了 (user_id, material_id) 唯一索引在帖子表建了 (category_id, create_time) 联合索引在积分表建了 (user_id, create_time) 索引。其他那些一时觉得可能有用的索引比如用户名模糊查询、帖子标题模糊查询后面都通过排查慢 SQL 再单独补没在一开始全建上。因为索引会拖慢写入速度尤其是 study_record 这种频繁写入的表索引过多会在高并发上报时形成写瓶颈。3. 后端 SpringBoot 实现权限设计是核心积分一致性是难点3.1 登录鉴权链路JWT Spring Security 的落地方式后端我用的 Spring Boot 2.7 Spring Security jjwt 0.11.5没有引入独立的 OAuth2 框架。内部管理系统的登录场景很简单账号密码登录签发一个 JWT前端后续请求在 Header 里带着 token后端从 token 解析出用户 ID 和角色。先说 JWT 工具类。核心逻辑就是生成 token 和解密 token密钥放在配置文件的 spring.jwt.secret 里过期时间看业务需求我这边设的是 12 小时。token 里我塞的 Claim 只有 userId 和 username还有 roleCodes不要往 token 里塞太多信息因为 token 有时会出现在日志里塞太多敏感信息不利于安全。public String createToken(Long userId, String username, String roleCodes) { return Jwts.builder() .setSubject(userId.toString()) .claim(username, username) .claim(roles, roleCodes) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); }然后写一个 JwtAuthenticationTokenFilter继承 OncePerRequestFilter在 Spring Security 过滤链里先解析 token解析成功就把用户信息放进 SecurityContext。这里有个细节过滤器里抛出异常不会直接进入全局异常处理器我也是踩了坑才补上代码——在过滤器的 catch 里直接写 response 返回 401 JSON不要再继续调用 filterChain。Spring Security 的配置类里我开了跨域支持、关闭了 CSRF前后端分离 JWT 的无状态模式本来就不需要 CSRF、放行了 /api/auth/login 和静态资源路径其他请求全部需要认证。http.authorizeRequests() .antMatchers(/api/auth/login, /api/file/**).permitAll() .anyRequest().authenticated()这里有一个特别容易被忽视的坑Spring Security 会默认生成一个 BASIC 认证并且如果没有显式配置 CORS跨域请求会在过滤链的最前面被拦截。所以 cors() 必须加在 Security 过滤链上只靠 CrossOrigin 注解在很多情况下是不生效的。我后面专门有一节讲跨域问题。3.2 RBAC 权限模型不只有角色判断还要有数据权限RBAC 的常规做法是菜单表、按钮权限表、角色表、角色-菜单关联表。登录成功后后端返回当前用户所拥有的权限码列表前端根据权限码渲染菜单和按钮。我在项目里没有做动态菜单那么复杂只是做了一套简化版后端在登录接口返回一个 permissions 数组比如[user:add, user:edit, post:audit, material:publish]。前端拿到之后存到 Pinia然后通过一个自定义指令v-permission控制按钮显隐。管理员登录后能看到所有菜单运营管理员只能看到资料管理和帖子审核菜单普通党员只看到学习大厅和论坛。但要注意前后端控制只是用户体验层面的真正的权限校验必须回到后端。后端用 Spring Security 的 PreAuthorize 注解做接口级校验PreAuthorize(hasAuthority(material:add)) PostMapping(/material) public Result addMaterial(RequestBody MaterialDTO dto) { ... }这个权限码是在用户登录后从数据库查出来放到 Authentication 里的后面每个请求都会带着。还有一种权限是数据权限这个比功能权限更容易忽略。比如运营管理员只能管理自己所在支部的党员和学习记录系统管理员可以跨支部。这属于数据范围控制我在查询用户列表时加了一个条件如果当前登录人是运营管理员强制加上 department_id 等于自己所属支部的条件。这个逻辑放在 service 层不放在前端因为接口是公共的谁都能直接调用。3.3 MyBatis Mapper 的 SQL 组织该 join 就 join该分身就分身MyBatis 的项目我习惯 Mapper 接口 XML 文件的方式。简单单表 CRUD 可以直接用注解复杂查询统一放 XML。举个例子后台分页查 学习记录列表需要显示用户名、资料标题、所属支部、学习时长、完成状态 这五个字段。我的做法是主查询查 study_record 表JOIN sys_user 表拿用户名和支部 IDJOIN learn_material 拿资料标题。分页用 PageHelper一条 SQL 搞定。select idselectStudyRecordPage resultTypecom.example.dto.StudyRecordDTO SELECT sr.id, sr.user_id, sr.material_id, sr.study_duration, sr.progress, sr.finish_flag, sr.update_time, u.real_name, u.department_id, m.title AS material_title FROM study_record sr LEFT JOIN sys_user u ON sr.user_id u.id LEFT JOIN learn_material m ON sr.material_id m.id where if testdepartmentId ! null AND u.department_id #{departmentId} /if if testmaterialId ! null AND sr.material_id #{materialId} /if /where ORDER BY sr.update_time DESC /select这里有个性能细节PageHelper 分页时它会在原 SQL 外层包一层 COUNT 查询如果原 SQL 的 join 复杂COUNT 查询也会跟着 join白白浪费性能。我当时排查过一条慢 SQL后面解决办法是写了单独的 COUNT 查询只统计主表的行数而不是完全依赖 PageHelper 生成的 count 语句。还有个使用习惯值得分享能 Distinct 解决的问题尽量用 GROUP BY另外能用 LEFT JOIN 就不用子查询但子查询在有些场景下反而更快到底用哪种要看 EXPLAIN 的结果不要凭感觉。3.4 积分并发与幂等同一个人刷学习时长怎么拦这是整个后端里最容易写错的地方。场景是这样的前端视频播放页每 30 秒上报一次进度用户在多个标签页同时打开同一门课时同一秒可能进来两个上报请求。如果不做防重学习时长就会被重复累加积分也会被刷。我的做法分三层第一层数据库唯一索引兜底。study_record 表有 (user_id, material_id) 唯一索引论坛点赞表有联合主键这是最硬的一层保障。第二层使用 INSERT ... ON DUPLICATE KEY UPDATE。学习记录上报时不需要先查再更直接一条 SQL 把新时长累加进去。INSERT INTO study_record (user_id, material_id, study_duration, progress, finish_flag) VALUES (#{userId}, #{materialId}, #{duration}, #{progress}, #{finishFlag}) ON DUPLICATE KEY UPDATE study_duration study_duration #{duration}, progress #{progress}, finish_flag CASE WHEN finish_flag 0 THEN VALUES(finish_flag) ELSE finish_flag END但这里有个细节ON DUPLICATE KEY UPDATE 里的 duration 不能加在 SET 子句上那样每次累加 0等于没加。而是应该从参数里把本次上报的新增秒数和当前记录已有秒数区分开来。第三层事务边界。学习记录累加、积分流水写入、用户总积分更新这三个操作必须在一个事务里。否则可能出现学习记录加了但积分流水没写的情况。事务这里特别容易踩一个大坑在同一个类里调用自己的 Transactional 方法事务是不生效的。比如积分 service 的 addScore 方法加了事务注解但它是被同类里的另一个方法 this.addScore() 调用的Spring AOP 就拦截不到事务直接失效。解决办法要么把加了事务注解的方法抽到另一个 service 里面要么注入自身代理对象。积分累计也要用原子 SQL不要先 SELECT total_score再在 Java 里加一最后 UPDATE 回去。并发情况下这种读改写会互相覆盖。正确写法是UPDATE sys_user SET total_score total_score #{score} WHERE id #{userId}这样数据库行锁会保证并发更新不丢失。4. 前端 Vue3 实现从工程目录到业务页面的完整结构4.1 用 Vite 搭项目目录划分决定了后续开发节奏前端我用的 Vue3 Vite TypeScriptUI 框架 Element Plus状态管理 Pinia路由 vue-router。最开始目录结构我划分得很清楚src ├── api # 按模块拆分的接口请求 ├── assets # 静态资源 ├── components # 公共组件 ├── layout # 后台布局 ├── router # 路由配置 ├── stores # pinia 状态 ├── utils # axios 封装、工具函数 ├── views # 页面 └── types # 类型定义api 目录里每个模块一个文件比如 user.ts、material.ts、forum.ts里面统一导出接口方法。这比直接在组件里写 axios 调用要清晰得多后期接口字段改动只需要改一个文件。4.2 路由守卫与动态菜单未登录跳登录页无权限跳 403路由这块有两个场景要处理未登录的人访问任意页面都要跳转登录页登录后有没有权限访问某个管理页面。我写了一个全局前置守卫router.beforeEach((to, from, next) { const token useUserStore().token if (!token) { if (to.path /login) return next() return next({ path: /login }) } if (!useUserStore().roles.length) { // 拉取用户信息 } // 根据路由 meta 里的 roles 判断权限 if (to.meta.roles !to.meta.roles.includes(useUserStore().role)) { return next({ path: /403 }) } next() })这里踩过一个小坑刷新页面时 Pinia 里的用户状态会丢失而 token 存在 localStorage 里还在。所以刷新后要在路由守卫里重新拉取用户信息再判断权限否则会出现明明已经登录了刷新一次就被踢回登录页的现象。我用 Pinia 的 persist 插件把用户基本信息也持久化到 localStorage刷新后先从本地恢复再静默调接口校验 token 有效性这个体验要好很多。4.3 axios 封装401 统一跳转业务码统一处理axios 封装是我每次项目都重点强调的部分。所有接口调用要么走封装好的 http 实例要么走对应的 api 函数绝对禁止业务代码里散落裸的 axios 调用。service.interceptors.request.use((config) { const token useUserStore().token if (token) config.headers.Authorization Bearer ${token} return config }) service.interceptors.response.use( (response) { const res response.data // 后端统一返回 { code: 200, data: ... } 这种结构 if (res.code 200) return res.data ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, (error) { if (error.response?.status 401) { useUserStore().clearAuth() router.push(/login) } ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )记得把 token 失效401和权限不足403分开处理。401 跳登录页清用户信息403 弹提示即可。后端返回的业务错误码比如 5001 表示积分不足也不要和 HTTP 状态码混在一起统一放在响应体的 code 字段里。4.4 学习大厅、视频播放、论坛三个核心页面的组件设计学习大厅页面是典型的列表 筛选 分页结构。我用 Element Plus 的 Tabs 做分类切换卡片列表用 el-card 展示资料封面、标题、时长、学习状态。组件拆分上把资料卡片抽象成 MaterialCard 组件复用列表和推荐位。视频播放页是前端最有文章可做的地方。视频用的 HTML5 原生 video没有引入第三方播放器。学习进度上报逻辑是这样的播放器启动时记录 startTime每 30 秒向后端上报一次本次清理的时长同时更新本地进度用户关闭页面或者切换路由时上报最后一段剩余时长视频播完调用 finish 接口标记完成。上报要解决两个问题频繁请求会压垮后端、用户关页面时请求可能发不出去。前者我用 30 秒节流来处理后者我用navigator.sendBeacon来兜底在 beforeunload 事件里用 sendBeacon 发请求比在 unload 里用 fetch 可靠得多。论坛页面其实就是一个标准的内容社区帖子列表、发帖弹窗、帖子详情、评论列表。这个模块最关键的组件是富文本编辑器。我在项目里用的是 wangEditor它对 Vue3 支持不错体积也适中。要注意的是富文本内容必须做 XSS 过滤我在后端存内容前用 Jsoup 消毒把 script、iframe、onclick 这类危险标签清理掉。之前有人调侃说只有自己的系统不做 XSS 防护但真出问题就晚了这部分不能省。4.5 管理后台的表格页表单校验和分页查询的标准套路管理后台的页面用户管理、资料管理、考试管理、帖子审核套路高度一致顶部筛选表单、中间数据表格、底部翻页、弹窗编辑。Element Plus 的表格配合 el-form 弹窗是最顺手的组合。我自定义了一个 SearchForm 组件通过传入配置数组来生成筛选表单避免每个页面写重复的表单代码。这个配置驱动的思路在项目后期字段增加时省了很多时间。筛选表单里的日期范围控件有个细节传给后端的日期范围要转成startTime和endTime两个字段并且 endTime 要加上 23:59:59否则查询不到当天的数据。这个坑几乎每个新人都踩过。5. 联调部署与踩坑记录跨域、反射、时区、批量导入5.1 跨域问题为什么加了 CrossOrigin 还报错前后端分离开发时前端跑在 Vite 默认的 5173 端口后端跑在 8080跨域问题绝对会遇到。最常见的报错是CORS error: Request header field authorization is not allowed by Access-Control-Allow-Headers。原因很简单接口请求带了自定义 HeaderAuthorization跨域预检请求 OPTIONS 必须能被后端正确响应并且在后端允许的请求头列表里加入 Authorization。之前在 Spring Security 配置里说过跨域配置必须加在 Security 过滤链里而不能只依赖 Spring MVC 的 CrossOrigin 注解。因为 Spring Security 的过滤链在 MVC 之前预检请求进入 Security 过滤器时就会被挡住根本到不了处理跨域的组件。我当时的配置是这样的写一个 WebMvcConfigurer 的 addCorsMappings 方法同时在 SecurityConfig 里调用http.cors()两个配合才彻底解决。// Spring Security 配置 http.cors().and().csrf().disable() // MVC 跨域配置 Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }不过等到生产环境前端构建后放到 Nginx通过 Nginx 反向代理/api/到后端同源部署就没有跨域问题了。跨域只存在于开发阶段。这是最省心的路径不用去研究各种跨域方案。5.2 MyBatis 驼峰映射和日期时区两个容易让人困惑的小坑第一个坑是驼峰映射。MySQL 字段是user_id、real_nameJava 属性是 userId、realName如果没配map-underscore-to-camel-case: true查出来的对象全是 null。这个配置在 application.yml 里加上就行但要注意多表 join 时如果有重名字段比如用户表的 create_time 和帖子表的 create_time 同时映射到 DTO 的 createTime就会互相覆盖。所以 DTO 里要对查出来的字段取别名比如u.create_time AS userCreateTime。第二个坑是日期时区。MySQL 连接串上必须写上serverTimezoneAsia/Shanghai否则 JDBC 驱动和数据库之间会有时区偏移查出来的时间比实际慢 8 小时。如果用了 MySQL 8.0驱动会自动检测但推荐还是显式写清楚。Java 侧的日期类型统一用LocalDateTime前端接收时通过 dayjs 格式化展示不要在 Java 里用 Date 还去手动设置时区那种写法混乱得很。5.3 批量导入党员数据的性能优化运营管理员要批量导入党员名单初始数据量不小几千人一次性导入 Excel。我第一版图省事直接在 service 循环里逐条 INSERT5000 条数据跑了一分多钟而且中途出错的话事务回滚全部白干。优化后分三步。第一步用 Apache POI 的流式读取模式SXSSFWorkbook避免一次性把整个 Excel 加载进内存。第二步分批插入每 500 条为一批用 MyBatis 的 foreach 批量插入 SQL而不是在循环里逐条调用 insert 方法。第三步是启用 spring transaction 的嵌套回滚一批失败不影响前面已经成功的历史批次数据。insert idbatchInsertUsers parameterTypelist INSERT INTO sys_user (username, password, real_name, department_id, status) VALUES foreach collectionlist itemu separator, (#{u.username}, #{u.password}, #{u.realName}, #{u.departmentId}, 1) /foreach /insert批量插入还有一个小细节注意 MySQL 的 max_allowed_packet 限制。一次插入太多条SQL 报文超过最大值会报错。我每次 500 条是经过测试的安全值不用贪多。5.4 Linux 部署和 Windows 开发环境的差异开发时在 Windows 上跑没问题部署到 Linux 之后有几处必须改。第一处是文件上传路径。Windows 上我用的是D:/upload/这种绝对路径Linux 上要换/data/app/upload/并且要为上传目录配置正确的读写权限否则创建任务或者上传头像会报 IOException。更好的做法是在 application-prod.yml 里用${file.upload-path:/data/app/upload/}这种配置项部署时通过环境变量传进去不要硬编码。第二处是 jar 包启动方式。不要直接java -jar挂着就完事我自己写了一个启停脚本用nohup方式启动并指定--spring.profiles.activeprod。生产环境的数据库密码不要写在 application-prod.yml 里提交到仓库用环境变量覆盖。第三处是 Nginx 配置。前端用 history 模式路由刷新页面会出现 404必须在 Nginx 里加一个 location 块做回退location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }proxy_pass后面的斜杠要特别注意写成http://127.0.0.1:8080/和http://127.0.0.1:8080两个效果是完全不同的——带斜杠会把/api前缀去掉不带斜杠则原样转发。想要前端接口路径是/api/user/list后端 Controller 是/user/list就必须要带斜杠。这个我调了整整一下午才意识到问题。6. 这套系统还可以怎么扩展项目交付之后我自己梳理了几个值得做的扩展方向也供你们参考。一是接入 WebSocket 做站内实时通知。现在帖子回复、审核通过这类事件只能靠轮询或者刷新才看得到。用 WebSocket 推送一条未读消息体验会好很多。Spring Boot 集成 WebSocket 实现起来并不复杂通过鉴权拿到 userId 绑定 session 即可。二是引入 Redis 做缓存和排行。用户量如果涨到几千甚至上万积分排行榜和热门帖子列表这种读多写少的场景就很适合缓存。Redis 的 ZSet 做积分排行简直天生合适ZREVRANGE 一条命令就能拿到前十名。但要注意缓存和数据库的一致性积分变动时要主动删除缓存键而不是等它自然过期。三是对接统一身份认证。很多单位已经有现成的组织架构和账号系统这套平台的用户登录取了数据库自己的一套账密。后续如果要做单点登录对接 CAS 或者 OAuth2 也是水到渠成的事前提是当初用户表设计时不要把账号逻辑写死。四是数据可视化报表。现在后台有学习时长和积分数据但是展示不够直观。后续集成 ECharts做各支部学习完成率、积分分布、资料点击排行这些图表这些数据都在库里就是多几张统计 SQL 和几个图表组件的事。最后一个想说的实际体会这个项目最大的难点不是框架使用而是如何在各种业务约束下做取舍。比如积分规则到底怎么定才能既鼓励学习又不至于被钻空子考试要不要限制切屏帖子审核流到底需要几级。这些问题没有标准答案只有结合具体场景去权衡。技术选型时老老实实选择自己熟悉的组合比追逐热门框架要可靠得多。这套 SpringBoot Vue3 MyBatis MySQL 的组合不太时髦但绝对够用也绝对稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑