前一阵我们团队接到一个需求,要做一套红色知识学习平台。刚听到这个题目时,我第一反应是:这不就是一个内容管理系统吗?无非是文章列表、用户登录、后台发文章。等真正把需求聊透之后才发现,这里面的坑比想象中多得多。基于Spring Boot的红色知识学习平台,表面上是一个“文章+答题+打卡”的组合拳,实际上牵扯到内容审核、学习行为量化、防作弊、多角色权限等一系列问题。本文记录我从0到1搭建这套平台的全过程,包括需求梳理、技术选型、数据库设计、核心模块实现、权限防作弊以及上线前的性能调优。如果你正准备做类似主题的系统设计、毕业设计,或者要给单位搭一套内部学习平台,这篇文章应该能帮你少踩不少坑。
1. 需求边界与目标用户:它不是简单的CMS站点
1.1 需求方到底在痛什么
需求方是一家单位的党建学习口,以前学红色知识主要靠纸质材料、微信群转发文章,学习效果完全没法衡量。他们提了几个很具体的诉求:
- 内容要成体系,不能再靠零散转发,要有专题、有分类、有来源。
- 学习行为要留痕,谁学了、学了多久、学了几篇,管理员要能看得到。
- 要能组织线上答题活动,形成比学赶超的氛围。
- 管理员要能自己录内容、发内容,不能每次改个字都找开发。
把这些诉求翻译成技术语言,就是三个关键词:内容管理、学习行为采集、答题活动闭环。但“可量化”这三个字才是整个项目的灵魂。平台上所有功能设计,最终都要回答一个问题:今天这个用户到底学了没有,学了多少。
1.2 用户角色和使用场景
这个平台不像电商、社交产品有那么复杂的用户分层,但角色边界必须清晰。我梳理了三类角色和对应场景:
| 角色 | 主要行为 | 核心功能诉求 |
|---|---|---|
| 普通用户 | 浏览文章、观看图文专题、参与答题、每日打卡 | 学习日历、积分、排行榜、错题回顾 |
| 内容管理员 | 录入编辑内容、管理分类、审核发布 | 富文本编辑、内容置顶、数据统计 |
| 超级管理员 | 账号管理、角色分配、系统参数配置 | 权限分配、操作日志、全局设置 |
使用场景也分几种。日常自主学习:用户登录后按分类浏览内容,阅读一篇文章并完成打卡;单位组织的专题学习周:管理员批量发布专题内容,用户集中学习并参加答题竞赛;管理员查看报表:按部门、按人查看学习时长和答题成绩,作为学习效果评估依据。
1.3 和普通CMS平台的本质差异
如果照搬一套博客系统改一改,肯定会翻车。红色知识学习平台和普通CMS的差异至少有四点:
第一,内容权威性要求高。不是用户UGC,而是组织方整理的内容,每一篇都可能有来源出处,发出去之前必须审核,审核动作要留痕。
第二,学习行为形成闭环。浏览、阅读、计时、打卡、答题、积分、排行,这些都是串联的。普通CMS只需要“发文章—看文章”,这里却要求每一步都有数据回流。
第三,权限模型更立体。除了前台用户、后台管理员,还有“审核员”这种中间角色。内容发布要走“编辑提交—审核通过—发布上线”的状态机。
第四,防作弊是刚需。刷时长、刷积分、重复刷题,这些在内部学习平台里是必然会出现的问题,不提前设计好方案,上线一周数据就会失真。
想清楚这四点,项目才敢开工。接下来就是技术选型和架构设计。
2. 技术选型与分层架构:Spring Boot 如何支撑整个平台
2.1 为什么继续用 Spring Boot 而不是微服务
这个项目规模,撑死几千个用户同时在线,属于典型的中小型业务系统,上微服务纯粹是给自己找麻烦。Spring Boot 2.7.x 是当前最稳妥的选择,资料多、生态成熟,和 JDK 8 配合非常顺畅,出了问题网上随便一搜就有答案。
后端技术栈我最终定下来是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8 + Redis + JWT。管理端用 Vue3 + Element Plus,前台页面也是 Vue3,前后端完全分离。有些同学可能会问,为什么不用 Spring Data JPA?我的理由很简单:这个系统里有大量按条件查询和多表关联统计,MyBatis-Plus 的 QueryWrapper 写起来最直接,而且团队里大家对这个更熟悉,少一点学习成本。
2.2 项目骨架怎么搭
创建一个 Spring Boot 工程没什么好说的,关键在于依赖怎么引入。我直接贴出核心的 pom.xml 依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency>JWT 用 java-jwt 这个库,比手写一套 token 生成校验靠谱得多。Redis 走 Spring Data Redis,后续做缓存和防刷都要用。校验组件也建议加上,后面写参数校验时能省不少事。
2.3 分层结构设计
代码分包我按下面这种方式组织,逻辑清晰,团队协作不容易乱:
com.example.redstudy ├── common // 统一返回、全局异常、常量 ├── config // Redis、拦截器、WebMvc配置 ├── controller // 接口层,只做参数接收和结果返回 ├── service // 业务层,核心逻辑都在这 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体 ├── dto // 接收前端参数的模型 └── vo // 返回给前端的数据模型一个容易被忽略的细节是 dto 和 vo 的拆分。很多新手直接拿 entity 去接收前端参数,导致后面前端表单字段一变化,实体类就得跟着改,数据库表也可能被污染。我把请求参数和响应数据单独建模,虽然多写几个类,但后期维护省心太多。
2.4 统一返回体和全局异常处理
前后端分离项目,接口返回格式必须统一。我定义了一个 Result 类:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }配合全局异常处理器,把业务异常、参数校验异常、系统异常分开捕获,返回给前端的结构永远是同一套,前端拿到 code 不等于 200 就弹 message。这样做的好处是联调阶段基本不用撕接口格式。
3. 数据模型设计:六张表与“学习记录”粒度之争
3.1 用户表:别把角色做成死字段
用户表是系统的基础,但设计时容易踩一个坑:把角色写死成一个字段,admin、user 这样一填了事。如果以后增加一个“审核员”角色,就得改代码加枚举。我的做法是保留 role_type 字段的同时,预留了 user_role 关联表的空间。数据量不大时直接用字段判断,等权限模型复杂了再拆表,迁移成本可控。
用户表核心字段如下:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT '加盐后的密码', `salt` varchar(32) DEFAULT NULL, `nickname` varchar(50) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `role_type` varchar(20) NOT NULL DEFAULT 'USER', `department_id` bigint DEFAULT NULL COMMENT '所属部门', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码必须加盐,这是基本觉悟。salt 用 UUID 去掉横线生成,数据库里存的是 md5(password + salt) 或者更保险的 bcrypt。建议直接用 bcrypt,不要自己造轮子。
3.2 内容域:分类、文章、标签分离
内容模块包含分类表、文章表、标签表。分类和文章是一对多,标签和文章是多对多。为了不引入多余关联表,我加了 content_tag 字段用逗号分隔存储标签 ID,查询时由前端按需解析。小系统没必要为了范式完美搞五张表,适度冗余能大幅减少查询复杂度。
文章表需要注意的字段:
content用longtext,富文本内容体积比纯文本大很多。source记录内容来源,权威性要求决定这个字段不能省。status记录审核状态,audit_user_id、audit_time记录审核痕迹。view_count用 int 做冗余计数,避免每次统计都 count 一次。
3.3 学习记录粒度之争:一张表还是两张表
这是整个数据库设计里我纠结最久的地方。学习记录到底存什么粒度?
方案 A:每人每天一条记录,字段是 user_id、learn_date、total_duration、checkin_time。好处是打卡、连续天数统计非常流畅,一条记录一个用户一天。坏处是想知道“这个人读过哪几篇文章”时完全没数据。
方案 B:每篇文章一条学习记录,字段是 user_id、article_id、duration、finish_flag。好处是能统计已读文章数、掌握度。坏处是打卡场景要按日期聚合,连续天数计算要 group by,数据量大时有点吃力。
最终我两个方案都用了,拆成study_daily和study_detail两张表。前者管打卡和连续天数,后者管学习明细。业务上一个管“今天学了多久”,一个管“这篇学了多久”,本来就是两个诉求,硬塞一张表会让两个方向的查询都别扭。
核心建表语句如下:
CREATE TABLE `study_daily` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `learn_date` date NOT NULL, `total_duration` int DEFAULT '0' COMMENT '学习时长,单位秒', `checkin_time` datetime DEFAULT NULL COMMENT '首次打卡时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_date` (`user_id`, `learn_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `study_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `article_id` bigint NOT NULL, `duration` int DEFAULT '0', `finish_flag` tinyint DEFAULT '0', `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_article` (`user_id`, `article_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;study_daily上加唯一索引(user_id, learn_date),天然防止同一天重复插入。study_detail上建(user_id, article_id)索引,查询某人对某篇文章的学习情况直接走索引。
3.4 题库与成绩表
答题模块需要三张表:题目表、答题记录表、答题明细表。题目表设计:
question_type:单选、多选、判断。options:JSON 字符串存选项,如["A. 遵义", "B. 延安", ...]。answer:正确答案,单选存 A/B/C/D,多选存逗号分隔。analysis:答案解析,答题后展示给用户看。difficulty:1-3 难度等级,抽题时按难度比例出。
答题记录表记录一次考试的总体情况,答题明细表记录每一道题的对错和用户答案。明细表用text存用户答案,因为多选题答案是一串字母。
3.5 索引策略与冗余字段心得
小系统的索引设计原则是“够用就行”。我在所有外键字段上都建了索引,状态字段和分类字段这种低选择性的字段建立联合索引,比如(category_id, status)。像content这种大文本字段坚决不建索引。view_count这类统计字段不实时查库,而是通过 Redis 异步累加后定期回写,避免每次页面刷新都触发一次 update。
4. 核心功能拆解:学习打卡、答题抽题与内容检索
4.1 学习计时与打卡的落地细节
学习计时如果只靠前端计时,用户关掉页面就停了,或者开两个页面刷时长,数据根本不可信。我的方案是前端每 30 秒上报一次心跳,后端把耗时累计到 Redis:
@Service public class StudyHeartbeatService { @Autowired private StringRedisTemplate redisTemplate; private static final String HEARTBEAT_KEY = "study:heartbeat:%s:%s"; public void heartbeat(Long userId, Long articleId) { String date = LocalDate.now().toString(); String key = String.format(HEARTBEAT_KEY, userId, date); // 每次心跳累加30秒,设置2小时过期,用户学习过程中会持续续期 redisTemplate.opsForValue().increment(key, 30); redisTemplate.expire(key, 2, TimeUnit.HOURS); // 更新文章维度的学习时长 updateArticleDuration(userId, articleId, 30); } }打卡接口独立于心跳,用户手动点击“今日打卡”时触发。这里用 SetNX 做并发控制,防止用户手动连点导致连续天数加两次:
public Result<Boolean> checkIn(Long userId) { String date = LocalDate.now().toString(); String lockKey = "study:checkin:" + userId + ":" + date; Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(success)) { return Result.error(4001, "今天已经打过卡了"); } // 判断昨天是否有记录,有则连续天数+1,否则重置为1 int streak = calcContinuousDays(userId); StudyDaily daily = new StudyDaily(); daily.setUserId(userId); daily.setLearnDate(LocalDate.now()); daily.setCheckinTime(LocalDateTime.now()); // 写库、更新连续天数 studyDailyService.saveOrUpdate(daily); return Result.success(true); }连续天数的核心逻辑在calcContinuousDays:查昨天的记录,存在就在其基础上加 1,不存在就重置为 1。注意这里要考虑到用户跨天还在学习的情况,凌晨打卡时以“自然日”为准,不搞复杂的“最近24小时滚动窗口”,需求方也认可这种统计口径。
4.2 答题模块:随机抽题并不是纯粹的随机
答题模块一开始也想得很简单:从题库里随机出 10 道题。后来测试发现,用户连续做几套卷子会反复抽到同样的题,体验很差。原因是简单随机在题量只有一百多道时重复率确实高。
我的做法是加一个“近期已出题”缓存,每次抽题时排除最近 20 道已经出过的题:
public List<Question> pickQuestions(Long categoryId, int count) { String cacheKey = "study:recent:" + userId + ":" + categoryId; Set<String> recentIds = redisTemplate.opsForZSet().range(cacheKey, 0, -1); List<Question> pool = questionMapper.selectList( new LambdaQueryWrapper<Question>() .eq(Question::getCategoryId, categoryId) .notIn(JsonUtil.isNotEmpty(recentIds), Question::getId, recentIds) .orderByAsc(Question::getId) ); // 按难度比例抽题:简单4道、中等4道、困难2道 Map<Integer, List<Question>> groupByDifficulty = pool.stream() .collect(Collectors.groupingBy(Question::getDifficulty)); // 抽取后把题目ID存入ZSet并设置过期时间为3天 recentIds.forEach(id -> redisTemplate.opsForZSet().add(cacheKey, id, System.currentTimeMillis())); redisTemplate.expire(cacheKey, 3, TimeUnit.DAYS); return selectedQuestions; }按难度比例抽题这步值得展开说一下。如果不控制难度,抽出来全是简单题,用户会觉得没挑战;全是难题,学习积极性又被打击。我按 4:4:2 的比例抽简单、中等、困难三类,每套卷子 10 道,难度分布比较合理。
答题提交后的判分逻辑要放到后端做,前端只负责把 user_answer 传回来,后端和 answer 字段比对,绝不能信任前端传的 isCorrect 字段。
4.3 内容检索:不引 Elasticsearch 的全量方案
学习平台的内容数据量短期很难超过十万条,上 Elasticsearch 有点杀鸡用牛刀。我用 MyBatis-Plus 的 QueryWrapper 做多字段模糊匹配,先满足核心搜索需求:
public Page<ArticleVO> search(String keyword, Integer categoryId, int page, int size) { LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); wrapper.and(kw -> kw .like(Article::getTitle, keyword) .or() .like(Article::getSummary, keyword) .or() .like(Article::getContent, keyword)); if (categoryId != null) { wrapper.eq(Article::getCategoryId, categoryId); } wrapper.eq(Article::getStatus, "PUBLISHED") .orderByDesc(Article::getPublishTime); Page<Article> result = articleMapper.selectPage(new Page<>(page, size), wrapper); // 转成VO返回,隐藏content字段避免传输超大文本 return convertToVO(result); }搜索功能有两个细节要注意。第一是 or 条件必须嵌套在 and 里,否则会和 categoryId、status 的条件产生逻辑混乱。第二是返回给前端时不要把整篇 content 带出去,列表接口只返回标题、摘要、封面图这些元数据,详情接口单独再返回 content,这对接口性能影响非常大。
如果未来内容量真的大到 LIKE 扛不住,再迁移到 Elasticsearch 也不迟,业务层接口已经做了隔离,查询逻辑都在 Service 层,换引擎只影响一个实现类。
4.4 管理端富文本内容的安全处理
内容管理员往富文本编辑器里粘贴内容时,通常会带出一堆标签和行内样式,这时候直接入库就是给自己埋雷。我用 jsoup 做标签白名单过滤,只保留安全的标签:
public String cleanHtml(String dirtyHtml) { Whitelist whitelist = Whitelist.relaxed(); whitelist.addTags("p", "br", "span", "img", "h1", "h2", "h3", "blockquote"); whitelist.addAttributes("img", "src", "alt", "width", "height"); whitelist.addAttributes("a", "href", "title", "target"); whitelist.removeAttributes("a", "javascript", "onclick"); return Jsoup.clean(dirtyHtml, whitelist); }凡是用户可控输入然后会展示给其他人的内容,都必须过这一道清洗。不光是防 XSS,也是防止样式错乱、布局崩坏。评论、昵称这些字段同理,入库前统一走过滤器。
5. 权限体系与防作弊:三层防线怎么落地
5.1 RBAC 权限模型的轻量实现
完整版 RBAC 需要用户表、角色表、权限表、用户角色关联表、角色权限关联表五张表。但本项目实际角色只有三种,操作权限按角色划分足够。我采用了折中方案:用户表保留 role_type 字段,再配合自定义注解和拦截器控制接口访问。
自定义注解加在 Controller 方法上:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }拦截器里解析 JWT 拿到当前用户角色,判断是否有对应权限:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole == null) { return true; } // 从ThreadLocal获取当前登录用户 UserContext user = userContextHolder.get(); if (user == null) { response.setStatus(401); return false; } List<String> allowedRoles = Arrays.asList(requireRole.value()); if (!allowedRoles.contains(user.getRoleType())) { response.setStatus(403); return false; } } return true; }这种轻量实现的好处是角色少时直观、开发快。如果后续权限点细化到“某分类只有某些管理员能管”,再迁移到完整 RBAC 模型,改动也能控在一个 Service 类之内。
5.2 内容审核状态机
内容审核必须有状态流转,我用一个 status 字段表示四个状态:DRAFT(草稿)、PENDING(待审核)、PUBLISHED(已发布)、REJECTED(已驳回)。
状态流转规则:编辑创建时是 DRAFT,提交审核后变 PENDING,审核通过变 PUBLISHED,不通过变 REJECTED 并填写驳回原因。已发布的内容如果编辑再次修改,状态要打回 PENDING 重新走审核,不能直接覆盖线上内容。
审核记录单独存一张 audit_log 表,每次审核操作都插入一条记录,包含操作人、操作时间、操作类型、备注。一张表解决了两个问题:追溯某篇文章的审核历史,以及责任到人。
5.3 防刷三件套:每日积分上限、答题频率限制、IP限流
内部学习平台一旦和积分、排行榜挂钩,就必然有人想刷。我的防线分三层:
第一层,每日积分上限。用户每天从学习、答题、打卡获得的积分设一个总上限,超过后积分不再增长。这个直接用 Redis 计数器实现:
public boolean canAddScore(Long userId, int score) { String key = "study:score:" + userId + ":" + LocalDate.now().toString(); Long current = redisTemplate.opsForValue().increment(key, score); int maxDailyScore = 100; if (current > maxDailyScore) { // 超出部分扣回去,返回false redisTemplate.opsForValue().decrement(key, score); return false; } return true; }第二层,答题频率限制。同一用户同一分类一分钟内最多作答 5 次,用 Redis 加过期时间:
String key = "study:exam:rate:" + userId + ":" + categoryId; Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, 60, TimeUnit.SECONDS); } if (count > 5) { return Result.error(429, "答题过于频繁,请稍后再试"); }第三层,IP 限流。针对同一 IP 短时间内的请求总量做限制,防止有人写脚本批量刷接口。这个放在拦截器里执行,每个请求进来先检查 IP 维度的计数器。上线后观察日志,平时的正常请求量远低于限流阈值,所以对正常用户几乎没有感知。
5.4 敏感词过滤与操作日志
评论、昵称这些用户输入内容,发布前必须经过敏感词过滤。我用了基于 DFA 算法的敏感词工具,初始化时加载一次词库到内存,匹配时时间复杂度稳定。词库支持热更新,运营发现新词后通过管理端追加,不用重启服务。
管理员的所有敏感操作(发布内容、审核通过、修改用户状态、调整角色)都记录操作日志。日志内容包含操作人、请求路径、请求参数、IP、时间。审计日志不做实时分析,每天由脚本归档一次,出问题时能根据日志还原完整操作链路。
6. 上线前的打磨:慢查询、缓存与部署排错
6.1 通过慢查询日志揪出性能短板
开发环境数据量小,很多 SQL 性能问题根本测不出来。上线前我导了一批模拟数据,然后开启 MySQL 慢查询日志:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;跑了几轮测试后,发现study_detail表按用户查询的时候有慢 SQL。原因是表里数据量上来后,虽然建了(user_id, article_id)索引,但查询条件里还有update_time的排序,导致 MySQL 需要回表。解决方法是把排序字段加入索引,建立(user_id, article_id, update_time)联合索引,或者改用覆盖索引把duration、finish_flag也带进去。
这里给新手一个建议:不要相信直觉,先用 EXPLAIN 看执行计划。EXPLAIN SELECT ...一行输出就能告诉你有没有走索引、扫描了多少行、有没有 Using filesort。慢 SQL 优化是有据可循的,不是拍脑袋瞎改。
6.2 Redis 缓存策略:文章详情和分类列表
文章详情是读多写少的典型场景,一天可能被访问几千次。我用 Cache Aside 模式:
public ArticleVO getArticleDetail(Long id) { String key = "article:detail:" + id; Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { return JSON.parseObject(cache.toString(), ArticleVO.class); } ArticleVO vo = queryFromDb(id); if (vo != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); } return vo; }更新文章时主动删除缓存,而不是等缓存过期。这样能保证编辑发布新内容后,用户刷新就能看到,最多只存在一个极短的缓存不一致窗口。
分类列表和排行榜数据也有类似处理。排行榜的缓存时间设短一点,比如 5 分钟,因为实时性要求相对高。缓存 key 的设计要带版本号,比如article:detail:v2:{id},改结构时直接换版本号,避免新旧数据格式混在一起导致的解析异常。
6.3 部署阶段踩过的典型坑
把项目从本地搬到服务器,遇到的第一个问题是MySQL 时区错乱。本地连数据库一切正常,服务器上所有create_time都比实际时间早 8 个小时,最后检查发现连接串里少配了:
jdbc:mysql://localhost:3306/redstudy?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4第二个坑是user 表名和保留字冲突。虽然user在 MySQL 里不是严格的保留字,但某些环境下容易出问题。我的习惯是表名前统一加t_前缀:t_user、t_article,既避免了保留字问题,也方便和其他业务库区分。
第三个坑是文件上传路径。本地开发用绝对路径没问题,打包成 jar 后路径就失效了。我的方案是把上传文件放在服务器固定目录/data/redstudy/upload,然后由 Nginx 做静态资源映射,应用代码里不保存完整路径,只保存相对路径。
第四个坑是HikariCP 连接池默认值太小。默认 maximum-pool-size 是 10,模拟答题并发 100 人时直接报连接不够。调大连接池确实能缓解,但更重要的是优化 SQL 和事务范围,连接池上限调到 40 后稳定运行,没有再出现连接耗尽的情况。
6.4 部署清单参考
整个项目部署我用的是最经典的方案:一台云服务器装 MySQL、Redis、Nginx,后端打 jar 包用 systemd 托管,前端打包后的 dist 目录交给 Nginx 托管。
# 后端构建打包 mvn clean package -DskipTests # 启动jar包 nohup java -jar redstudy-server.jar --spring.profiles.active=prod & # Nginx关键配置 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /data/redstudy/upload/; }生产环境一定要用独立的环境配置文件application-prod.yml,数据库连接、Redis 地址、日志级别都分开。开发环境和生产环境的配置混在一起,是后期运维事故的高发源头。
这个项目做下来,我最深的体会是:Spring Boot 本身只是工具,真正决定项目成败的是对业务模型的理解。把“学习闭环”四个字想透了,数据库设计和模块划分都会变得清晰;不想透,写了再多代码也只是在做一个不好用的 CMS。这套架构后续如果想继续扩展,可以往视频学习、积分商城、基于行为数据的个性化推荐这几个方向走,核心骨架基本不用动。希望这篇偏实战的记录能给你一些灵感,少走点弯路。