前阵子帮朋友做了一套婚纱影楼的线上服务平台,技术栈就是最常见的 SpringBoot + Vue + MySQL,前后端完全分离开发。整套系统把影楼线下最头疼的档期预约、订单管理、选片售后这三块核心流程全部搬到了线上,测试环境跑了两周,基本稳定,目前在他们门店里用着正常。
为什么把这个项目拿出来写?因为它太典型了。市面上大量 SpringBoot 相关的管理系统项目,说到底就是增删改查的堆叠,做完了对业务的理解还是浮在表面。而婚纱影楼平台不一样,它天然带着状态机、并发冲突、金额计算这类真实业务难点——比如一个化妆档期被两个人同时预定怎么办,订单从待付定金到已付尾款中间要经过几道流程,客户选片加了 20 张精修费用怎么算。把这些逻辑理清楚了,SpringBoot 的理解才算真正落地,面试的时候也有东西可以讲。
这篇不打算铺太多理论,重点说清楚三件事:一是整个平台的需求和模块是怎么拆出来的,二是数据库和核心接口是怎么设计的,三是联调和部署阶段踩过哪些坑。想拿这套思路做参考的同学,可以照着搭一版,也可以挑里面的模块充实自己的项目。
1. 项目背景与需求拆解
1.1 婚纱影楼业务的真实痛点
婚纱影楼的线下流程其实很长:门店咨询、选套餐、定档期、拍摄、选片、精修、出件,中间还要穿插钱款收取和确认。传统作坊式管理靠的是门店的登记本加微信,问题非常明显:
第一,档期用表格手工登记,一个热门周末被两对新人同时预定的情况经常发生,核对起来费劲。第二,订单状态客户完全不清楚,几乎每天都有"我婚纱照现在到哪一步了"的询问,客服得反复翻聊天记录。第三,选片必须到店,销售和修片师来回传照片编号,沟通成本极高。第四,月底对账靠人工,营收和回款对不上是常有的事。
所以做这套平台,本质上不是做一个"好看的后台",而是把容易出乱子的线下环节线上化,让每一条业务流程都有据可查。需求拆解不能只看表面功能,要围绕业务流转来梳理。我把平台拆成三个核心流程:预约流程(浏览套餐到锁定档期)、订单流程(支付、拍摄、选片、精修、出件)、售后流程(评价、客片归档)。这三个流程串起来,整个店的运营链路就完整了。
1.2 功能模块与角色权限划分
系统涉及的角色有五类:客户、店长、摄影师、修片师、财务。角色不需要设计得很复杂,关键是各自的操作边界要清楚。我直接按业务流来划分模块,而不是按角色来划分,这样数据库和接口设计会更清晰。
| 角色 | 核心操作 | 对应模块 |
|---|---|---|
| 客户 | 注册登录、浏览套餐、在线预约、支付定金、查看进度、选片、评价 | 用户中心、预约、订单、选片 |
| 店长 | 套餐上架、预约确认、员工排班、数据查看 | 套餐管理、档期管理、排班 |
| 摄影师/化妆师 | 查看当天排期 | 排班管理 |
| 修片师 | 接收选片任务、上传精修成片 | 选片管理 |
| 财务 | 订单核对、营收统计 | 订单管理、数据统计 |
核心模块包括:用户中心、套餐管理、档期预约、订单管理、选片管理、员工排班、消息通知、数据统计。其中档期预约和订单管理是最重要的两个模块,后面代码实现里花时间最多的地方也在这两块。
预约模块要处理的不是简单的插入一条记录,而是要保证同一个摄影档期不能被两个人同时占用;订单模块要处理的不只是金额汇总,还有状态流转和退款场景。这两块做扎实了,整个平台的骨架就立住了,其余的模块基本就是标准 CRUD。
2. 技术选型与总体架构设计
2.1 为什么选 SpringBoot + MyBatis-Plus + Vue
技术选型时纠结过要不要上 Spring Cloud,后来想清楚一个道理:影楼平台是典型的单体业务系统,没有高并发、没有海量数据,单体架构完全够用,上微服务就是给自己找麻烦。最终定的技术栈是 SpringBoot 2.7.14 + JDK8 + MyBatis-Plus 3.5.x + MySQL 8.0 + Redis + Vue2 + Element UI。
| 技术组件 | 版本选择 | 选型理由 |
|---|---|---|
| SpringBoot | 2.7.14 | JDK8 适配好,生态成熟,踩坑资料多 |
| MyBatis-Plus | 3.5.x | 单表 CRUD 免写 SQL,分页插件好用 |
| MySQL | 8.0 | 主流稳定,decimal 和 JSON 类型支持好 |
| Redis | 6.x | 缓存 + 分布式锁处理档期并发 |
| Vue + Element UI | 2.x | 开发效率高,管理端表格表单组件齐全 |
这里特别提醒版本问题。SpringBoot 3.x 已经发布很久了,但它是基于 JDK17 的,如果你的开发环境还是 JDK8,或者项目里依赖的部分老版本库没有适配 JDK17,硬上 Spring Boot 3 会遇到一堆兼容问题。这个项目我最后选了 2.7.14,它是 2.x 的最后一个版本,稳定性和兼容性都经过大量项目验证。
再说说自动装配原理,写代码不一定要手动操作,但理解它对排查问题很有帮助。SpringBoot 启动类上的 @SpringBootApplication 其实是三个注解的组合:@SpringBootConfiguration 标记配置类,@EnableAutoConfiguration 开启自动配置,@ComponentScan 扫描当前包及子包下的 Bean。自动配置的核心是 AutoConfigurationImportSelector,它在启动时读取 META-INF 下的 spring.factories(SpringBoot 3.x 改成 AutoConfiguration.imports)文件,加载自动配置类,再通过 @ConditionalOnClass、@ConditionalOnMissingBean 这类条件注解决定哪些 Bean 真正生效。比如你引入了 spring-boot-starter-web,但没引入数据库驱动,DataSource 的自动配置就不会生效,逻辑全在那一堆条件判断里。
2.2 项目分层与结构设计
项目结构保持传统的四层架构:Controller 接收请求和参数校验,Service 写业务逻辑和事务控制,Mapper 负责数据库交互,entity/dto/vo 分别对应数据库实体、入参对象和出参对象,再配合统一的 Result 返回类和全局异常处理器。
代码目录结构示例:
src/main/java ├── com.studio.platform │ ├── controller │ ├── service │ │ └── impl │ ├── mapper │ ├── entity │ ├── dto │ ├── vo │ ├── config │ ├── common │ │ ├── Result.java │ │ ├── BusinessException.java │ │ └── GlobalExceptionHandler.java │ └── util src/main/resources ├── mapper ├── application.yml └── banner.txtBanner.txt 是 SpringBoot 启动时控制台打印的字符画,属于锦上添花的部分。网上有在线生成器,复制粘贴就能用,启动日志好看一点,项目展示时观感会好不少。
为什么要强调分层规范?因为业务状态流转多,如果 Controller 里直接写 SQL 业务逻辑,后面改一个支付状态就要动接口层,测试起来也麻烦。把业务逻辑全部下沉到 Service,Controller 只做参数接收、调用、结果包装,后期加功能时改动的范围会小很多。MyBatis 的 SQL 写在 resources/mapper 目录下的 XML 文件里,和 Java 代码解耦,复杂查询也好维护。
3. 数据库设计与核心表结构
3.1 核心业务表梳理
数据库设计我花了整整一天,因为它是整个项目的地基。核心表控制在十张以内,避免过度设计:
- user:客户表,存手机号、昵称、密码(BCrypt 加密)
- employee:员工表,存店长、摄影师、化妆师、修片师,用岗位字段区分
- package_info:套餐表,包含套餐名、价格、服务内容、封面图
- appointment:预约档期表,记录客户、摄影师、日期、时段、状态
- order_main:订单主表,关联用户和预约,存总金额、状态
- order_detail:订单明细表,记录套餐费用、加片费用、后期费用等分类金额
- selection:选片表,记录客户选中的照片编号和精修标记
- photo_work:客片表,存储拍摄原片、精修成片的文件路径
- review:评价表,关联订单,存评分和文字内容
- coupon:优惠券表,支持满减逻辑
user 和 employee 分开存,而不是合并成一个账号表,是因为两者字段差异明显。客户只需要手机号和昵称,员工需要岗位、入职时间、工作状态,混在一张表里会有大量空字段,查询时还要反复判断类型,没必要。账号登录方面,客户走手机号密码登录,员工走后端独立登录接口,权限用角色字段控制,项目规模不大不引入 Spring Security。
订单相关坚持拆成主表和明细表,主要原因是需要支持部分退款和二次消费。客户拍完套餐后选了加片精修,这些费用挂在订单明细里,明细表和主表独立,统计各项营收时直接按类别汇总,不需要解析字符串。
3.2 关键表字段设计与订单状态流转
套餐表字段不难设计,难在预约档期表。预约表的核心字段包括:customer_id(预约人)、employee_id(摄影师)、appointment_date(拍摄日期)、time_slot(时段,如 09:00-12:00)、status(待确认、已确认、已完成、已取消)。为了查档期冲突,我在 employee_id + appointment_date + time_slot 这三个字段上建了联合唯一索引,让数据库层面兜底,防止同一个摄影师同一时段被重复预约。
订单状态我定义了一个枚举类,避免代码里散落一堆魔法数字:
public enum OrderStatus { UNPAID(0, "待支付定金"), DEPOSIT_PAID(1, "定金已支付"), SHOOTING_DONE(2, "拍摄完成"), SELECTING(3, "待选片"), RETOUCHING(4, "精修中"), DELIVERED(5, "已出件"), COMPLETED(6, "已完成"), CANCELLED(7, "已取消"); }状态机的意义在于:客户端提交"变更订单为已完成"的请求时,后端要先判断当前状态是否允许直接跳变。比如一笔订单还在待支付定金阶段,是不能直接跑到精修中的。我在 Service 层写了一个状态校验方法,变更前先读取当前订单状态和目标状态,不在状态机允许的转换列表里就直接抛业务异常。这样做的好处是,不管前端怎么"乱点",后端状态不会错乱,运营数据是可信的。
4. 核心模块实现细节
4.1 套餐展示与档期预约实现
套餐展示用 MyBatis-Plus 的分页插件就能搞定。先在配置类里注册分页拦截器,然后 Service 里调用 Page 对象查列表。这东西写起来很快,数据量不大时性能完全够用。
档期预约是实现复杂度第一关,也是整个项目里并发逻辑最多的部分。预约接口做的事情拆开有三步:判断用户是否有未完成的预约、检查该摄影师当天该时段是否被占用、创建预约记录。三步必须放在一个事务里,而且档期检查要防并发。
最简单的防并发做法是给预约表加联合唯一索引,数据库层面保证同一个摄影师同一时段只能有一条记录,出现重复插入时捕获 DuplicateKeyException 并提示友好信息。项目里接了 Redis,也可以加一个分布式锁,key 设计成 appointment:pre:employeeId:date:slot,加锁成功才允许预约。
@Transactional(rollbackFor = Exception.class) public Result createAppointment(AppointmentCreateDTO dto) { // 1. 校验用户是否存在未完成预约 // 2. 检查档期冲突 LambdaQueryWrapper<Appointment> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Appointment::getEmployeeId, dto.getEmployeeId()) .eq(Appointment::getAppointmentDate, dto.getAppointmentDate()) .eq(Appointment::getTimeSlot, dto.getTimeSlot()) .eq(Appointment::getStatus, 1); // 已确认状态 if (appointmentMapper.selectCount(wrapper) > 0) { throw new BusinessException("该时段已被预约,请更换时间"); } // 3. 保存预约记录 appointmentMapper.insert(buildAppointment(dto)); return Result.success(); }这里有个重要经验:不要把"检查重复预约"单独抽出一个方法,再在调用处加锁,而是放在创建方法内部,同时用数据库唯一索引兜底,否则高并发下两个请求同时通过检查,就会出现数据不一致。我最后是两套方案一起上:Redis 锁挡掉绝大多数并发,唯一索引做最后一道防线,实测下来很稳。
4.2 订单创建与金额计算
预约确认之后就要创建订单。订单创建逻辑不算复杂,但金额计算是个容易踩坑的点。影楼金额涉及套餐原价、优惠券减免、加片费用、精修费用,累计过程中如果用 double 或 float 计算,浮点误差会在累加多笔后放大,对账时出现几分钱的差异很难查。
所以所有金额字段在数据库里用 decimal,Java 代码里用 BigDecimal。计算时统一使用 BigDecimal 的 add、subtract、multiply 方法,不要中间转 doubleValue。
BigDecimal totalAmount = packagePrice.add(extraAmount).subtract(discountAmount); if (totalAmount.compareTo(BigDecimal.ZERO) < 0) { throw new BusinessException("订单金额不能为负数"); }订单创建和预约状态更新放在同一个事务里。创建成功之后要把预约状态从待确认改成已确认,同时扣减对应档期名额。如果这一步分开执行,客户付了定金但预约还是待确认,店铺的档期表就对不上。事务控制的意义就是用框架的代价最小的方式,保证这些多表操作要么全部成功,要么全部回滚。
4.3 选片管理与文件上传
选片模块是整个平台里最贴近业务特色的功能。客户拍摄完成后进入选片阶段,后端把照片列表展示给客户,客户勾选要精修的照片编号,比如选了 20 张精修,这个信息进入选片表,修片师在后台看到任务后逐个上传精修成片。
上传功能用 SpringBoot 内置的 MultipartFile 就能实现,关键在配置:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB如果不配置这两个参数,SpringBoot 默认单文件 1MB,婚纱照原片根本传不上去。文件存储建议按日期分目录,存放到服务器本地磁盘的独立目录,不要把文件放在 resources 目录下,否则重新打包部署时文件会被清理。数据库里存的是相对路径,前端访问时通过文件映射接口读取。
选片数据保存时要注意:照片编号是字符串数组,我用逗号拼接存进 selection 表的一列里。虽然不符合严格的数据库第一范式,但考虑到选片明细不会做复杂的条件查询,解析出来展示即可,这样插入和读取都更简单。如果未来要做选片统计,再考虑拆成明细表。
5. 运营支撑与细节优化
5.1 定时任务与提醒机制
影楼运营里有个刚需功能:拍了照片的客户迟迟不来选片,影响整个出片周期。传统店靠人工微信提醒,做成平台之后可以用定时任务自动提醒。SpringBoot 提供了非常轻量的方案:启动类上加 @EnableScheduling,提醒方法上标记 @Scheduled(cron = "0 0 9 * * ?"),每天上午 9 点跑一次,查询所有拍摄完成且超过 7 天未选片的订单,给客户发送站内信和短信提醒。
选这个方案的考量很简单:项目规模小,不需要分布式调度。Spring Task 是单机调度,完全够用。如果后面项目要部署多台实例,同一个定时任务会在每个实例上重复执行,到那时再上 Quartz 或者用 xxl-job 也不迟,当前阶段不改架构。
5.2 前后端联调与静态资源整合部署
前后端分离开发阶段,接口联调最烦的就是跨域。开发环境下 Vue 跑在 8080,后端跑在 9000,浏览器直接发请求会被 CORS 拦截。我在后端加了一个 CORS 配置类,允许指定来源、放开需要的请求头和方法,联调起来顺畅很多,这个配置在生产环境需要收紧,不要用允许所有来源的通配写法。
还有一种常见的整合方式:把 Vue 打包后的 dist 目录复制到 SpringBoot 的 resources/static 下面,让后端 jar 包同时充当静态资源服务器。这样部署时只需要启动一个 Java 进程,省掉了 Nginx 配置。但 Vue 如果用 history 路由模式,刷新非首页路径时会出现 404,因为后端没有对应的路由。解决办法是配置一个路由转发:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }这段配置对单页应用很关键。不处理的话,演示时点进详情页刷新一下,页面直接白屏,非常影响观感。我部署阶段就因为白屏问题排查了半天,最后定位到是 Vue Router 的 history 模式引起,加上这段转发就好了。
6. 常见问题与排查技巧实录
6.1 预约并发冲突与数据一致性
实测时我用 Jmeter 开了 50 个线程同时预约同一个摄影师的同一时段,跑第一遍就出现了两条成功记录,原因就是检查代码在事务之外。排查思路很简单:先看应用日志,两个请求几乎同时进入了档期检查方法,都查到 count 为 0,然后都插入成功。数据库唯一索引当时没生效,是因为我漏建了联合索引。
解决措施分两层:第一层在预约表上补了 employee_id、appointment_date、time_slot 的联合唯一索引,让数据库在极端并发下拒绝重复;第二层引入 Redis setnx 锁,抢锁失败的请求直接返回"该时段正在被预约中,请稍后重试"。双重防护之后并发测试才完全通过。
这个经历给我的教训是:靠代码层面的 check-then-act 永远有窗口期,数据库约束才是最后的防线。单机项目也要有这个意识,否则上线后被用户并发操作打一次就得加班。
6.2 前后端联调中的常见问题
高频问题之一:LocalDateTime 字段返回给前端变成一串数字。SpringBoot 默认序列化 LocalDateTime 时不带格式,前端拿到的是时间戳数组,非常难处理。解决办法是在全局配置里统一格式化:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8高频问题之二:图片上传报 413。本地测试正常,部署到服务器后用 Nginx 反代,上传超过 Nginx 默认的 client_max_body_size 限制就返回 413。Nginx 默认只有 1MB,改一下配置:
client_max_body_size 100m;花样多但本质都是"中间层限制",排查时先看自己后端有没有限制,再看反代层有没有限制,一层层排除。
6.3 部署与配置阶段的其他坑
- 端口占用:SpringBoot 默认 8080,服务器上如果跑了其他 Java 进程,启动直接抛端口占用异常。用 netstat -tunlp 定位占用进程,或者直接在 application.yml 里改 server.port。
- 数据库时区问题:MySQL 连接串加上 serverTimezone=Asia/Shanghai,否则日期时间会比北京时间少 8 小时。
- 服务器系统时区:java -jar 启动时加 -Duser.timezone=GMT+8,保证定时任务在正确的时间点触发。
- 打包跳过测试:mvn clean package -Dmaven.test.skip=true,避免单测环境连不上测试库导致打包失败。
这些坑单个看都不难,但叠加在一起,第一次部署的人很容易被折腾一晚上。我的习惯是把所有环境相关的配置外置到 application.yml,部署时不重新打包,直接用新配置覆盖 jar 包内的默认配置,省事很多。
7. 经验心得与后续扩展
做这个项目最大的感受是:SpringBoot 本身其实不是项目难点,真正花心思的全在业务约束上。档期冲突怎么防、状态流转怎么卡、金额计算用什么类型、并发时怎么保证数据不重,这些才是面试时会被追问的地方,也是把项目从"增删改查演示"升级成"能真实上线的业务系统"的分水岭。
如果后面继续扩展,方向很多:小程序端做一个客户预约入口,短信通知接入第三方服务,选片功能升级成在线对比和批量勾选,数据统计模块再细化到每个摄影师的产出排行。技术上都是增量,思路是相通的——先把业务流转理清楚,再把每个流转节点用代码给兜住。
最后分享一个实际开发里的小技巧:项目里所有查询接口返回值统一包一层 Result,接口报错时也返回正常 HTTP 状态码,业务错误码放在 Result 里的 code 字段。这样前端拦截器只需要处理业务码,不用去分辨 HTTP 500 和 200,前后端联调效率能提高不少。这个小习惯是我在多个项目里一直坚持的,确实能少踩很多坑。