news 2026/10/20 12:57:49

基于Spring Boot的红色知识学习平台开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot的红色知识学习平台开发实战

前一阵我们团队接到一个需求,要做一套红色知识学习平台。刚听到这个题目时,我第一反应是:这不就是一个内容管理系统吗?无非是文章列表、用户登录、后台发文章。等真正把需求聊透之后才发现,这里面的坑比想象中多得多。基于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。这套架构后续如果想继续扩展,可以往视频学习、积分商城、基于行为数据的个性化推荐这几个方向走,核心骨架基本不用动。希望这篇偏实战的记录能给你一些灵感,少走点弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 12:57:48

技能是被练出来的:从知道到做到的习得方法

做了这么多年技术&#xff0c;也带过不少新人&#xff0c;我越来越发现一个扎心的事实&#xff1a;真正拉开人与人差距的&#xff0c;从来不是谁收藏的教程多、谁记的笔记全&#xff0c;而是谁把“知道”变成了“会做”。这几年我面试过的人少说也有上百个&#xff0c;简历上写…

作者头像 李华
网站建设 2026/10/20 12:57:07

显卡黄色感叹号怎么解决?三招搞定驱动与系统排查

设备管理器里那个黄色感叹号&#xff0c;几乎每个用过电脑的人迟早都会撞上一次。尤其当它挂在“显示适配器”下面的显卡设备上时&#xff0c;整个屏幕要么直接黑着&#xff0c;要么分辨率掉到像回到了十年前&#xff0c;图标又大又糊&#xff0c;看什么都难受。说实话&#xf…

作者头像 李华
网站建设 2026/10/20 12:55:41

什么是 context-mode?一种解决多环境状态污染的工程实践

项目标题&#xff1a;“context-mode”这个说法目前在主流技术社区、开源项目文档、编程语言官方资料或权威技术出版物中&#xff0c;并无明确定义的标准化术语。它既不是 Python 的内置模式、JavaScript 的运行时上下文类型&#xff0c;也不是 Linux shell 的标准执行态&#…

作者头像 李华
网站建设 2026/10/20 12:55:41

context-mode:上下文感知模式的技术原理与工程实践

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题"context-mode"&#xff0c;但未提供任何实质性的【项目正文】、【关键词】或【摘要描述】&#xff1b;所附“相关热搜词”与“基于标题及热词网络搜索的内容”部分为空&#xff0…

作者头像 李华
网站建设 2026/10/20 12:55:25

AI短剧创作系统实战:五环流水线破解短视频批量生产瓶颈

短视频流量没有消失&#xff0c;消失的是“手工剪片”的红利。过去大半年&#xff0c;身边几个做教培课程、连锁门店和自媒体代运营的朋友&#xff0c;几乎都撞上了同一堵墙&#xff1a;素材拍了一大堆&#xff0c;剪辑却卡在人工上&#xff0c;一条片子从选题到发布要三四个小…

作者头像 李华
网站建设 2026/10/20 12:55:16

从零搭建OpenClaw:私人AI助手的工具调用与安全设计实践

最近我花了一个周末&#xff0c;把一台吃灰的小主机变成了一套能自己干活的私人AI助手。这个项目我给它起了个名字&#xff0c;叫 OpenClaw&#xff0c;核心就三件事&#xff1a;让它自己去网上搜实时资料、让它写代码并跑起来、让它通过SSH接管我的服务器做日常巡检和运维。以…

作者头像 李华