1. 项目背景与系统定位:为什么企业需要自建学习交流平台
前阵子帮某大型集团搭内部学习系统时,对方提出一个很实际的需求:底下的分支机构有几千人,日常的培训资料、学习计划、交流活动散落在各个办公群里,发完即沉底,统计学习情况只能靠人工表格层层上报,月底对账对到怀疑人生。他们想要一个能把“发布任务、组织学习、提交心得、跟踪进度、统计考核、交流互动”全部串起来的统一平台。聊到最后技术选型,对方明确说希望用 SpringBoot + Vue 这套主流组合,因为团队后续要自己维护,不想依赖外部厂商。
这就是这篇博文的由来。我要分享的是如何从零构建一个企业级学习交流平台管理系统,后端用 SpringBoot 整合 MyBatis 操作 MySQL,前端用 Vue 全家桶,覆盖从需求分析、数据库设计、核心接口实现到前端页面、部署上线的完整链路。标题里的“党员学习交流”只是场景定语,本质上是一套通用的“组织化学习与交流管理平台”,任何需要“按组织架构下发任务、记录学习行为、生成统计报表、提供交流社区”的企业、学校、行业协会都能直接复用这套设计思路。
这篇文章适合谁?两类人。一类是刚接触 SpringBoot + Vue 全栈开发、想找一个完整项目练手的初中级开发者,另一类是要在企业内网落地类似系统的运维/开发同学。我会把核心表结构、接口设计、权限模型、前后端对接细节、部署踩坑点全部摊开讲,你可以直接照着抄。
2. 技术栈全景与选型理由:为什么是 SpringBoot + Vue + MyBatis + MySQL
2.1 这套选型解决的核心问题
技术选型没有绝对最优,只有“对你当前团队最合适”。我当时给这家集团推荐这套组合,核心就三个理由。
第一,生态成熟、招聘市场上容易找到人。SpringBoot 在 Java 服务端领域已经是事实标准,Vue 在前端领域同样是国内企业级项目占有率最高的框架之一。项目后续要交接、要迭代,技术栈太冷门就是给自己埋雷。第二,分层清晰,职责明确。SpringBoot 负责提供 RESTful API,Vue 负责页面交互,MyBatis 半自动化 SQL 让复杂查询可控,MySQL 满足千万级以下数据量的业务场景绰绰有余。第三,部署成本低。一个可执行 Jar 包加一个 Nginx 静态站点,就能跑起来,内网环境不需要额外引入 Redis、MQ 这些重量级组件(当然,后面如果要扛高并发可以再加,但初版别过度设计)。
2.2 各组件在这个项目里的分工定位
| 组件 | 在本项目中的角色 | 关键特性 |
|---|---|---|
| SpringBoot 2.7.x | 后端服务框架 | 内嵌 Tomcat、自动配置、Starter 机制 |
| Spring Security + JWT | 认证与授权 | 无状态登录,适合前后端分离 |
| MyBatis 3.5.x | 数据访问层 | SQL 与 Java 代码解耦,复杂报表查询友好 |
| MySQL 8.0 | 数据存储 | InnoDB 引擎,支持事务与行级锁 |
| Vue 2.7 + Element UI | 前端框架与组件库 | 快速搭建后台管理界面 |
| Vue Router + Vuex | 前端路由与状态管理 | 路由守卫做登录拦截,全局状态存用户信息 |
| Nginx | 静态资源服务与反向代理 | 部署前端 dist 包,转发 /api 请求到后端 |
这里特别说明一下 MyBatis vs MyBatis-Plus 的选择。很多人问我,既然用 MyBatis 为什么不直接上 Plus?我的习惯是:核心业务表用原生 MyBatis 写 SQL,因为要精细控制 JOIN、子查询、动态条件;通用单表 CRUD 可以借助代码生成器减少重复劳动。标题里写的是 MyBatis 架构,我就按原生 MyBatis 为主来讲解,最后我会单独讲一段如何低成本集成 Plus 增强开发效率。
2.3 开发环境与版本约定
这次分享的版本组合是我实测稳定的一套:
- JDK 1.8(如果你是新项目,直接上 JDK 17 也没问题,但 SpringBoot 版本要对应升级到 3.x)
- SpringBoot 2.7.18
- MySQL 8.0.28
- Node.js 16.x + npm 8.x
- Vue CLI 4.x 或 5.x 都可以
- Maven 3.6.3
工具方面,后端 IDE 用 IntelliJ IDEA,前端用 VS Code,数据库管理用 Navicat 或者 DataGrip。别在这些工具上纠结,能干活就行。
3. 核心功能模块设计与数据库建模:先把地基打牢
3.1 功能模块划分
学习交流平台这类系统,表面上看功能不少,但拆解下来核心就五块:组织用户管理、学习资源管理、学习任务与记录、交流互动、统计报表。业务流转关系大致是:管理员维护组织架构和用户账号,发布者上传学习资料并创建学习任务,学员登录后收到待办任务,完成学习后提交心得,系统记录学习时长和行为日志,管理员随时查看各级组织的学习进度和统计数据,同时所有用户可以在交流区发帖、回复、点赞。
模块设计阶段,我强烈建议先画流程图再写代码。这五块业务的依赖关系很清晰:组织用户是基础数据,学习资源是内容主体,学习任务把资源和人关联起来,学习记录是任务执行的结果,交流互动是独立的功能域,统计报表则是对前面所有数据的聚合查询。我在实际设计时把交流区拆成独立模块,没有和学习任务混在一起,这样后续单独扩展(比如加评论审核、加积分商城)不会动到核心学习链路。
3.2 数据库设计:15 张核心表的结构说明
数据库设计是整个项目里最不能马虎的部分。表结构设计不好,后面写 SQL 的时候就是灾难。我设计的这套库一共 15 张表,按业务域分成四组。
第一组:组织与用户域(4张表)
sys_user:用户表。核心字段包括id、username、password(BCrypt 加密存储)、real_name、avatar、phone、email、org_id、status、create_time。这里org_id关联组织表,一个人只能挂在一个主组织下。sys_org:组织架构表。经典的设计是id、parent_id、org_name、org_level、sort_order,通过parent_id自关联形成树形结构。千万别用层级字段如 level1/level2/level3 来硬编码深度,因为组织架构的层级是动态变化的。sys_role:角色表。预置三种角色:超级管理员、管理员、普通学员。不建议再细分更多角色,角色越多权限管理越复杂。sys_user_role:用户角色关联表。多对多关系的中间表,字段就三个:id、user_id、role_id。
第二组:学习资源域(3张表)
study_resource:学习资源表。存储课件、视频、文档的元数据。字段包括title、type(1-视频 2-文档 3-图文)、file_url、cover_url、content(富文本正文)、publisher_id、status(0-草稿 1-已发布 2-下架)、publish_time、read_count。resource_category:资源分类表。支持多级分类,设计上同样用parent_id自关联。resource_category_rel:资源分类关联表。因为一个资源可以属于多个分类,用中间表维护关系。
第三组:学习任务与记录域(4张表)
study_task:学习任务表。字段包括title、description、org_ids(目标组织范围,用逗号分隔的 ID 串存储)、start_time、end_time、publisher_id、status、is_mandatory。study_task_resource:任务与资源关联表。一个任务下挂多个学习资源。study_record:学习记录表。这是整个系统数据量增长最快的表,每个用户学完每个资源都会产生一条记录。字段包括user_id、task_id、resource_id、learn_duration(秒)、status(0-未完成 1-已完成)、finish_time、score。study_thought:学习心得表。用户提交的文字心得,关联user_id、task_id、resource_id、content、approve_status(0-待审核 1-通过 2-驳回)。
第四组:交流互动域(4张表)
post:帖子表。字段包括title、content(富文本)、author_id、category(交流类型)、view_count、like_count、reply_count、status(0-正常 1-审核中 2-已删除)、is_top、create_time。post_reply:回复表。post_id、reply_author_id、content、parent_id(支持楼中楼回复)、create_time。post_like:点赞表。post_id、user_id、create_time,防止重复点赞需要加唯一索引。sys_notice:通知公告表。系统级的消息推送,用户登录后可以看到未读消息数量。
这套表结构我实际跑过,支撑几千用户、几十万条学习记录完全没问题。如果数据量再大,后续的优化重点是study_record表按时间做分区,以及统计查询走汇总表,而不是实时聚合。
3.3 关键设计细节与索引策略
几个容易出问题的地方,我用实际经验说明:
密码加密存储。用户密码绝对不允许明文存放,我用 Spring Security 自带的 BCryptPasswordEncoder,每次加密结果不同,即使两个用户密码相同,密文也不一样。登录校验时用matches()方法比对。这是安全底线。
逻辑删除 vs 物理删除。所有的业务表都建议加逻辑删除。用户误删了数据,要能找回;管理员删了资源,学员端已经产生学习记录的不能连带受影响。我统一做法是加一个deleted字段(0-正常 1-已删除),查询时默认过滤。
索引设计。原则有两条:一是高频查询条件必须建索引,二是联合索引要遵循最左前缀法则。我在这套系统里建了这么几个关键索引:
study_record表:(user_id, status)、(task_id, resource_id)sys_user表:(username) 唯一索引、(org_id)post表:(status, create_time) 联合索引用于列表页排序post_like表:(post_id, user_id) 唯一索引防止重复点赞
索引不是越多越好,每多一个索引,插入和更新就要多维护一棵 B+ 树。只给真正被 WHERE 和 ORDER BY 高频使用的字段建索引。
4. 后端核心实现:从登录鉴权到学习进度上报
4.1 SpringBoot 项目结构与包划分
后端工程我采用标准的 Maven 结构,包名按照com.company.learning这样的规范来。具体分包如下:
com.company.learning ├── controller // 接口层,只做参数接收和结果返回 ├── service // 业务逻辑层,接口定义 ├── service.impl // 业务逻辑实现 ├── mapper // MyBatis 数据访问接口 ├── entity // 数据库实体类 ├── dto // 前端交互数据对象(VO) ├── config // 配置类(WebMvc、Security、跨域) ├── common // 公共类(统一返回结果、异常处理、常量) ├── utils // 工具类(JWT、日期、文件处理) └── interceptor // 拦截器(登录验证、日志记录)这个分层看着简单,但很多人写着写着就乱套了。我的约束是:Controller 里不写业务逻辑,只做参数校验和数据封装;Service 层才是业务的核心;Mapper 只负责 SQL 交互。谁违反这个规矩,代码 Review 的时候就会被拉去谈话。
4.2 基于 JWT 的登录鉴权设计
前后端分离架构下,Session 方案已经不太适用了,我用 JWT(JSON Web Token)做无状态认证。流程是这样:
用户提交用户名密码,后端认证成功后生成一个 Token 返回前端,前端存储到 localStorage,并在每次请求时放到 HTTP Header 的Authorization字段里,后端通过拦截器解析 Token 识别用户身份。Token 里我放三个 Claim:用户 ID、用户名、角色编码。过期时间设置为 24 小时,实际使用时可以根据安全要求调整,政务类项目通常要求 2 小时,企业内网可以放宽到 12 或 24 小时。
核心实现代码,先看生成 Token 的工具类:
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; public String generateToken(Long userId, String username, String roleCode) { Date now = new Date(); Date expireDate = new Date(now.getTime() + expiration * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("roleCode", roleCode) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }注意.signWith()里的secret不要太短,HS256 算法要求密钥至少 256 位(32 个字符以上),否则启动时会报警告甚至报错。项目里我放在 application.yml 配置文件中,真正部署时要用环境变量覆盖,不能打成明文包。
然后是一个拦截器,在请求进入 Controller 之前校验 Token:
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行 OPTIONS 预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } // 去掉 Bearer 前缀 token = token.replace("Bearer ", ""); try { Claims claims = jwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("roleCode", claims.get("roleCode")); return true; } catch (Exception e) { throw new BusinessException(401, "Token 无效或已过期"); } } }这里是我踩过的坑:前端 axios 拦截器里,请求头加 Token 的代码一定要处理 Token 为空的情况。我遇到过用户未登录就访问页面,前端抛异常,但因为代码里没有if (!token) return,导致带着Authorization: undefined发请求,后端收到一个字符串 "undefined" 而非空,拦截器解析 Token 时报 500 而不是 401,排查了半天才发现问题根源。
4.3 权限控制:注解 + 拦截器双层校验
角色权限这块,我在拦截器校验身份的基础上,加了一层方法级权限控制。实现方式不复杂,自定义一个@RequireRole(value = {"admin"})注解,然后在拦截器里检查用户角色是否匹配。这样比引入 Spring Security 的@PreAuthorize注解更直观,代码量更少,也方便新同学理解。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }拦截器里的校验逻辑:
HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole != null) { String roleCode = (String) request.getAttribute("roleCode"); boolean allowed = Arrays.asList(requireRole.value()).contains(roleCode); if (!allowed) { throw new BusinessException(403, "无权限访问"); } }实际项目里我建议还是用 Spring Security 完整方案,但如果你要快速交付一个内部系统,上面这套轻量做法完全够用。功能层面没有任何差别,差别在代码规范性和可扩展性上。
4.4 学习任务与进度上报的核心接口
系统最核心的业务接口是“接受任务—学习—上报进度—提交心得”这条链路。我们看两个关键接口的实现逻辑。
接口一:获取我的待办任务列表
这个接口需要组合查询当前用户所在组织的所有下发任务,并标记每个任务的学习完成状态。SQL 用 MyBatis 动态 SQL 写:
<select id="selectMyTaskList" resultType="com.company.learning.entity.StudyTask"> SELECT DISTINCT t.* FROM study_task t WHERE t.deleted = 0 AND t.status = 1 AND FIND_IN_SET(#{orgId}, t.org_ids) AND t.start_time <= NOW() AND t.end_time >= NOW() ORDER BY t.create_time DESC </select>FIND_IN_SET是 MySQL 处理逗号分隔 ID 串的一个技巧,但性能上并不理想。如果组织数量非常庞大(超过几万),建议改成关联表study_task_org来存储任务和组织的多对多关系。我初版为了快速交付用了逗号分隔方案,后来数据量上来以后才重构的,你们可以一步到位直接用关联表。
接口二:学习进度上报
学员完成一个资源的学习后,前端调用上报接口,传参是taskId、resourceId、learnDuration。后端逻辑是:
@Override @Transactional(rollbackFor = Exception.class) public void reportLearnProgress(LearnProgressDTO dto, Long userId) { StudyRecord record = studyRecordMapper .selectByUserAndResource(userId, dto.getTaskId(), dto.getResourceId()); if (record == null) { // 首次学习,插入记录 StudyRecord newRecord = new StudyRecord(); newRecord.setUserId(userId); newRecord.setTaskId(dto.getTaskId()); newRecord.setResourceId(dto.getResourceId()); newRecord.setLearnDuration(dto.getLearnDuration()); newRecord.setStatus(1); newRecord.setFinishTime(new Date()); studyRecordMapper.insert(newRecord); } else { // 重复上报,累加时长,不重复计算完成状态 record.setLearnDuration(record.getLearnDuration() + dto.getLearnDuration()); studyRecordMapper.update(record); } }注意这里@Transactional注解加上rollbackFor = Exception.class,确保异常时事务回滚。学习记录这种高频写入接口,我做了个细节优化:前端不是实时上报的,而是每 30 秒或用户切走页面时批量上报一次,避免接口频繁请求造成数据库压力。前端代码里用visibilitychange事件监听用户离开页面时,把缓存的上报数据一次性发出去。
5. 前端工程化实现:Vue + Element UI 的落地细节
5.1 前端项目结构与路由守卫
前端工程我用 Vue CLI 创建,目录结构如下:
src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── views // 页面视图 │ ├── login // 登录页 │ ├── dashboard // 工作台/首页 │ ├── resource // 学习资源 │ ├── task // 学习任务 │ ├── study // 在线学习 │ ├── community // 交流社区 │ └── system // 系统管理 ├── router // 路由配置 ├── store // Vuex 状态管理 ├── utils // 工具函数 └── App.vue路由守卫是前端登录拦截的核心。没有 Token 的用户访问系统页面时,强制跳转到登录页。实现代码:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else { if (!token) { next('/login') } else { // 判断用户信息是否已加载 if (!store.state.user.userInfo) { store.dispatch('fetchUserInfo').then(() => { next() }).catch(() => { localStorage.removeItem('token') next('/login') }) } else { next() } } } })路由守卫这个环节有个容易漏掉的细节:每次刷新页面时 Vuex 里的用户信息会丢失,如果不在守卫里重新拉取用户信息,就会出现刷新之后页面虽然能进,但用户姓名、角色等基础信息全是空的情况。上面代码里用store.state.user.userInfo做了一次判断,重新拉取后放行,这个处理很关键。
5.2 axios 统一封装与请求拦截
前端和后端交互的核心是 axios 实例,我做了一层统一封装。好处是:所有请求自动携带 Token、统一处理错误码、统一处理 401 跳转登录。
const service = axios.create({ baseURL: process.env.VUE_APP_BASE_URL || '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }, error => { return Promise.reject(error) }) service.interceptors.response.use(response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('登录已过期')) } return Promise.reject(new Error(res.msg || '请求失败')) }, error => { return Promise.reject(error) })这里要注意的是,代码里判断res.code === 200而不是response.status === 200。HTTP 状态码表示的是传输层的成功,业务状态码表示的是业务逻辑的成功。后端接口可能返回 HTTP 200 但业务码是 500,比如参数校验失败。这套双状态码的设计,前后端必须约定清楚,否则排查问题的时候很容易被误导。
5.3 学习任务列表页与学习页面的交互设计
学员端最有代表性的页面是“我的学习任务”列表和在线学习页面。列表页用 Element UI 的el-card和el-timeline组件展示任务卡片,每张卡片显示任务标题、起止时间、关联资源数量、当前完成进度条。
在线学习页面我采用了左右两栏布局:左侧是资源列表,支持切换视频、文档、图文;右侧是学习内容和学习时长计时器。计时器逻辑里有个防作弊的细节:只有页面处于可见状态(document.visibilityState === 'visible')时才累加计时,用户切到其他标签页时计时自动暂停。这个用原生visibilitychange事件就能实现,不需要引入第三方库。
实际上报的时机我上面说过,是 30 秒一次累计上报,配合beforeunload事件在页面关闭时做最后一次上报。有的系统做得更严格,要求视频播放进度超过 90% 才算完成,我这里放宽了,只要用户打开资源学习就算一次有效记录。具体规则要和业务方确认,技术上都支持。
5.4 富文本组件与文件上传的坑
学习资源里的图文内容、交流社区里的帖子正文,都需要富文本编辑能力。我用了在中后台项目里成熟的wangEditor组件,它自带图片上传功能,但默认会把图片转成 base64 塞进 HTML,导致文章体积暴增。我的解决方案是重写uploadImgServer配置项,把图片上传走后端接口,拿到 URL 后再插入编辑器。
文件上传接口的设计,前端用el-upload组件,后端提供一个通用上传接口:
@PostMapping("/api/common/upload") public Result upload(MultipartFile file) throws IOException { if (file.isEmpty()) { throw new BusinessException(400, "上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; // 按日期分目录存储 String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String filePath = uploadDir + "/" + datePath + "/" + fileName; File dest = new File(filePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); String fileUrl = "/files/" + datePath + "/" + fileName; return Result.success(fileUrl); }这个接口有两个细节要注意。第一,文件名重命名用 UUID,不要用原始文件名,因为会存在中文乱码、文件名冲突、路径穿越等问题。第二,静态资源映射要在 SpringBoot 里配置虚拟路径,把/files/**映射到本地的uploadDir目录:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); } }如果你是前后端部署在同一台服务器,由 Nginx 统一托管,可以不用 SpringBoot 配置静态资源映射,直接让 Nginx 的location /files/指向本地目录,性能更好。二选一就行,别配重复了。
6. 统计报表实现:组织维度的学习进度聚合
6.1 报表需求分析与 SQL 设计
管理端最看重的功能是统计报表。常见的需求有:各组织学习完成率排名、资源学习热度排行、用户学习时长排行、任务完成率趋势。这些统计需求,核心就是 GROUP BY 加聚合函数。
我们看完成率统计这个典型场景。要计算每个组织下所有用户的学习完成情况,SQL 大致是这样:
SELECT o.id AS org_id, o.org_name, COUNT(DISTINCT r.user_id) AS total_users, COUNT(DISTINCT CASE WHEN r.status = 1 THEN r.user_id END) AS finished_users, ROUND(COUNT(DISTINCT CASE WHEN r.status = 1 THEN r.user_id END) / COUNT(DISTINCT r.user_id) * 100, 2) AS finish_rate FROM sys_org o LEFT JOIN sys_user u ON u.org_id = o.id AND u.deleted = 0 LEFT JOIN study_record r ON r.user_id = u.id GROUP BY o.id, o.org_name ORDER BY finish_rate DESC实际开发中,这种统计 SQL 会随着数据量增大而变慢。优化思路有两个方向。第一,加汇总表,每天凌晨用定时任务跑批生成统计结果,白天报表直接查汇总表。第二,用 Redis 做缓存,统计结果缓存 5 分钟,减轻数据库压力。初版系统用户量不大时,直接把 SQL 写好、加上适当的索引就够了,报表接口响应时间控制在 2 秒内完全没问题。
6.2 前端报表展示方案
报表展示我用的是ECharts,项目里通过 npm 按需加载。柱状图展示各组织完成率排名,折线图展示近 30 天学习人数趋势,饼图展示资源类型分布。
一个实战中很容易踩的坑是:ECharts 图表在 el-tab-pane 或 el-dialog 中渲染时宽度会变成 100px。原因是容器初始化时是隐藏状态,ECharts 拿不到容器的真实宽高。解决办法是在容器显示后手动调用chart.resize(),或者在nextTick里重新初始化图表。我在项目里写了个工具函数,在 Tab 切换的click事件里触发resize,这个问题就消失了。
7. 部署上线与安全加固:从开发到生产环境的最后一公里
7.1 打包构建流程
前后端分离项目的部署,流程固定为四步:
第一步,后端打包。在项目根目录执行mvn clean package -DskipTests,生成learning-server.jar。这个 Jar 包自带 Tomcat,不需要额外装 Tomcat 容器。
第二步,前端打包。在src/frontend目录执行npm run build,生成dist目录。Vue CLI 会根据.env.production里的配置把接口地址指向生产环境的域名。
第三步,准备部署目录。我的建议路径规划是:
/app/learning/ ├── server/ │ └── learning-server.jar ├── web/ │ └── dist/ # 前端静态文件 ├── files/ # 上传的文件 ├── logs/ # 应用日志 └── config/ └── application.yml第四步,配置 Nginx。核心配置片段如下:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /app/learning/web/dist; index index.html; 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_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传文件访问 location /files/ { alias /app/learning/files/; } }注意proxy_pass http://127.0.0.1:8080/;最后的斜杠。带斜杠表示把/api前缀去掉后转发,比如请求/api/user/list会转发到后端的/user/list。这个斜杠写不写,转发路径完全不同,我因为这件事踩过一次坑,排查了半天才意识到是 Nginx 配置问题。
7.2 生产环境数据库初始化
数据库初始化这块,我在项目里放了一个sql/init.sql脚本,包含建库建表和基础数据。基础数据里预设了超级管理员账号、角色数据、组织根节点。部署时执行:
mysql -u root -p < /app/learning/sql/init.sql这里提醒一句:初始化脚本里的默认密码一定要在部署后立即修改。我见过不少团队把初始密码写死在文档里,半年都不改,这是极大的安全隐患。项目里用 BCrypt 预置了密码哈希,但你拿到源码后应生成自己的哈希替换掉。
7.3 安全加固清单
安全这块我结合这几年做项目的经验,列一份可直接对照执行的清单:
| 安全项 | 方案/配置 | 说明 |
|---|---|---|
| SQL 注入 | MyBatis 全部使用#{}参数占位 | 禁止使用${}拼接用户输入,必填项用@Validated校验 |
| XSS 攻击 | 后端统一过滤 HTML 标签 | 富文本内容要白名单过滤(如Jsoup的 Cleaner) |
| 越权访问 | 接口层级校验数据归属 | 查询和修改时校验当前用户是否拥有该数据权限 |
| 密码安全 | BCrypt 加密 + 密码强度校验 | 至少 8 位含字母数字,登录失败 5 次锁定 |
| 传输安全 | HTTPS 部署 | Nginx 配置 SSL 证书,HTTP 自动跳转 HTTPS |
| 文件上传 | 后缀白名单 + 文件大小限制 | 仅允许图片/视频/PDF,最大 200MB(视频类) |
| 操作日志 | AOP 记录关键操作 | 管理员删除、发布、修改等操作记录日志 |
XSS 这里特别说一下。交流社区因为允许用户发富文本,是 XSS 的高发地。我在保存帖子内容前做了过滤,只保留p、strong、img、a、ul、li、blockquote这些常用标签,其余全部剥掉。图片标签的src只允许http和https协议,杜绝javascript:这种危险协议。你从网上下载的富文本编辑组件默认是不过滤这些的,这块必须后端自己做。
8. 我踩过的坑与优化调优经验
这一章写点代码之外的东西,全是实战中实实在在遇到的问题,希望能让你少走弯路。
坑一:数据库连接池参数没配置导致连接耗尽。项目刚上线那两天,用户量一上来,后台疯狂报错Connection is not available, request timed out。我检查发现是 HikariCP 的默认配置在低配服务器上扛不住并发,后来把 maximum-pool-size 从默认 10 调整到 30,并设置了 connection-timeout 为 30000。如果服务器配置一般,不建议盲目调大池子大小,每个连接都要占内存,一般 20-50 之间足够。
坑二:前端 Token 存储用 localStorage 还是 cookie。这个争论很多。localStorage 的优点是简单、跨标签页共享,缺点是容易受 XSS 攻击。Cookie 的缺点是 CSRF 风险。我最终选择了 localStorage + 后端严格 XSS 过滤的组合方案。对内网系统来说这个选择是安全的,但如果你是互联网应用且用户数据敏感,建议用 HttpOnly 的 Cookie 方案。
坑三:大文件上传时 Nginx 默认限制 1MB。有次上传教学视频一直失败,前端报网络错误,后来发现是 Nginx 的client_max_body_size默认值太小。修改配置:
location /api/ { client_max_body_size 200m; }所以如果上线后上传文件报错,先查 Nginx 的client_max_body_size,别急着怀疑后端代码。
优化一:学习记录表数据量大了怎么扛。前面提到过,study_record是数据增长最快的表。我在项目里把这张表按月份做了分区,使用 MySQL 的 RANGE 分区,每个月一个分区。也写过定时归档逻辑,超过 6 个月的记录不删,只是迁移到历史库。统计报表默认只查最近 3 个月,保证查询响应速度。
优化二:查询列表接口的深分页问题。列表页翻到第 100 页时,LIMIT 9900, 20这种写法会越查越慢。优化方案是采用“前翻页”或“游标分页”替代传统的页码分页。具体做法是记录上一页最后一条数据的 ID,下一页查询时加WHERE id > lastId ORDER BY id LIMIT 20,这样无论翻到多深都只扫描 20 条数据。这个优化对于学习记录列表、帖子列表这种高频深分页的场景效果立竿见影。
优化三:统一异常处理。我在common包里写了一个全局异常处理器,用@RestControllerAdvice注解捕获所有异常。好处是接口出错了会返回统一的 JSON 结构,而不是默认的 Whitelabel Error Page,前端可以直接展示错误信息,不需要每次调试都手动抓包。这个习惯我从第一次做 SpringBoot 项目就开始坚持,强烈推荐。
9. 关于 MyBatis-Plus 的增强替代方案
最后聊一个偏实战的话题。标题写的是 MyBatis 架构,但很多同学会用 MyBatis-Plus 来提升开发效率。我的建议是:如果你是从零开始做,并且团队对 SQL 没有极致掌控需求,直接用 MyBatis-Plus 没毛病。它可以在不写 SQL 的情况下完成大部分的单表 CRUD,内置的分页插件、条件构造器能省下大量重复代码。
我后来在另一个迭代版本里集成了 Plus,项目里留下的原生 MyBatis XML 只有那些复杂统计报表的 SQL。两种技术共存的方案是可行的:Mapper 接口继承BaseMapper<T>获得通用 CRUD,同时保留 XML 文件写自定义 SQL。只要 MyBatis 的mapper-locations配置正确指向 XML 目录,互不冲突。
如果你坚持只用原生 MyBatis,推荐借助代码生成器生成实体类和 Mapper 接口,减少体力活。我用的方案是 MyBatis Generator 配合自定义模板,不过配置起来有点繁琐,这里就不展开细节了——总之不要手写 15 张表的 CRUD,那是纯体力劳动,交给工具就好。
以我个人的经验,这类系统的技术难度其实不在某个单一技术点,而在于如何把组织架构、任务流转、数据统计这些业务关系梳理清楚,再用合理的技术手段实现。项目做完之后,最有价值的收获往往不是学会了某个框架的新特性,而是对整个系统的数据流和业务边界有了完整的认知。希望这篇文章能帮你把整条链路打通,少踩几个我踩过的坑。