1. 需求分析与整体设计思路
1.1 这个项目到底在解决什么问题
“儿童性教育网站系统”这个题目,放在技术角度上,其实是一个再常见不过的“内容管理 + 在线学习”类系统。儿童性教育是一个长期被忽视、但近几年越来越受到学校和家庭重视的领域。传统模式下,这类教育往往依赖线下课堂、纸质教材,存在内容更新慢、覆盖范围窄、难以追踪学习效果的问题。而一个网站系统,可以把内容按照年龄段和主题拆分成模块,让家长、老师和孩子都能以自己的身份访问,学习进度在线保存,数据实时统计。
从开发角度看,这个项目最大的价值在于它覆盖了前后端分离开发中的典型场景:用户角色权限(学生、家长、管理员)、内容分类管理、学习记录追踪、数据可视化展示。换句话说,你把这个项目做透了,再去面试或者做其他管理系统,基本是降维打击。因为它不是简单的“增删改查”拼凑,而是有真实业务逻辑在里面的系统。
1.2 用户角色与核心模块拆解
拿到题目后,我做的第一件事是画角色图。别急着写代码,先把业务边界想清楚。
系统里至少有三类用户:
| 角色 | 核心诉求 | 典型操作 |
|---|---|---|
| 学生 | 浏览学习内容、记录学习进度、完成课后问答 | 查看文章、视频,标记已学,提交答案 |
| 家长 | 了解孩子学习情况、管理家庭账号 | 查看学习报告、设置学习提醒 |
| 管理员 | 维护内容体系、管理用户、审核评论 | 分类管理、内容发布、用户禁用 |
基于这三个角色,系统天然分出了几个核心模块:
- 用户认证与权限管理模块
- 内容管理模块(图文、视频、音频多种形式)
- 分类与标签体系(按年龄分组、按主题分组)
- 学习进度记录模块
- 数据统计与展示模块(家长端和个人中心)
每个模块不是孤立存在的。比如学习进度记录,前端需要调用后端接口,后端要查数据库里这个用户的学习记录表,而内容表要有对应的内容ID,分类表又决定了内容的归属。业务链路是通的,系统才立得住。
1.3 为什么选前后端分离架构
如果是传统单体项目,JSP + Servlet 也能做出来,但维护性和扩展性都很差。前后端分离的最大好处是:前端只负责页面渲染和用户交互,后端只负责数据接口和业务逻辑,两者通过 JSON 格式的接口通信。开发时可以并行推进,部署后也可以分别扩展——比如页面访问量上来了,前端静态资源可以交给 CDN;接口压力大了,后端可以横向扩展多台机器做负载均衡。
对于儿童教育网站这种内容型产品,还有一个隐藏的好处:内容运营人员可以独立替换前端页面,而不影响后端逻辑。比如今天要上线一个“青春期启蒙”专题,只需要在分类里新增数据,前端配置好对应路由即可,后端代码一行不用动。这就是模块解耦带来的实际价值。
理解了这个架构逻辑,下面我们就可以定技术栈了。
2. 技术选型与核心机制解析
2.1 SpringBoot:为什么不是 SSM 手工整合
很多课程设计还在用 SSM(Spring + SpringMVC + MyBatis)那套手工 XML 配置方案。不是说不能用,而是效率太低。SpringBoot 的核心思想是“约定大于配置”,它通过自动配置把大量样板代码消掉了。
比如一个 Web 接口,SSM 下你要写 web.xml、spring-mvc.xml、datasource.xml 等一堆配置文件,还要手动装配 SqlSessionFactory。SpringBoot 下只需要在 pom.xml 里引入 spring-boot-starter-web,然后写一个 Controller 类就行。
更重要的是,SpringBoot 内置 Tomcat。本地开发时,运行 main 方法就能启动项目,部署时打成一个 jar 包丢服务器上直接跑,不需要单独安装 Tomcat。对一个学生项目或者个人项目来说,这个开发体验的差距是决定性的。
2.2 Vue:前端为什么用它而不用 JQuery
如果是十年前,这类项目用 JQuery + Bootstrap 就能交差。但现在做前后端分离,Vue 的价值体现在组件化开发和响应式数据绑定上。
举个例子:课程列表页面需要展示分类、内容卡片、学习进度三条数据。JQuery 的做法是拼字符串然后 innerHTML,一旦数据变化,你得手动去操作 DOM。Vue 呢?你在 template 里写 {{ courseList }},数据变了页面自动跟着变,中间的状态同步逻辑框架全部帮你处理好了。
Vue 生态里还有一个关键配套:Vue Router 和 Axios。Vue Router 负责前端路由,比如 /course、/course/1、/admin,这种页面级别的跳转不需要后端参与;Axios 负责和后端 API 通信,统一封装请求头、拦截错误、携带 token,这些都是一套 Web 应用的基本盘。
2.3 MyBatis:半自动化持久层的取舍
选 MyBatis 而不是 JPA/Hibernate,核心原因是“可控性”。教育类系统的查询场景很复杂:条件分页查询、多表关联统计、动态排序。MyBatis 允许你手写 SQL,优化起来心里有数。
比如“获取某个年龄段的所有课程并按最新发布时间排序”这类需求,SQL 里的 WHERE 条件取决于前端传的参数。MyBatis 的动态 SQL 标签(if、where、foreach)可以非常优雅地处理这种场景。JPA 也能做,但遇到复杂 SQL 时,要么写 JPQL,要么走原生 SQL,还得处理懒加载问题,学习成本反而更高。
MyBatis 的常规用法就三件事:写 Mapper 接口、写 XML 映射文件、在 Service 里调用。下面的数据库设计和核心代码环节,我会把这三件事一一展开。
2.4 MySQL:数据的最终归宿
MySQL 在这个项目里承担的是所有业务数据的持久化存储。选择它的理由很简单:成熟稳定、社区资料多、大学课程里几乎全覆盖。对于这种中小型系统,MySQL 的 InnoDB 引擎在事务支持、行级锁、崩溃恢复方面都足够了。
数据量级预判:假设有 10 万用户,每人产生 50 条学习记录,也就是 500 万条记录。这个量级下,MySQL 只要索引建得合理、SQL 写得规范,性能完全没问题。不需要上 Redis 做缓存,也不需要分库分表,老老实实把表结构和索引设计好,就能稳定跑起来。
3. 数据库设计与核心表结构
3.1 数据库整体规划
拿到需求后,先想清楚需要哪些表。我最终规划了以下核心表:
- user:用户表,存储三类账号(学生、家长、管理员)
- category:内容分类表,支持层级结构,如“5-8岁”“9-12岁”或“认识身体”“隐私保护”
- content:内容表,保存图文、视频链接、音频链接等
- content_comment:评论表(可选,先做基本功能可省略)
- learning_record:学习记录表,记录用户看过哪些内容、学到哪一步
- feedback:反馈表,家长或学生提交疑问
六个表之间通过外键关联。具体的关联关系是:user 和 content 多对多,通过 learning_record 关联;category 和 content 一对多;user 和 feedback 一对多。
3.2 表结构 DDL(可直接执行)
下面这个 DDL 是我在实际项目中整理过的版本,字段设计兼顾了业务需要和查询效率。你可以直接复制执行。
-- 用户表 CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '密码,MD5后存储', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '角色:1-学生 2-家长 3-管理员', `age` INT DEFAULT NULL COMMENT '年龄(学生角色使用)', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';-- 内容分类表 CREATE TABLE `category` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `parent_id` INT DEFAULT 0 COMMENT '父分类ID,0表示顶级', `name` VARCHAR(100) NOT NULL COMMENT '分类名称', `age_group` VARCHAR(20) DEFAULT NULL COMMENT '年龄段标签', `sort_order` INT DEFAULT 0 COMMENT '排序权重,越小越靠前', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容分类表';-- 内容表 CREATE TABLE `content` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `category_id` INT NOT NULL COMMENT '所属分类ID', `title` VARCHAR(200) NOT NULL COMMENT '标题', `summary` VARCHAR(500) DEFAULT NULL COMMENT '摘要', `body` TEXT COMMENT '正文内容(富文本)', `video_url` VARCHAR(255) DEFAULT NULL COMMENT '视频链接', `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图', `is_published` TINYINT DEFAULT 0 COMMENT '是否发布:0-草稿 1-已发布', `publish_time` DATETIME DEFAULT NULL COMMENT '发布时间', `view_count` INT DEFAULT 0 COMMENT '浏览量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容表';-- 学习记录表 CREATE TABLE `learning_record` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL COMMENT '用户ID', `content_id` INT NOT NULL COMMENT '内容ID', `progress` TINYINT DEFAULT 0 COMMENT '进度百分比 0-100', `finish_status` TINYINT DEFAULT 0 COMMENT '是否完成:0-未完成 1-已完成', `last_study_time` DATETIME DEFAULT NULL COMMENT '最近学习时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_content` (`user_id`, `content_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习记录表';3.3 设计要点与避坑
表设计阶段有几个坑是新手必踩的,我这里结合实战拆一下:
第一个坑是密码存储。不少同学直接把明文密码存库,这是严重错误。虽然这个项目只是教学/练手用,但一旦上线就是事故。正确做法是 MD5(加盐更稳)后再存。登录时把输入密码做同样处理再比较。
第二个坑是 timestamp 字段的类型。如果你的 MySQL 版本低于 5.6,DATETIME 默认值 CURRENT_TIMESTAMP 是不支持的,需要换成在代码里手动 set。我建议直接用 5.7+,省心很多。
第三个坑是 utf8mb4 字符集。如果表结构用默认 utf8,存 Emoji 表情或生僻字会报错。儿童教育网站难免遇到一些特殊字符,直接在建表语句里写死 utf8mb4,避免后患。
第四个坑是 category 表的 parent_id 设计。我做的是有限层级(最多两级),所以直接用一个 parent_id 搞定。如果你想支持无限层级,需要额外的路径字段或者递归查询,但在这个项目里完全没必要,别过度设计。
4. 后端核心功能实现
4.1 项目结构与依赖配置
后端工程我建议用 Maven 搭建标准结构。下面是 pom.xml 的核心依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>Lombok 在课程设计里很好用,@Data 注解省掉所有 getter/setter,代码看起来干净很多。JWT 依赖是为了做登录鉴权,下面会讲。
application.yml 里的配置长这样:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/edu_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.edu.entity configuration: map-underscore-to-camel-case: true注意map-underscore-to-camel-case: true。这一行配置非常重要,它能让数据库字段create_time自动映射到实体类的createTime属性,省去在 XML 里一堆 resultMap 的麻烦。
4.2 登录鉴权实现(JWT + 拦截器)
登录功能是前后端分离架构里的灵魂点。传统 Session 方式在分离架构下不好使,因为跨域场景下 Cookie 很难维护。JWT 方案是现在的标准做法。
核心思路:用户登录成功后,后端生成一个 token 返回给前端。前端每次请求把这个 token 放进请求头(Authorization: Bearer xxx),后端拦截器校验 token,通过就放行,不通过就返回 401。
生成 token 的工具类:
public class JwtUtil { private static final String SECRET = "your-secret-key-change-it"; public static String createToken(Integer userId, String username) { Algorithm algorithm = Algorithm.HMAC256(SECRET); return JWT.create() .withClaim("userId", userId) .withClaim("username", username) .withExpiresAt(new Date(System.currentTimeMillis() + 7*24*60*60*1000)) .sign(algorithm); } public static DecodedJWT verify(String token) { return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); } }登录接口的核心逻辑:
@PostMapping("/login") public Result login(@RequestBody LoginDTO loginDTO) { // 1. 根据用户名查用户 User user = userMapper.findByUsername(loginDTO.getUsername()); if (user == null) { return Result.error("用户不存在"); } // 2. 校验密码(MD5对比) String inputPwd = DigestUtils.md5DigestAsHex(loginDTO.getPassword().getBytes()); if (!inputPwd.equals(user.getPassword())) { return Result.error("密码错误"); } // 3. 检查状态 if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } // 4. 生成token返回 String token = JwtUtil.createToken(user.getId(), user.getUsername()); return Result.success(Collections.singletonMap("token", token)); }拦截器配置里,要把登录接口和静态资源排除掉,其余接口统一校验。核心代码:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { response.setStatus(401); return false; } try { DecodedJWT decodedJWT = JwtUtil.verify(authHeader.substring(7)); request.setAttribute("userId", decodedJWT.getClaim("userId").asInt()); return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦截器注册:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register"); } }这里有一个细节值得注意:CORS 跨域配置。如果前端开发服务器跑在 5173 端口(Vite),后端在 8080,跨域会被拦截,所以后端必须开启跨域支持:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return new CorsFilter(source); } }很多同学部署后接口通不了,80% 都是跨域配置漏了或者拦截器把预检请求 OPTIONS 拦截了。上面的配置保留了预检请求,不会出问题。
4.3 内容管理模块实现
内容模块是这个系统的业务核心。我以“分类下内容分页查询”为例,展示 MyBatis 的完整链路。
Controller 层:
@RestController @RequestMapping("/api/content") public class ContentController { @Resource private ContentService contentService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword) { PageResult<Content> result = contentService.queryPage(page, size, categoryId, keyword); return Result.success(result); } }Service 层:
@Service public class ContentServiceImpl implements ContentService { @Resource private ContentMapper contentMapper; @Override public PageResult<Content> queryPage(Integer page, Integer size, Integer categoryId, String keyword) { Integer offset = (page - 1) * size; List<Content> list = contentMapper.selectPage(offset, size, categoryId, keyword); Long total = contentMapper.countPage(categoryId, keyword); return new PageResult<>(list, total, page, size); } }MyBatis XML 映射文件:
<select id="selectPage" resultType="com.edu.entity.Content"> SELECT * FROM content <where> AND is_published = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY publish_time DESC LIMIT #{offset}, #{size} </select> <select id="countPage" resultType="long"> SELECT COUNT(*) FROM content <where> AND is_published = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%')) </if> </where> </select>这里有个 SQL 层面的踩坑经验:LIKE '%keyword%'这种写法在大数据量下会全表扫描。这个项目数据量小,无所谓;但如果要优化,MySQL 5.7+ 可以用全文索引,或者改为前缀匹配(keyword%)。教学项目里别过度优化,但面试时能说出这个点,能加分不少。
4.4 学习记录与进度追踪
学习进度的核心逻辑是:用户看某篇内容,前端每隔一段时间调用一次“更新进度”接口。后端拿到进度值,更新 learning_record 表。
@PostMapping("/record/update") public Result updateRecord(@RequestBody RecordDTO recordDTO, HttpServletRequest request) { Integer userId = (Integer) request.getAttribute("userId"); // 判断是否已存在记录 LearningRecord existing = learningRecordMapper.findByUserAndContent(userId, recordDTO.getContentId()); if (existing == null) { LearningRecord record = new LearningRecord(); record.setUserId(userId); record.setContentId(recordDTO.getContentId()); record.setProgress(recordDTO.getProgress()); record.setFinishStatus(recordDTO.getProgress() >= 90 ? 1 : 0); record.setLastStudyTime(new Date()); learningRecordMapper.insert(record); } else { existing.setProgress(recordDTO.getProgress()); existing.setFinishStatus(recordDTO.getProgress() >= 90 ? 1 : 0); existing.setLastStudyTime(new Date()); learningRecordMapper.update(existing); } return Result.success(); }注意 unique key(user_id, content_id)的联合唯一约束。如果没有这个约束,高并发下可能出现重复记录,进而导致统计错乱。先建表约束,再在代码里做“先查后插”的逻辑,双重保险。
学习进度的 update 接口不能太频繁调用。我在前端设置的是每 10 秒调一次,且只在 progress 变化超过 5 个百分点时才触发,不然数据库压力会无谓增大。
4.5 数据统计与图表展示
统计模块主要面向家长和管理员。比如家长想看到“孩子在 5-8 岁分类下完成了 80% 的学习内容”,管理员想看“哪个分类的内容访问量最高”。
后端只提供统计数据接口,前端用图表库(如 ECharts 或 Chart.js)渲染。
@GetMapping("/statistics/category-views") public Result categoryViews() { List<CategoryViewVO> list = contentMapper.selectCategoryViewStatistics(); return Result.success(list); }对应的 SQL:
SELECT c.name AS categoryName, IFNULL(SUM(co.view_count), 0) AS totalViews FROM category c LEFT JOIN content co ON c.id = co.category_id GROUP BY c.id, c.name ORDER BY totalViews DESC这里用 LEFT JOIN 而不是 INNER JOIN,是为了把还没有内容的分类也统计进去(显示为 0),不然前端图表上会出现分类缺失。
5. 前端实现与交互逻辑
5.1 前端工程结构和路由设计
前端我使用 Vue 3 + Vite + Element Plus。Vite 比 Webpack 快很多,启动即热更新,开发体验非常好。工程结构:
src/ ├── api/ │ ├── auth.js │ ├── content.js │ └── record.js ├── views/ │ ├── Login.vue │ ├── Home.vue │ ├── CourseList.vue │ ├── CourseDetail.vue │ ├── Profile.vue │ └── admin/ │ ├── AdminDashboard.vue │ ├── CategoryManage.vue │ └── ContentManage.vue ├── router/ │ └── index.js ├── store/ │ └── user.js └── main.js路由配置需要加一个全局前置守卫,作用是判断用户有没有 token,没有就重定向到登录页:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else { next(); } });5.2 Axios 请求封装
前端所有接口请求通过一个统一封装的 Axios 实例发出去。核心点有两个:请求拦截器自动带 token,响应拦截器统一处理 401 和错误提示。
import axios from 'axios'; import { ElMessage } from 'element-plus'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); // 请求拦截器:自动附加token request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:处理业务错误和401 request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response?.status === 401) { localStorage.removeItem('token'); router.push('/login'); ElMessage.warning('登录已过期,请重新登录'); } else { ElMessage.error('网络请求失败'); } return Promise.reject(error); } );关于 baseURL,开发环境用代理解决跨域。在 vite.config.js 里配置:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });这样前端请求/api/login,实际会转发到http://localhost:8080/api/login,而且因为代理方式请求是同源的,连后端那个 CORS 配置都可以不设置。不过我还是建议后端 CORS 开起来,因为部署后可能前后端域名不一致。
5.3 学习页面与进度交互
学习内容页面是学生用户使用频率最高的页面,交互设计的合理程度直接影响学习体验。
我的设计思路是这样的:
- 左侧目录:列出当前分类下所有内容,已完成的内容打勾标识。
- 中间正文区:展示课程内容,如果是视频则嵌入视频播放器。
- 右侧信息栏:显示分类信息、学习进度条、相关推荐。
进度条更新的核心逻辑:
const updateProgress = () => { // 模拟:scrollTop / 总高度 计算阅读进度 const scrollTop = document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; const progress = Math.round((scrollTop / scrollHeight) * 100); if (Math.abs(progress - currentProgress) >= 5) { currentProgress = progress; recordAPI.update({ contentId: contentId, progress: progress }); } }; onMounted(() => { window.addEventListener('scroll', throttle(updateProgress, 3000)); });这个是简化版,真实项目中还要考虑视频学习场景:用户看到视频播放进度百分之多少,才算是“学完”。我在项目中用的是 vidoe 元素的 timeupdate 事件,每播放一定时长就上报一次进度。
还有一个细节:学习完成状态。很多同学只在进度到达 100% 时标记完成,但这是错的。我的处理是:阅读场景下,用户滑到底部并在该页面停留超过 10 秒,才算完成;视频场景下,用户播放到 90% 以上,就算完成。这样设计更贴近真实学习行为。
5.4 管理后台:分类与内容发布
管理员页面的核心功能是:内容发布全流程。我的实现是分类管理和内容管理两块。
分类管理页面,一个表格展示所有分类,支持新增、编辑、删除、排序。删除分类时必须校验:如果该分类下还有内容,禁止删除,提示“请先移走该分类下的内容”。
内容管理页面,编辑表单支持富文本,我用的是 wangEditor,轻量且够用。发布时有个“是否立即发布”的开关,对应后端的is_published字段。保存为草稿时 is_published=0,正式发布时 is_published=1,同时设置 publish_time 为当前时间。
这里有一个比较隐蔽的坑:富文本入库后再展示,需要保证 HTML 原样输出,不能转义。Vue 里用 v-html 指令渲染,但要注意 XSS 风险。后台只能管理员发布,所以风险可控,但如果你后续要开放用户投稿,一定要对富文本做过滤清洗,这是安全底线。
6. 部署与上线全流程
6.1 环境准备与服务器配置
部署环境建议如下:
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 与 SpringBoot 版本匹配 |
| MySQL | 5.7+ | 生产环境建议 8.0 |
| Nginx | 1.20+ | 前端静态资源服务和反向代理 |
| Maven | 3.6+ | 后端打包构建 |
| Node.js | 16+ | 前端构建(build 阶段需要) |
我部署时踩过一个版本坑:Java 17 下跑 SpringBoot 2.x 会报错,因为反射相关 API 被限制。你的 SpringBoot 版本如果低于 2.6,老老实实用 JDK 8;如果使用 2.7+ 或 3.x,JDK 11/17 都可以。
6.2 后端打包流程
本地验证没问题后,执行后端打包:
mvn clean package -DskipTests打包成功后 target 目录下会生成xxx.jar文件。把它上传到服务器的/app/edu-system/目录下。
启动命令:
nohup java -jar /app/edu-system/edu-backend.jar --spring.profiles.active=prod > /app/edu-system/logs/app.log 2>&1 &这里我建议把生产环境的数据库账号密码放到application-prod.yml配置文件里,通过--spring.profiles.active=prod激活。不要在默认配置里写生产密码,免得代码仓库泄露后数据库也跟着暴露。
检查启动是否成功:
tail -f /app/edu-system/logs/app.log看到Started Application in xx seconds就说明启动成功。
6.3 MySQL 初始化脚本
服务器上的 MySQL 需要手动导入建表语句和初始数据:
mysql -u root -p edu_db < /app/edu-system/sql/edu_db.sql如果提示数据库不存在,先执行:
CREATE DATABASE edu_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;初始数据建议插入一个管理员账号、两个测试学生账号,还有几篇示例教学内容。这样部署完成后页面不至于空荡荡的。
6.4 前端构建与 Nginx 配置
前端构建前先检查.env.production文件里的 API 地址。如果前后端部署在同一台服务器上,API 地址写成/api相对路径即可,由 Nginx 反代到后端服务。
构建:
npm install npm run build构建产物在dist/目录。把它上传到服务器/usr/share/nginx/html/edu-frontend/。
Nginx 配置:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html/edu-frontend; index index.html; # 前端页面路由:前端路由用 history 模式,必须配置 fallback location / { try_files $uri $uri/ /index.html; } # API反向代理 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 ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ { expires 7d; add_header Cache-Control "public, no-transform"; } }try_files $uri $uri/ /index.html;这一行千万别漏。Vue 的 history 路由模式下,用户直接访问/course/1时,Nginx 找不到这个真实文件路径,没有 fallback 就会返回 404。我见过太多人部署后,首页能打开,一刷新子页面就白屏,就是这个配置缺失导致的。
配置修改后,重载 Nginx:
nginx -t nginx -s reload6.5 HTTPS 配置(可选但强烈建议)
儿童教育类网站建议上 HTTPS,因为会有家长和孩子浏览,传输安全教育内容时加密是基本要求。
在 Nginx 配置里加证书相关字段,核心就三行:
listen 443 ssl; ssl_certificate /etc/ssl/edu-cert.pem; ssl_certificate_key /etc/ssl/edu-cert-key.pem; # 同时把80端口请求重定向到443证书可以申请免费的三方证书。这个步骤虽然增加了一些部署复杂度,但对提升网站可信度非常有帮助。地址栏带锁和不带锁,用户的心理信任度差异很大。
7. 常见问题与排错实战
7.1 端口被占用或启动失败
后端启动报Port 8080 was already in use,说明 8080 端口被占用了。排查:
netstat -tlnp | grep 8080杀掉占用进程:
kill -9 <进程号>或者直接换端口,修改 application.yml 里的server.port。我建议服务端应用防范意识强一点,生产环境开 8080 或者修改成更冷门的端口,减少被扫描攻击的概率。
还有一种启动失败情况:Access denied for user 'root'@'localhost'。这是数据库账号密码不对。检查配置文件里的 username 和 password 是否有拼写错误,以及 MySQL 用户是否只允许 localhost 连接。如果数据库在另一台机器,用户还需要授权远程访问。
7.2 前端白屏
白屏是高频问题,排查顺序如下:
- 按 F12 打开开发者工具看 Console 报错。
- 看 Network 面板里
/api请求是否返回 200。 - 如果请求挂在 401,说明 token 过期,重新登录即可。
- 如果接口返回 404,检查 Nginx 的
/api/反代配置,尤其检查proxy_pass后面是否带了路径。
还有一种情况:构建完 dist 后直接双击 index.html 打开,是纯白屏。这是因为 Vue history 路由和相对路径资源加载问题,生产环境必须通过服务器访问,本地测试建议用npm run preview或者 Nginx。
7.3 跨域问题复盘
如果你在前端开发时没配代理,直接请求 http://localhost:8080/api/xxx,浏览器会报 CORS 错误。解决办法在前文已经给了:后端开 CORS 过滤器,或者前端配 Vite 代理。最省事的方案是:开发用代理,生产用 Nginx 反代,后端 CORS 开不开都行。
值得注意:如果你用 axios 的 baseURL 写死了http://localhost:8080/api,部署到服务器后忘了改,页面照样白屏。我给你的建议是:baseURL 永远不要写死绝对地址,统一用/api相对路径,让代理层去决定转发到哪。这样代码挪到什么环境都能直接跑。
7.4 MyBatis 常见报错集锦
报错Invalid bound statement (not found),大概率是 Mapper XML 文件名和 Mapper 接口名不一致。检查 XML 文件里的 namespace 是否写成了接口的全限定名。
报错Field 'xxx' doesn't have a default value,是表结构和插入语句对不上。检查实体类字段、XML 里 insert 语句字段是否都有值,create_time这类字段如果数据库没有默认值且代码没 set,就会报这个错。
报错Too Many Connections,是数据库连接数被占满。重启 MySQL 服务能暂时解决,但根本原因是连接池配置过小。在 application.yml 中增加:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5别把 maximum-pool-size 调太大,20 对于这个项目足够。设置太大,MySQL 反而扛不住。
7.5 学习进度数据丢失
有时候用户明明学完了一篇文章,但个人中心显示进度为 0。这个问题通常出在前端上报逻辑上。检查两个地方:
- 前端是否在路由离开时做了最后一次性上报。
- 后端 updateRecord 接口里的 user 是否拿到的是当前操作人。
我之前遇到过一个问题:从详情页返回列表页时,页面触发 scroll 事件上报了progress = 0,直接把后端库里存的 80% 覆盖成 0% 了。修复方案是:前端在离开页面时,如果当前 progress 大于已保存值才上报;后端也做同样判断,只在新的 progress 大于旧值时更新。
这个“只看增加,不看减少”的规则,在学习进度这类场景下是通用逻辑,不然用户每次重新打开内容,进度条都会因为初始值为 0 而被重置,体验非常糟糕。
8. 实操总结与扩展建议
写到这里,整个项目从需求分析到架构设计,从数据库建表到前后端代码实现,再到最后的服务器部署和问题排查,完整链路已经走了一遍。按我这个流程走下来,前后端分离儿童性教育网站系统是能真正跑起来的。
我在实际做这个项目时的最大感受是:虽然技术上都是常规操作,但真正耗时间和踩坑的往往不是新增业务逻辑,而是那些边界场景——刷新后路由失效、token 过期后的跳转、进度上报的重复覆盖、部署环境差异。这些细节如果没人提醒,单纯照着教程敲一遍代码,确实很难提前规避。
如果你打算基于这个项目继续扩展,我建议优先做三件事:一是加入图片/视频内容审核流程,管理员发布的内容需要二次审核后才能上线,这样更符合儿童教育网站的内容安全要求;二是引入 Redis 做接口缓存,减轻数据库压力,也能顺带实现验证码的存取;三是增加家长端的数据报告下载功能,把孩子的学习记录导出成 PDF 或 Excel,方便家长存档和老师做记录。
另外还有个小技巧:代码仓库里记得把数据库密码、JWT 密钥都放到配置文件的占位符里,用环境变量注入。这样即使源码被传到公开仓库,核心机密也不会泄露。我在给多个同学做代码审查时发现,至少有三分之一的人把数据库密码直接写死了,这是个非常危险的坏习惯,哪怕只是练手项目,也应该从一开始就养成好习惯。
这个项目作为前后端分离的入门级完整系统,技术难度不大,但业务链条很完整。把它弄懂、弄透,比盲目追求高难度的炫技项目要实在得多。遇到具体的报错或设计问题,欢迎对照本文的排除思路,一步步来。