做婚纱摄影预约系统这件事最早是帮一个朋友的工作室搭后台。他们的业务模式很传统客户打电话或者加微信咨询客服把套餐发过去然后手工在Excel里排摄影师档期再电话确认。单量少的时候还行旺季一到漏排、撞期、客户到店发现摄影师档期对不上各种问题全冒出来。后来我基于Spring Boot和Vue完整做了一套前后端分离的婚纱照摄影预约系统也就是这个代号dbx29的项目把客户预约、摄影师排期、订单状态管理、后台数据统计全跑通了。这篇文把我从技术选型、数据库设计到核心功能实现、部署上线、踩坑排查的完整过程写出来正在做毕设、课程设计或者想接同类型外包项目的同学可以直接当参考。1. 技术选型不能拍脑袋前后端分离方案的取舍思路1.1 为什么锁定Spring Boot Vue这个组合现在做这种中小型业务系统可选的技术栈其实很多。有人用Python写FastAPI有人用Node.js搞全栈也有人图省事直接用若依这类脚手架二次开发。我最后选了Spring Boot Vue不是因为它最时髦而是这个组合在业务系统场景下最稳、资料最多、排查问题最容易。先说后端。婚纱摄影预约系统不算高并发项目核心是业务逻辑可靠订单状态要流转正确、档期不能重复被约、摄影师和套裨的数据关联不能出错。Spring Boot的生态把这些问题都覆盖得很好MyBatis-Plus做数据访问层非常省事Spring Security或者拦截器加JWT做登录认证也成熟最关键的是事务管理能力强——预约下单涉及订单表插入、档期状态更新、套餐数量扣减这些操作必须放在一个事务里Spring的Transactional用起来简单又可靠。前端选Vue则是因为它的上手成本和团队熟悉度。我实际开发中用过Vue2也用过Vue3这次直接用Vue3 Vite Element Plus的组合。Vite的启动速度比Webpack时代快一个量级Element Plus的表格、表单、日期选择器、弹窗这些组件做后台管理页面非常顺手基本就是开箱即用。对做管理系统来说你不希望在前端基建上花太多时间组件库能帮你把80%的界面搭完。1.2 前后端分离到底带来了什么这个系统我没有做成传统的服务端渲染页面而是彻底前后端分离。前端跑在Nginx上负责页面展示和交互后端跑在Tomcat上只提供RESTful接口。前后端分离的核心价值在于职责边界清晰后端只和JSON打交道前端只和接口打交道。摄影师管理、套餐管理、订单管理这些页面前端通过Axios调接口拿数据渲染后端完全不关心页面长什么样只保证接口的输入输出稳定。这样带来两个直接的好处前后端可以并行开发我定义好接口文档前端开工渲染页面后端专注业务逻辑工期能压缩一半。后续如果要做小程序端或者移动端后端接口基本不用改动只需把接口暴露好就行。当然前后端分离也带来了一些问题最典型的就是跨域和Token认证这些我后面会单独讲。如果你打算做的是一个纯内网使用的简单系统没必要硬上前后端分离分开容易联调的时候跨域和鉴权能让你多熬夜好几天。但既然选择了这条路把CORS配置和JWT方案搞透就是基本功。1.3 项目目录规划约定优于配置项目结构我按通常的工程习惯来安排具体分成这样后端标准Maven结构controller/service/mapper/entity/config/common分包。前端Vue标准结构views里按业务模块建目录比如admin、user、photographerapi目录集中放接口调用方法router里配置路由store用Pinia保存登录状态等全局数据。接口路径统一以/api开头比如/api/user/login、/api/order/create这样前端代理和后台权限拦截都比较方便。有一点值得强调不要所有接口都写在UserController里。我见过不少初学者把登录、订单、套餐查询全塞进一个Controller一个Java文件几百行维护起来非常痛苦。这个项目的做法是按资源划分UserController、PhotographerController、PackageController、ScheduleController、OrderController、ReviewController各管各的出了问题定位也快。2. 数据库建模把摄影师、档期和订单的关系理顺2.1 实体关系梳理与核心业务闭环做数据库设计之前我先把业务理了一遍。婚纱摄影预约系统的核心用户有两种普通用户来预约拍照的客户和管理员工作室内部人员。业务链条其实不复杂客户浏览摄影师和套餐 → 选定摄影师后查看可预约档期 → 提交订单 → 管理员在后台确认订单 → 拍摄完成后客户评价。这个链条对应到数据库就是这几张核心表用户表存系统用户用role字段区分是普通用户还是管理员避免单独建一张管理员表。摄影师表存储摄影师基本资料、擅长风格、封面照片地址、介绍文案。套餐表每个摄影师可以关联多个套餐套餐里包含照片数量、服装套数、服务时长等信息。档期表这是预约系统的核心记录摄影师某一天某个时间段是否已被预约通过status字段区分0表示空闲1表示已被锁定。订单表把用户、摄影师、套餐、档期串联起来记录订单状态。评价表用户拍摄完成后可以对订单进行评价方便其他用户参考。这六张表之间的关系很直观一个摄影师拥有多个档期和多个套餐一个用户产生多笔订单一笔订单对应一个套餐和一个档期一个订单最终产生一条评价。2.2 核心表字段设计与建表语句当时设计的时候有几处字段我特别留意。首先是用户表密码字段我存的不是明文而是用BCrypt加密后的字符串这是底线问题哪怕系统再小也不能把明文密码存进数据库。其次是档期表我加了一个版本号字段version用来处理并发预约的时间冲突后面讲预约逻辑的时候会细说。以下是系统里最重要的三张表的创建脚本你可以直接参考字段命名和类型选择CREATE TABLE photographer ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 摄影师姓名, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, specialty_tags varchar(255) DEFAULT NULL COMMENT 擅长风格逗号分隔, intro text COMMENT 个人简介, status tinyint(1) DEFAULT 1 COMMENT 是否上架0下架1上架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT摄影师表; CREATE TABLE schedule ( id bigint(20) NOT NULL AUTO_INCREMENT, photographer_id bigint(20) NOT NULL COMMENT 摄影师ID, work_date date NOT NULL COMMENT 工作日期, time_slot varchar(20) NOT NULL COMMENT 时间段例如09:00-12:00, status tinyint(1) DEFAULT 0 COMMENT 0空闲 1已占用, version int(11) DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_photo_date_slot (photographer_id,work_date,time_slot) COMMENT 同一摄影师同一时段不重复 ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT档期表; CREATE TABLE booking_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL, photographer_id bigint(20) NOT NULL, package_id bigint(20) NOT NULL, schedule_id bigint(20) NOT NULL, amount decimal(10,2) NOT NULL COMMENT 订单金额, status tinyint(1) DEFAULT 0 COMMENT 0待确认 1已确认 2已完成 3已取消, remark varchar(255) DEFAULT NULL COMMENT 客户备注, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT订单表;这里有几个设计细节值得展开说一下**唯一索引uk_photo_date_slot**是防档期重复的数据库层面兜底。业务代码可以做各种判断但如果有两个请求同时进来仅靠代码判断是存在漏洞的唯一索引就是最后一道关卡同一摄影师的一个具体日期时间段物理上不可能出现两条记录。订单号用独立字段而不是用自增ID因为自增ID会暴露业务量而且不同表之间容易出现混淆。我生成订单号的规则是yyyyMMddHHmmss加6位随机数保证在同一个时间点不会撞车。金额字段用decimal类型而不是double这是几乎所有涉及钱的项目都踩过的坑。浮点数在加减乘除时会有精度丢失婚纱旅拍套餐动不动就是几千上万的金额精度出问题会非常难看。2.3 索引设计与后续扩展预留这个系统还涉及用户操作日志、管理员操作记录、摄影师作品集等扩展功能我先在数据库设计里预留了一些字段。比如摄影师表加works字段换成单独的作品表后续要扩充功能就能在现有设计上平滑扩展。索引这一块除了主键索引和唯一索引我针对高频查询场景做了补充booking_order表上user_id加普通索引因为用户查询自己订单走WHERE user_id ?没有索引的话数据量一大就会全表扫描schedule表上photographer_id加索引因为前端查看摄影师档期非常频繁。3. 从登录到下单核心功能逐段实现与细节解析3.1 登录认证与JWT全流程认证方案我选了JWT而不是传统的Session。原因很简单前后端分离之后Session的维护成本比较高如果部署的时候前端后端不在同一个域名下Cookie跨域更是麻烦。JWT是无状态的服务器不用保存任何会话信息客户端把Token带上就能验。完整的登录流程是这样的前端把用户名和密码通过POST请求发送到/api/user/login。后端拿到请求后先根据用户名查出用户用BCrypt校验密码是否匹配。校验通过后生成一个JWT字符串返回给前端。前端把Token存到localStorage里Axios请求拦截器每次在请求头里带上Authorization: Bearer token。后端写一个拦截器拦截需要认证的接口验证Token有效才放行。后端的拦截器代码大致长这样public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 if (request.getRequestURI().contains(/api/user/login) || request.getRequestURI().contains(/api/user/register)) { return true; } // 从请求头获取token String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } if (token null || token.isEmpty()) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } // 校验token有效性 try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\token无效或已过期\}); return false; } } }这里有个很实用的细节登录接口要把用户角色也写进Token里。这样后端接口做权限控制时只需要从Token里解析出role字段判断是不是管理员不需要每次打数据库查用户角色效率高很多。前端那边对应的操作是响应拦截器里如果遇到401状态码就清掉本地的Token然后跳转到登录页。这个逻辑必须写完整否则用户登录过期后一直停留在当前页面接口还一直报401体验非常差。实际开发中我遇到过一个比较隐蔽的问题Token过期时间设置得太长。我一开始把过期时间设成了7天用户改密码后旧Token依然有效这对后台管理系统来说是不可接受的。后来我在改密码的接口里引入了Token黑名单机制以应对这种情况。如果嫌麻烦直接把过期时间压到2小时然后让前端做自动续期这也是常见的做法。3.2 档期管理时间冲突处理是绝对核心这个系统的核心业务是什么不是登录不是套餐管理而是档期管理。摄影师的工作时间就那些能不能预约成功取决于档期有没有被占掉。这块如果做不好这个系统上线就是灾难。我做档期管理用了两层方案兜底。第一层是查询时的状态过滤。用户查看某个摄影师的档期前端传photographerId、startDate和endDate后端查数据库时只返回status 0空闲的记录。这样把已经被占用的档期直接过滤掉用户看不到就不会选到。第二层是下单时的乐观锁更新。这是防止并发的关键。假如两个用户同时看到了某个档期为空闲同时提交了预约请求如果不加控制两个请求都读到status 0然后都把档期改成1这就出问题了。但是用乐观锁的机制先把version字段读出来更新的时候带上version条件Transactional public boolean bookSchedule(Long scheduleId, Long userId, Long photographerId, Long packageId) { // 1. 查询档期 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule.getStatus() 1) { throw new RuntimeException(该档期已被预约请重新选择); } // 2. 更新档期状态带上version条件 int updateCount scheduleMapper.updateStatusWithVersion(scheduleId, schedule.getVersion()); if (updateCount 0) { throw new RuntimeException(手速太快了该档期刚刚被别人约走); } // 3. 创建订单 BookingOrder order new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setPhotographerId(photographerId); order.setPackageId(packageId); order.setScheduleId(scheduleId); order.setStatus(0); order.setAmount(packageMapper.selectById(packageId).getPrice()); orderMapper.insert(order); return true; }对应的MyBatis-Plus的更新语句核心是这一段update idupdateStatusWithVersion UPDATE schedule SET status 1, version version 1 WHERE id #{scheduleId} AND version #{version} /update这里的关键在于更新语句影响的行数。如果两个请求同时进来version都是0第一个请求执行更新时版本号匹配成功影响行数为1第二个请求执行更新时version已经变成1了WHERE version 0匹配不到记录影响行数为0那就让第二个请求提示用户重新选档期。这就是乐观锁的整个思路。注意Transactional必须加在service层的bookSchedule方法上因为更新档期和创建订单是两个写操作必须要一起成功或者一起回滚。否则会出现档期改成了已预约但订单没创建成功的脏数据。3.3 订单状态机设计婚纱摄影订单的状态流转其实比想象中复杂。它不是简单的用户下单→完成中间涉及商家确认、拍摄完成、用户评价等环节。我当时把订单状态定义成了这几个0待商家确认。用户提交预约后进入这个状态。1已确认待拍摄。商家在后台看到订单确认排放档期无误后点击确认。2已完成。拍摄完成后商家把订单标记为完成。3已取消。用户或商家主动取消订单。为什么需要待商家确认这个状态因为档期虽然是用户自选的但工作室可能需要根据摄影师空档微调或者客户备注里有一些特殊要求商家需要二次确认。另外支付流程在实际业务中可以放在确认之前或者之后。我这个版本先做的是预约占位到店支付所以待确认是一个比较稳妥的状态。前端页面上根据状态值决定按钮的显示状态为0时用户看到的是等待确认中状态为1时用户看到的是拍摄日期倒计时状态为2时用户可以点击去评价状态为3时整单置灰。3.4 后台管理端功能落点后台管理端我用Element Plus做了几块页面订单管理页表格展示所有订单按状态筛选支持单条订单的确认、取消操作。摄影师管理页维护摄影师列表上下架摄影师编辑资料。档期管理页按周视图展示某个摄影师的档期格子可以直接在日历上标记某个时间段不可约。套餐管理页对套餐的增删改查封面图上传用的是Element Plus的Upload组件配合后端文件接口。数据统计页用ECharts画了柱状图和饼图统计每周订单量、套餐受欢迎程度。后台的几个列表页都涉及分页查询。我当时统一封装了一个分页参数结构前端传pageNum和pageSize后端用MyBatis-Plus的Page对象返回结果集同时带上total总数。前端用el-pagination组件对接实际操作起来非常顺畅。4. 环境搭建与联调部署按这套流程走能少踩一半的坑4.1 前端项目初始化与接口设计约定前端我用Vite创建项目选择Vue3和JavaScript版本的模板。创建命令我就不写了直接说几个关键的配置点。首先Vite的开发服务器代理要配置跨域。开发的时候前端跑在5173端口后端跑在8080端口如果直接请求后端接口会被浏览器的同源策略拦掉。我在vite.config.js里配置了代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这行的意思是前端请求/api/xxx时开发服务器帮我们把请求转发到http://localhost:8080/api/xxx这样从浏览器的角度来看请求是同源的不会有跨域问题。需要注意这个代理只在开发环境生效生产环境部署时如果前端和后端不在同一个域名下还是要靠后端CORS配置或者用Nginx反代来解决。接口定义这一块我建议把每个模块的接口调用独立成一个文件放在api目录下不要散落在页面组件里。我自己习惯的做法是// api/order.js import request from /utils/request export const createOrder (data) { return request.post(/api/order/create, data) } export const getMyOrders (params) { return request.get(/api/order/my, { params }) }每个页面的组件里只需要引入对应模块的方法如果接口地址变了改api目录里的一个文件就行不用全局搜索。Axios封装也很有必要。我在utils/request.js里统一创建了Axios实例设置基础路径加上请求拦截器和响应拦截器。请求拦截器自动从localStorage里取Token放进请求头响应拦截器统一处理code如果code是200就返回数据。这里还有一个容易犯的错误响应拦截器里不光要处理业务code还要处理HTTP状态码否则后端返回401时前端拿到的err里已经是网络层错误和业务错误混在一起排查起来非常麻烦。4.2 后端配置跨域、返回值封装与分析后端我初始化的时候用Spring Initializr选了Spring Web、MyBatis、MySQL驱动这几个依赖JDK版本用的8或11都可以。项目跑起来第一步就是配置CORS。开发环境下后端需要允许前端的http://localhost:5173跨域访问。如果你不想用前端代理那一套可以直接在后端写一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }但这里有个安全细节需要注意allowedOriginPatterns写成*在生产环境并不可取相当于允许任意网站跨域调用你的接口。如果只是开发环境调试无所谓上线之前一定要改成实际的域名白名单。接口返回值的统一格式我也专门封装了一个Result类结构固定为{ code: 200, message: success, data: { } }这样前端拦截器判断逻辑非常统一code是200就往下走否则统一的提示。这个Result类用泛型封装data的类型可以是任何对象或List。4.3 生产部署Nginx反向代理与打包到了部署环节我是用一台Linux服务器后端用Maven打包成Jar包通过nohup命令后台运行mvn clean package -DskipTests nohup java -jar dbx29-server.jar --spring.profiles.activeprod server.log 21 前端执行npm run build生成dist目录然后把dist里的文件放到Nginx的站点目录配置大概长这样server { listen 80; server_name your-domain.com; root /var/www/dbx29/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }关键亮点在于location /api/这一段。如果部署时前端和后端都在同一台服务器Nginx会把带有/api/前缀的请求转发到后端的8080端口。这样用户浏览器访问的是同源域名既解决了跨域又隐藏了后端端口比暴露8080给用户更稳。踩过的坑Vue Router的history模式。如果你用history模式路由用户在浏览器里直接访问http://your-domain.com/order会得到一个404原因是Nginx不知道该把请求转发给前端页面还是后端接口。解决方案是在Nginx配置里加上location / { try_files $uri $uri/ /index.html; }这一行的作用就是如果找不到对应的文件或目录就返回index.html让Vue Router接管路由。5. 踩坑记录跨域、时间格式与并发冲突的现场排查5.1 跨域问题接口通了但浏览器报错我一开始开发的时候用的是前端Vite代理一切正常。后来把前端打包部署到测试服务器后端也在同一台机器上但浏览器一直报跨域错误。排查了很久才发现问题出在Nginx配置上——前端页面是通过80端口访问的接口通过location /api/转发到8080前后端的源已经统一了但后端接口的CORS配置里allowedOriginPatterns还是*虽然不冲突但某些浏览器对allowCredentials(true)配合*会直接拒绝响应。这个问题的根源有两个层面一是后端CORS配置里allowCredentials(true)不能和*通配一起用二是我在Nginx层已经做了代理此时后端接口根本不需要再配CORS。解决办法就是告诉后端生产环境把CORS配置去掉或者严格限制来源开发环境保留宽松配置。前后端分离项目里跨域问题要分环境处理别一套配置走天下。5.2 时间格式问题LocalDateTime序列化后变数组前端拿到档期列表的时候发现workDate字段在JSON里变成了形如[2024, 6, 15]的数组整个页面无法正常解析。这是因为后端默认使用Jackson序列化LocalDateTime时没有配置JavaTimeModule导致输出格式不符合前端预期。我试了几种方案最终选择在项目里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8前端拿到的时间字符串直接就能渲染到页面上不用再做额外处理。需要注意的是如果某个字段要按特定格式输出比如只输出日期yyyy-MM-dd可以在实体类的对应字段上加JsonFormat(pattern yyyy-MM-dd, timezone GMT8)。5.3 并发下档期被同时预约这个问题其实在开发阶段不容易暴露出来因为本地调试基本都是单线程操作。但上线后周末客户集中咨询时同一个热门摄影师的黄金时段在几分钟内被好几个客户选中后台开始出现订单建立了但档期状态还是空闲的怪象。我通过日志定位发现问题出在没有使用乐观锁之前业务代码是先查询档期状态如果空闲就先更新状态再插入订单。这个顺序中间有竞态窗口两个请求都查出了空闲在更新的时候没有版本号限制双双更新成功。后来加上version字段并用UPDATE ... WHERE id ? AND version ?之后这个问题彻底解决。日志里也会看到updateCount 0时会抛出该档期刚刚被别人约走的提示这个提示反而成了客户之间抢档期的有趣互动。5.4 图片上传与静态资源访问摄影师资料里的头像和宣传图我一开始是把文件直接存进后端项目的static/images目录开发时看着没问题但打包部署之后发现图片404了。原因是打包成Jar后static目录里的文件锁在Jar包内部外部无法直接追加文件。后来我把文件存储在服务器的绝对目录比如/data/upload/images/然后后端配置了虚拟路径映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file:/data/upload/); }这样前端访问http://your-domain.com/upload/images/xx.jpg就是访问磁盘上的真实文件不再依赖Jar包内部。项目里所有上传的文件路径记得存数据库时要存相对路径比如/upload/images/xx.jpg不要存绝对路径否则以后换服务器或迁移目录数据库里的路径就全废了。5.5 前端常见问题路由守卫与刷新404用户登录状态会影响页面访问权限我在前端配置了全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })这个逻辑看起来简单但有个细节坑用户登录成功后跳转页面由于刷新页面会导致内存里的用户状态丢失。我用Pinia保存用户信息但Pinia默认不持久化刷新后就清空了。解决方案是给Pinia配合pinia-plugin-persistedstate把用户信息持久化到localStorage里。前端的登录状态问题本质是内存状态和持久化状态的取舍理清这一点所有类似问题都能解决。最后的经验总结dbx29这个系统从需求梳理、数据库设计到前后端开发、测试、上线整体下来我收获最大的一点业务系统的难点从来不在某个单独的技术点上而在于如何让所有模块的数据状态保持一致。档期状态、订单状态、用户状态它们之间的流转只要有一处没对齐后面就会出现连锁的脏数据问题。所以做这类预约系统一定要先把数据模型和状态机定义清楚再动手写代码一旦开始写代码尽量不要频繁改表结构。最后再分享一个实用的小技巧开发这类系统时后端统一接口返回格式、统一异常处理、统一返回错误码前端统一拦截器、统一路由守卫这些统一会极大减少前后端联调时的互相扯皮。尤其是接口返回值如果一会儿返回data是一段话一会儿又是一个对象前端处理起来会很崩溃。先把约定写下来比什么都重要。