去年做完一个基于SpringBoot的兴趣家教平台系统,从需求梳理到上线大概花了两个月时间。这个项目不是那种市面上常见的“学科辅导匹配平台”,而是专门针对兴趣类教学场景,像钢琴陪练、绘画入门、少儿编程、书法、围棋、街舞这类非学科内容。整个系统涵盖用户注册认证、老师入驻、课程商品发布、试听预约、订单支付、课时包管理、约课排课、上课核销、评价闭环以及后台运营配置,是一个比较完整的交易型平台。
如果你正准备做这类作品,或者你所在团队想用SpringBoot从零搭一个“轻交易+轻社交”的业务系统,这篇文章可以把我在设计表结构、写核心接口、做事务控制和部署排障时的思路完整讲一遍,包括那些普通教程里很少提到的坑。
1. 这个项目到底要解决什么问题
1.1 需求从哪里来
先说一个最常见的痛点:家长想给孩子找一位周末教国画的老师,身边没有合适的人选,去培训机构的成本又太高,信息主要靠朋友推荐或者本地群打听。另一边,很多有专业技能的年轻人,比如美院在读学生、乐团乐手、退役运动员,他们有教学能力,却缺少一个能把课程挂出来、把时间表管理起来的轻量工具。
常见的家教平台大多聚焦K12学科辅导,老师上传简历之后由平台安排试讲,流程重、审核周期长。兴趣类课程则完全不同,它更像“商品”,可以标准化为课时包,一节60分钟或者90分钟,用户先看老师的介绍和往期作品,再决定是否购买。所以这个平台的定位是一个轻量交易撮合系统,并不做复杂的教研、排班或ERP。
1.2 角色链路与核心流程
平台涉及三类核心角色:学生或家长端(C端用户)、老师端(B端服务提供者)、后台管理员。
C端用户的主要操作是浏览课程、搜索附近或支持线上教学的老师、查看评价、下单购买课时包、预约某位老师某个时间段,然后在实际上课后确认核销。老师端需要完成实名入驻、创建课程、维护可约时间段、查看订单、确认开课、管理自己的课时收入记录。管理员负责审核老师入驻资质、上下架课程、处理纠纷和举报。
整个业务主链路可以梳理成:
- 老师提交入驻申请,上传资质附件
- 管理员审核通过,老师发布课程包并设置可约时间
- C端用户浏览/搜索课程
- 用户选择试听或者直接购买课时包,生成订单
- 模拟在线支付成功,订单变为已支付并发放课时到用户账户
- 用户在老师的可约时间段里选择上课时间,生成课表记录
- 到达上课时间,老师或用户确认上课,系统扣减课时
- 课后双方完成评价,评价展现在老师主页
值得说明的是,这里把“买课时包”和“约具体上课时间”拆成了两个步骤。很多新手在做这类系统时会把订单和预约混在一起,认为用户下单时就选定时间。实际业务里,购买课时包通常是一笔预充值交易,钱是先付给平台或者老师,之后每次上课再从课时账户中扣减。如果把每次上课都做成独立订单,会产生大量小额支付,支付手续费和售后成本都不划算,所以采用“课时包+约课核销”这个模型更贴近线下培训机构的真实玩法。
1.3 模块边界怎么划分
按业务边界划分模块,后面写代码才不会有纠缠不清的感觉。我大致分成:
| 模块 | 核心职责 |
|---|---|
| 用户认证模块 | 注册、登录、验证码、JWT签发与刷新、找回密码 |
| 老师入驻模块 | 资质提交、审核状态流转、入驻资料维护 |
| 课程商品模块 | 课程分类、课程包管理、课程上下架、浏览与检索 |
| 订单支付模块 | 订单创建、支付回调处理、订单状态流转、超时关闭 |
| 课时账户模块 | 用户课时包、发课时、扣课时、变动流水 |
| 预约上课模块 | 可约时间设置、约课、取消、核销 |
| 评价投诉模块 | 课程评价、回复、后台审核 |
| 内容运营模块 | Banner位、公告、课程推荐排序 |
| 后台管理模块 | 管理员权限、审核、统计报表 |
把模块边界切清楚后,再去看Controller和Service层的包结构就会很自然。不要一上来就同时做营销优惠券、拼团、分销这类复杂玩法,先跑通交易闭环比什么都重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与工程初始化
2.1 后端框架版本怎么选
SpringBoot是当前Java后端开发绕不开的框架,核心价值在于自动装配、约定优于配置和丰富的Starter生态。这个项目选用的是SpringBoot 2.7.18,而不是最新的SpringBoot 3.x,原因是当时项目依赖的一部分第三方SDK对JDK8更友好,团队环境也是JDK8,直接上SpringBoot 3的话JDK版本还得升级到17,SpringFox的Swagger文档方案在3.x也会遇到兼容性问题。
如果你是新开的个人项目且愿意用JDK17,SpringBoot 3.x也完全没问题,SpringDoc相对更积极适配新版本。但如果你只是做课程设计、demo演示或者公司内部旧系统迭代,老老实实用2.7.x配JDK8通常最省心,网上搜到的大多数资料都能直接参考。
工程构建工具使用Maven,原因是Spring Initializr生成后即可运行,没有Gradle的学习成本。项目结构采用Maven多模块或单模块都可以。我建议单体阶段尽量用单模块,或者如下按功能拆子模块但最终打成一个jar包:
homework-common:统一返回、异常、工具类homework-admin:后台管理相关接口homework-api:C端和B端接口homework-server:启动类、配置类、打包入口
如果只是个人维护,单工程里面按controller / service / mapper / entity / dto / vo分包也完全够用,不需要为了“微服务”的噱头把一个下单逻辑拆成几十个服务,那是自找麻烦。SpringBoot项目启动类依赖的@SpringBootApplication核心逻辑就是自动装配,它会扫描当前包及其子包下的Bean,所以启动类位置别乱挪,不然Controller扫不到,这个错误新手经常犯。
2.2 认证、存储、缓存选型
做电商型系统,验证用户身份不能用简单的session共享方案,因为前后端分离之后接口要面向App、小程序和H5多端复用,所以直接采用Spring Security + JWT实现无状态认证。简单说就是用户登录成功后拿到一个token,之后每次请求在Authorization头里带上,后端通过过滤器解析并设置登录态。
数据库选型上,MySQL 8.0作为核心数据存储没有任何悬念,支持事务、全文索引、JSON字段,对于这种中小体量的平台足够。持久层框架我使用的是MyBatis-Plus,因为它自带BaseMapper的单表CRUD、分页插件、条件构造器,可以把课程筛选这类动态SQL写得很简洁。但是复杂统计报表或者多表关联的查询,我更倾向于在XML里手写SQL,不要盲目全用代码生成器自动生成的一堆通用方法,否则后期改起来特别痛苦。
缓存选型上接入Redis,主要存放三类数据:短信验证码、用户登录token黑名单、高频访问的课程列表。课程详情页通常会被大量并发访问,如果把每次查询都打到MySQL,数据库压力会很大。我会在后端给课程详情加一层缓存,key设计成course:detail:{courseId},课程信息变更后主动删除缓存,下一次查询再回填。这个方案比任何复杂的缓存中间件都好理解,也够用。
文件存储方面,开发环境使用MinIO搭建私有对象存储,生产环境可以平滑切到各大云厂商的OSS或S3兼容存储。老师上传资质、课程封面、作品图都走对象存储,不落本地磁盘。这样做的好处是多个后端节点部署时不用关心文件同步问题,前端拿到的是一串URL,CDN后续也好接。
2.3 工程分包与代码结构
后端代码建议按这个结构组织:
code复制com.example.tutor
├── TutorApplication.java
├── common
│ ├── exception
│ ├── result
│ ├── utils
│ └── constant
├── config
│ ├── SecurityConfig.java
│ ├── RedisConfig.java
│ ├── MybatisPlusConfig.java
│ ├── SwaggerConfig.java
│ └── WebMvcConfig.java
├── security
│ ├── JwtAuthenticationFilter.java
│ ├── LoginUser.java
│ └── UserDetailsServiceImpl.java
├── controller
│ ├── AuthController.java
│ ├── CourseController.java
│ ├── OrderController.java
│ ├── ScheduleController.java
│ └── admin
├── service
│ └── impl
├── mapper
├── entity
├── dto
├── vo
└── task
这里的核心思路是让依赖方向由Controller层向内收敛,Service实现类尽量只暴露接口给上层。JWT过滤器放在security包中,配置类统一放到config包,不要散落在业务代码里。全局异常处理类也用@RestControllerAdvice统一管理,业务异常码建议在common/exception中定义枚举。
工程初始化还有一个很多人忽略的细节:一个团队或项目的application.yml版本管理。开发环境、测试环境、生产环境最好拆成三个配置文件,用spring.profiles.active动态指定。比如本地连localhost的MySQL,测试库和线上库则在各自的配置文件中维护,避免每次部署时手动改连接串。
3. 核心表结构设计思考
3.1 用户与角色表怎么设计
首先定义统一的用户表,所有端用户都复用这一个表,不拆成“家长表”“老师表”“管理员表”,否则后期角色一旦扩展,数据模型就僵死了。通过用户角色关联表来区分身份,一个用户自身可以既是老师又是家长,这在现实中也是成立的,他自己学吉他,同时给孩子报一节美术课。
用户表核心字段包括:
- id:主键,使用雪花ID或数据库自增。个人项目用自增简单,但表数据迁移时会比较麻烦,建议直接用MyBatis-Plus默认的
ASSIGN_ID生成雪花ID - phone:手机号,唯一索引
- password:BCrypt加密后的密码,允许密码登录和验证码登录两种方式
- nickname、avatar
- status:1正常,0禁用。被禁用后无法登录和下单
- user_type:冗余字段,标识主要身份,比如1学生/家长,2老师,3管理员,便于列表查询
- create_time、update_time
用户角色如果细分,需要单独建角色表和用户角色关系表,也可以在单表中存角色集合字符串。传统做法建议角色表独立:
code复制role: id, role_code, role_name
user_role: id, user_id, role_id
用户和老师之间的关联还有一个容易遗漏的点:用户主表之外,还需要一张teacher_profile表保存老师的教学资质信息,比如教学年限、擅长方向、个人介绍、认证状态、审核状态。不要让业务属性字段全部堆在sys_user里,否则一张表几百个字段,索引都建不动。
3.2 课程、订单与课时账户
课程表course用来承载“课程包”这种商品,归属于某个认证老师。课程可以包含标题、封面图、课程分类、课程形式(线上/线下)、单价总课时、总价、上课适应人群、课程亮点等。
但是课程和订单之间不能直接挂在课程ID下,因为用户买的不是“某一节具体课”,而是“教学服务包”。这里需要一个课时包的概念,我设计成了course_package,比如“钢琴入门 10节基础包”或“素描体验 1节试听包”。一个老师可以为一个课程创建多个课时包。订单表记录的SKU则是某个course_package。
订单表关键字段:
- order_no:业务订单号,要求唯一,专门给用户看和客服查
- user_id:下单用户
- package_id:购买的课时包
- course_id、teacher_id:冗余,减少多表join
- total_amount、pay_amount、discount_amount:金额一律用int类型存“分”,比如99.00元存9900,避免double导致精度丢失
- status:枚举,0待支付,1已支付,2已取消,3已退款,4支付超时关闭
- pay_time、cancel_time
大多数情况下,订单支付后还要做两件事:发课时流水和课时账户余额增加。为了不让“订单支付了但课时没到账”这种状态出现,必须在同一个本地事务里完成。如果后面引入消息队列异步投递,就需要额外做对账补偿,现阶段没有这个必要,直接同步事务是最稳妥的。
课时账户表user_course_account记录用户在某个课程包下剩余课时,例如用户买了一个10节钢琴包,目前剩余7节。每次核销扣减时还要往course_lesson_record写入一条流水,记录每次扣减的订单号、上课记录、变化数量。通过流水表可以清晰追溯“用户最初买了20节,为什么现在只剩3节”。
3.3 排课、核销与评价
预约上课是整个系统设计中最讲究的地方。老师的可约时间我设计成两个维度:
- 老师维护一个默认周时间模板,比如每周日上午10点到12点可约
- 针对临时调课可以设置某一天不可约
然后前端日历会展示老师未来的可约时间段,用户选择一个具体时间后,生成预约记录appointment。
appointment核心字段是appointment_no、teacher_id、user_id、course_id、start_time、end_time、status。status状态机比较关键:0待上课,1已取消,2待到课确认,3已完成,4缺勤。由于兴趣课程通常是小班或者1对1,一个时间段最多容纳一个用户或一个班。因此,防止同一个时间段被两个人同时预约,最稳妥的方式是对teacher_id + appointment_date + time_slot建唯一索引,并在插入前先检查数量,把“超卖”拦在数据库层。
核销记录表建议与预约表合一,在appointment上增加confirm_type、confirm_time字段,不需要额外建一张核销表。用户或老师点击“确认上课”后,系统在事务中完成三件事:修改appointment状态为已完成,扣减课时账户余额,添加课时扣减流水。如果漏掉第三步,用户会发现上完一节课还是原来的课时数,这是典型的“没有用事务包住所有写操作”。
评价表则相对简单,关联appointment_id和user_id,填写评分、内容,后台可以审核是否展示。一个appointment只允许评价一次,所以对appointment_id做唯一约束。
4. 核心接口从0到1的实现过程
4.1 登录认证与权限控制
这是用户看到的第一个功能,做不好后面所有接口都别扭。这里把认证相关的核心配置和思路讲清楚。
使用Spring Security时,很多新手会被它一大堆默认过滤器和配置吓到。其实核心就三步:配置放行规则、自定义过滤器验证JWT、注入UserDetailsService用户信息。
自定义处理JWT的过滤器是整个登录功能的骨架。它会读取Authorization请求头里的Bearer token,如果token有效并且Redis中不存在黑名单标记,就解析出用户id和角色,把LoginUser写入SecurityContext。后续接口通过@AuthenticationPrincipal或者工具类获取当前用户。
java复制@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
Long userId = jwtUtils.parseUserId(token);
if (userId != null) {
LoginUser loginUser = new LoginUser();
loginUser.setUserId(userId);
loginUser.setRoles(jwtUtils.parseRoles(token));
// 这里真实项目中应从Redis或数据库加载完整用户信息
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
由于过滤器先于SpringMVC执行,在这里抛出的异常无法被普通@RestControllerAdvice捕获,所以必须在filter中手动根据请求类型返回JSON错误信息,或者在SecurityConfig中配置认证异常处理器。
SecurityConfig放行了哪些接口要重点关注。公开接口包括登录接口、注册接口、发送验证码、课程列表和详情、老师信息主页。其余接口全部需要认证,而老师入驻、课程发布、预约核销等操作还需要角色权限校验。用注解@PreAuthorize("hasRole('TEACHER')")控制起来非常直观,前提是在启动类或配置类开启@EnableGlobalMethodSecurity(prePostEnabled = true)(SpringBoot 2.7写法)。
一个多年项目常踩的坑是Swagger文档被拦截器保护导致前端无法预览接口文档。JWT暴露的接口中需要把/swagger-ui/**、/v3/api-docs/**、/doc.html这类路径全部加入白名单,以免调试接口时还要折腾token。当然上线前要么关闭Swagger,要么给文档再加一层独立的访问密码。
密码存储选择BCrypt。注册时用BCryptPasswordEncoder.encode()加密,登录时用它的matches()校验。注意BCrypt生成的密文每次不同但都能匹配,所以绝不能用自定义的“先MD5再加盐”方式,MD5对碰撞攻击缺少有效防范,BCrypt本身包含盐和代价因子,更安全。
验证码登录流程用Redis做短信验证码存储,手机号作为key,验证码作为value,5分钟过期。每次发送前要校验发送频率,60秒内不允许重复发送,防止被人恶意刷短信。
4.2 课程搜索与动态条件过滤
课程搜索不需要接入Elasticsearch那么重的搜索引擎,常规场景下由MySQL一个模糊查询加上几个结构化筛选条件就能完成。课程列表页需要支持按分类筛选、按课程形式筛选(线上或线下)、按价格排序、按销量排序、按关键词搜索课程名称或老师昵称。
在MyBatis-Plus中可以这样写:
java复制@Override
public IPage<CourseVO> searchCourse(CourseQueryDTO query) {
LambdaQueryWrapper<Course> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Course::getStatus, 1)
.eq(StringUtils.isNotBlank(query.getCategoryCode()),
Course::getCategoryCode, query.getCategoryCode())
.eq(query.getCourseType() != null,
Course::getCourseType, query.getCourseType())
.and(StringUtils.isNotBlank(query.getKeyword()), w ->
w.like(Course::getCourseName, query.getKeyword())
.or().like(Course::getSubtitle, query.getKeyword()));
wrapper.orderByDesc(Course::getSalesCount);
Page<Course> page = new Page<>(query.getPageNum(), query.getPageSize());
return courseMapper.selectCoursePage(page, wrapper);
}
如果使用条件构造器,所有的外部传参都是作为预编译参数传到SQL里,不存在字符串拼接SQL的问题,自然不会出现注入风险。但是动态排序字段如果要允许前端传入,最好单独做白名单映射,不要直接把字段名拼进order by里。
在列表查询时,不要把课程内容的大字段(如富文本详情)默认全查出来,否则接口会很慢。课程表中建议把内容拆成两个字段:introduction简短的简介直接查出来,detail_content富文本详情单独查询时再用selectCourseDetail获取。另一个实用做法是课程列表的覆盖索引查询,只查出课程ID,再用ID分批查详情,数据量上来后性能提升非常明显。
4.3 下单事务与幂等处理
课程包下订单在一个交易系统里是最需要谨慎处理的接口。设计上要防止四种情况:用户重复点击按钮导致重复下单,同一时刻超卖购买不可售课时包,订单状态被回退乱改,以及支付成功之后发课时失败导致的数据不一致。
下单接口的流程是:
- 校验课程包状态是否为上架
- 校验用户不是该课程老师本人
- 创建订单记录,状态待支付,生成唯一订单号
- 调用本地模拟支付,返回支付参数
这里的幂等方案是客户端在请求时携带一个requestId,后端基于(userId, packageId, requestId)先查是否已存在订单。如果没有再创建。更严谨的做法是为这个唯一键建立唯一索引,由数据库抛异常兜底。
订单号不能使用数据库自增ID直接暴露给用户,因为订单号会出现在支付流程和客服沟通场景,生成规则使用时间戳 + 随机数即可,也可以用MyBatis-Plus的雪花算法ID。我的订单号生成工具类采用了“yyMMddHHmmss + 6位随机数 + 2位随机来源”的格式,虽然并发极高时可能出现极端小概率重复,但增加唯一索引就足够保护数据了。
创建订单的核心方法必须加@Transactional,否则创建订单后一旦后续流程报错,会出现脏数据:
java复制@Transactional(rollbackFor = Exception.class)
public OrderVO createOrder(CreateOrderDTO dto) {
CoursePackage coursePackage = coursePackageMapper.selectById(dto.getPackageId());
if (coursePackage == null || coursePackage.getStatus() != 1) {
throw new BizException("课程包不存在或已下架");
}
if (coursePackage.getTeacherId().equals(dto.getUserId())) {
throw new BizException("不能购买自己发布的课程");
}
Order order = new Order();
order.setOrderNo(OrderNoGenerator.generate());
order.setUserId(dto.getUserId());
order.setPackageId(coursePackage.getId());
order.setCourseId(coursePackage.getCourseId());
order.setTeacherId(coursePackage.getTeacherId());
order.setTotalAmount(coursePackage.getPrice());
order.setPayAmount(coursePackage.getPrice());
order.setStatus(OrderStatusEnum.WAIT_PAY.getCode());
orderMapper.insert(order);
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(order, vo);
return vo;
}
用户点击支付后,前端会调起支付收银台。在本地demo中,我们可以写一个模拟支付接口,把订单状态更新为已支付,然后执行发课时逻辑。但生产对接微信或支付宝时,必须用异步回调来更新订单状态,不能说“我这边页面显示成功就成功了”,否则会因为网络回调问题造成金额和课时不一致。
支付回调处理要设计一个专门的服务方法,它的核心特征是天然要支持幂等:用户可能支付成功后点多次“查询订单”,或者支付平台重发了多次回调,代码必须保证重复回调不会重复加课时。判断逻辑要先查一次订单当前状态,如果已经是已支付,直接返回成功,不再执行后续发课时。
4.4 约课排课与自动提醒
约课和排课是整个系统交互最频繁的部分。
在老师端,老师需要维护“常用排课模板”,数据结构比较简单:teacher_schedule_template表记录teacher_id、weekday、start_time、end_time、是否开启。例如每周日全天。然后,当C端用户挑选课程后,通过传入的日期去解析出老师当天的所有可约时间段。解析过程用Java在内存里完成即可:先查出模板,排除掉老师设置的休息日,再剔除已经被预约的时间段。
用户点击约课并提交时,后端需要防止两个人同时选中同一个时间点。操作过程中加锁除了分布式锁或者同步锁之外,还可以用数据库条件更新实现悲观锁。简化版本可以设计schedule_time_slot表,初始把未来可约的时段生成好,每个时段有状态和version字段,约课时执行:
sql复制UPDATE schedule_time_slot
SET status = 1, user_id = #{userId}
WHERE id = #{slotId} AND status = 0
如果更新返回0,说明该时段已被别人抢走,提示用户换其他时间即可。这种原子更新的方案在单体应用里足够高效,且不需要额外引入分布式组件。
接着创建appointment记录和加入待上课状态。这一步不扣减课时,等到实际上课确认后再扣。很多初学同学在这里会踩坑:预约成功后立刻扣课时,等用户取消或者老师改期时才返还。这样做逻辑也行,但在出现“请假顺延”之类需求时,退款返还非常麻烦。统一改为“上课完成扣课时”后,取消预约根本不涉及金额和课时变化,系统简单很多。
提醒功能可以使用SpringBoot内置的@Scheduled定时任务,每分钟扫描一次明天有课但还没有被接收的预约记录,然后给用户推送服务号模板消息或者短信。如果项目部署了多个节点,需要给定时任务加分布式锁,否则每个节点都会执行一遍造成重复推送。我在项目初期使用Redis的setnx锁解决,key设置为task:remind_appointment,获取不到锁的节点直接跳过执行。
5. 上线前必须处理的代码细节
5.1 统一返回结构与全局异常
前后端分离之后,如果不约定接口返回格式,前端每次解析数据都要猜字段,非常容易出错。我定义了一个通用返回体:
json复制{
"code": 200,
"message": "success",
"data": {}
}
所有Controller的接口返回直接使用R<T>,并且所有业务异常都通过全局异常处理器封装返回该格式。不要使用Java自带的字段名success,因为布尔值的序列化和前端判断会有细节坑,统一使用code正数表示成功更加稳健。
全局异常处理器需要至少捕获三类:
- 业务异常:
BizException - 参数校验异常:
MethodArgumentNotValidException,通常在DTO字段上用@NotBlank、@NotNull注解 - 兜底Exception,避免错误堆栈直接暴露给前端的5xx
如果出现不可预期异常,一定要记录error日志上下文,包括请求路径和请求参数,不然线上出了问题完全没有线索。
SpringBoot项目里配置了Shiro或者Spring Security后,权限不足抛出的AccessDeniedException不会进入Controller层的异常处理器,所以要在SecurityConfig中单独设置authenticationEntryPoint和accessDeniedHandler,向前端返回标准JSON。这个坑不踩一遍很难自己发现。
5.2 文件上传和静态资源映射
上传图片,如老师头像、课程封面、认证资质,类型不复杂。在SpringBoot中上传文件主要涉及MultipartFile接收、校验大小和格式、上传到对象存储。
关键问题是限制上传大小。SpringBoot默认单次请求文件大小是1MB,如果不调大会直接报MaxUploadSizeExceededException。在配置文件中:
yaml复制spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 50MB
如果涉及老师上传整节课的视频资源,通常体量会比较大,几十MB甚至上百MB。简单的Multipart接口适合小文件,大文件推荐前端先做切片,后端逐片接收并等所有分片上传完成后再合并。由于这个平台一开始就锁定为兴趣类课程讲解和短视频演示,视频并不会特别大,所以在生产阶段我直接用分片上传方案:前端把文件切成每片5MB,后端每片生成临时文件,全部完成后调用合并接口,最后上传整个文件到MinIO。
静态资源映射方面,如果开发环境没有接入对象存储,又急着联调,就需要在WebMvcConfig里配置虚拟路径。避免为了给前端看一眼封面图写一堆硬代码。比如将磁盘目录映射到/files/**,注意生产环境绝对不要把业务静态文件落在单个节点本地。
5.3 多环境配置与打包启动
项目接近交付时,多环境配置非常重要。开发、测试、生产环境的数据源、Redis地址、MinIO地址都不同,靠人肉改配置文件非常容易出错。我把配置拆分如下:
code复制application.yml
application-dev.yml
application-test.yml
application-prod.yml
application.yml只保留公共部分,如SpringBoot应用名、MyBatis-Plus配置、日志级别。不同环境在同名的配置文件中覆盖各自的连接信息。启动时利用IDEA的运行参数或命令行指定profile:
bash复制java -jar homework-server.jar --spring.profiles.active=prod
考虑到部署环境不一定有外网和固定的运行时目录,对象存储地址等信息也放入环境变量读取,例如:
yaml复制minio:
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
access-key: ${MINIO_ACCESS_KEY:miniominio}
secret-key: ${MINIO_SECRET_KEY:miniominio}
前面示例中带默认值,这样本地搭建不会缺少配置,上线时在环境变量中注入生产值即可。这比把密钥直接写在application.yml并推送到Git仓库安全得多。
项目打包采用Maven构建,依赖SpringBoot Maven插件。mvn clean package -DskipTests构建后生成homework-server.jar,体积大概几十MB。部署方式我推荐直接服务器安装JDK运行,或者用Docker容器。Docker Desktop在开发机上调试很方便,Dockerfile写法大同小异:
dockerfile复制FROM openjdk:8-jre-alpine
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY target/homework-server.jar homework-server.jar
EXPOSE 8080
ENTRYPOINT ["java", "-Xms512m", "-Xmx512m", "-jar", "homework-server.jar", "--spring.profiles.active=prod"]
这里设置时区极重要。因为MySQL的DATETIME不带时区,容器默认是UTC时区,而业务系统约课时如果用时间字段,会出现“用户约了下午3点,落库是早上7点”这种诡异问题。所以在Dockerfile里必须加上时区设置,数据库连接串也能带上serverTimezone=Asia/Shanghai。
6. 实测踩坑与排障记录
6.1 常见运行问题速查
开发过程中遇到的问题五花八门,这里挑几个典型问题给出排查方向,适合收藏备查。
| 异常现象 | 根本原因 | 解决方案 |
|---|---|---|
| 启动类报循环依赖 | Service互相@Autowired,SpringBoot 2.6以后默认禁止循环依赖 | 重构调用关系,把公共查询拆到独立Service或通过构造器避免互相引用 |
| LocalDateTime返回给前端是数组 | Jackson默认不识别jsr310时间格式 | 在application.yml配置spring.jackson.date-format或使用@JsonFormat统一格式化 |
| 跨域导致前端请求失败 | 前后端分离开启CORS配置不完整 | 实现WebMvcConfigurer#addCorsMappings,配置allowedOriginPatterns、allowedHeaders、allowedMethods |
| MySQL查询中文乱码 | 数据库字符集不是utf8mb4 | 建库语句指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,连接串加characterEncoding=utf8 |
| Swagger加载报NPE | SpringFox版本和SpringBoot不匹配 | 切换到springdoc-openapi或者锁定依赖版本,不要用maven随便“拉最新” |
| 事务注解没生效 | Service方法被同类内部调用或方法非public | @Transactional要用在public方法,跨Service调用才会被AOP代理 |
| 文件上传报文件太大 | SpringBoot默认单请求1MB,限制没有调大 | 在配置文件中修改sprin.servlet.multipart.max-file-size |
| JWT登录后访问接口仍报401 | 自定义过滤器没有加载到SecurityFilterChain中 | 在SecurityConfig中addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) |
这里重点说一下循环依赖:SpringBoot 2.6及以后版本默认不允许循环依赖,你可能会遇到很多老项目升级后启动失败。不要看到报错就去把spring.main.allow-circular-references改成true,那只是掩盖问题。正确做法是想想A依赖B、B又依赖A这种情况能不能消除,比如把公共部分抽到C,让A和B都依赖C。
6.2 性能与安全优化建议
第一个被攻击的高发区是接口被刷。登录接口、注册接口、短信验证码接口如果没有限流,几天后你能在日志里看到一堆请求,甚至数据库垃圾数据暴涨。我在网关层面给这类接口设置了Redis计数器限流,每手机号每分钟只能登录失败5次,超过则锁定10分钟。虽然不能覆盖所有恶意行为,但至少挡掉最常见的扫号和撞库请求。
第二个是数据库查询慢。平台运营一段时间后预约记录会越来越多,如果没有按老师和时间建索引,后台看课时报表时查询会明显变慢。核心索引建议在appointment表建(teacher_id, start_time, status)联合索引,在订单表建(user_id, status, create_time)联合索引。下单时也要保证order_no有唯一索引。用EXPLAIN查看是否走索引就是一个好习惯。
第三个是SQL数据权限的问题。不正确的资源ID很容易被人通过修改路径参数越权查看或修改数据。比如用户A尝试把用户B的约课记录通过ID更新为自己完成。解决办法是所有数据操作前都要先判断当前登录人是否为该资源的归属人,或者限制在当前用户范围内操作。这个没有框架能帮你自动做,每个接口写的时候都要有意识。
性能优化方面,课程首页通常有大量读操作。Redis缓存可以解决一部分压力,但也要防止缓存穿透和缓存击穿。穿透指的是查询一个不存在的课程ID,每次都打到数据库。可以在Service加一层布隆过滤器过滤无效ID,或者如果查出来是空值也缓存几秒钟。击穿指某个热门课程缓存过期,瞬间有大量请求到数据库。简单做法是加互斥锁或把过期时间设置为一个基础时间加随机数。
6.3 从单体到可扩展的演进方向
发展到一定阶段后,单体应用会遇到一些瓶颈,但千万不要一上来就搞微服务和分布式事务。这个“兴趣家教平台”做完第一版后,如果业务量上涨,优先做的应该是:
- 引入Nacos或Apollo做配置中心,不再靠环境变量改配置
- 把短信、文件处理、消息推送等非核心流程异步化,使用SpringBoot整合的ActiveMQ或者RabbitMQ
- 将搜索从MySQL迁移至Elasticsearch,覆盖复杂标签筛选和“附近老师”地理检索
- 把订单支付和退款通过消息对账,避免网络抖动导致的状态不一致
这些都是在业务真正出现性能瓶颈或团队规模扩大的时候才需要。如果你的目标是写一个毕业设计或者一个业务demo,单体SpringBoot已经完全足够。反倒是把事务边界、权限模型、状态拼图和幂等设计做扎实,才是一个交易型项目最值钱的部分。
结尾:一点实打实的个人体会
做完这个项目,最大的感受是SpringBoot确实把Java后端开发的启动成本降得很低,但是业务系统的复杂度并没有因为框架变简单而减少。真正花时间的都是那些“框架之外”的事:资源权限归属、订单状态流转、金额精度、缓存一致性、部署时区。项目价值也是通过这些细节才真正体现出来。
如果让我给正在做类似项目的朋友一个建议,我会说:先别急着写代码,找一个真实的线下兴趣培训机构聊一聊,把“买课、约课、上课、扣课”这个闭环画成流程图,再用纸笔把核心表的字段填满,最后再打开IDEA创建SpringBoot工程。这样整体推进会顺畅很多。
另外一个经验是,不要追求把一个大而全的巨无霸平台写出来。这个项目目前只做了基础的“兴趣课交易闭环”,却已经足够覆盖权限、文件、消息、支付、任务调度等主流后端场景。后续哪怕想继续加一个拼团功能或者教培行业的分销玩法,也都是在现有订单模型上扩展,地基没歪,上层怎么盖都不慌。
