
前阵子帮朋友做了一套婚纱影楼的线上服务平台技术栈就是最常见的 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。技术组件版本选择选型理由SpringBoot2.7.14JDK8 适配好生态成熟踩坑资料多MyBatis-Plus3.5.x单表 CRUD 免写 SQL分页插件好用MySQL8.0主流稳定decimal 和 JSON 类型支持好Redis6.x缓存 分布式锁处理档期并发Vue Element UI2.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.factoriesSpringBoot 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 业务逻辑后面改一个支付状态就要动接口层测试起来也麻烦。把业务逻辑全部下沉到 ServiceController 只做参数接收、调用、结果包装后期加功能时改动的范围会小很多。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. 检查档期冲突 LambdaQueryWrapperAppointment 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 计算浮点误差会在累加多笔后放大对账时出现几分钱的差异很难查。所以所有金额字段在数据库里用 decimalJava 代码里用 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: GMT8高频问题之二图片上传报 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 连接串加上 serverTimezoneAsia/Shanghai否则日期时间会比北京时间少 8 小时。服务器系统时区java -jar 启动时加 -Duser.timezoneGMT8保证定时任务在正确的时间点触发。打包跳过测试mvn clean package -Dmaven.test.skiptrue避免单测环境连不上测试库导致打包失败。这些坑单个看都不难但叠加在一起第一次部署的人很容易被折腾一晚上。我的习惯是把所有环境相关的配置外置到 application.yml部署时不重新打包直接用新配置覆盖 jar 包内的默认配置省事很多。7. 经验心得与后续扩展做这个项目最大的感受是SpringBoot 本身其实不是项目难点真正花心思的全在业务约束上。档期冲突怎么防、状态流转怎么卡、金额计算用什么类型、并发时怎么保证数据不重这些才是面试时会被追问的地方也是把项目从增删改查演示升级成能真实上线的业务系统的分水岭。如果后面继续扩展方向很多小程序端做一个客户预约入口短信通知接入第三方服务选片功能升级成在线对比和批量勾选数据统计模块再细化到每个摄影师的产出排行。技术上都是增量思路是相通的——先把业务流转理清楚再把每个流转节点用代码给兜住。最后分享一个实际开发里的小技巧项目里所有查询接口返回值统一包一层 Result接口报错时也返回正常 HTTP 状态码业务错误码放在 Result 里的 code 字段。这样前端拦截器只需要处理业务码不用去分辨 HTTP 500 和 200前后端联调效率能提高不少。这个小习惯是我在多个项目里一直坚持的确实能少踩很多坑。