ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot婚纱摄影预订系统毕设:从数据库到答辩全攻略

2026/9/28 8:35:05 拓冰建站 浏览量
Spring Boot婚纱摄影预订系统毕设:从数据库到答辩全攻略 做毕设最怕的不是写代码而是选了一个“做完也讲不出所以然”的题目。Spring Boot在线婚纱摄影预订系统算是我这几年看过最适合拿来当计算机专业毕设的电商类项目之一。它业务链路完整、技术栈主流、扩展空间大而且演示效果非常直观——毕竟没有哪个评委能拒绝一套界面好看、流程闭环的预订系统。这篇文章我会把整套系统的设计思路、数据库建模、核心代码、答辩技巧全部拆开讲内容比较长建议先收藏再慢慢看。这个系统本质上就是一个“服务预约 商品展示”的复合型应用用户浏览婚纱套餐、查看摄影师作品、选择心仪档期下单管理员在后台处理订单、维护套餐和摄影师信息。它既包含传统的CRUD操作又涉及订单状态流转、档期冲突校验这类真正有业务深度的逻辑恰好覆盖了毕设答辩时最常被追问的“你的系统难点在哪”这个问题。无论你是Java基础一般、想找稳妥方案过关的应届生还是想借毕设把Spring Boot、MyBatis-Plus、Vue前后端分离这些技能真正串起来的求职者这套系统都值得研究。下面我按照从规划到落地的顺序把整个项目的实战过程完整走一遍。1. 先看清系统全貌这家“婚纱店”到底要管什么1.1 业务边界别画歪从一次真实预约看核心链路很多同学拿到“XX预订系统”的第一反应是疯狂堆功能把用户管理、套餐管理、订单管理、留言板、数据报表全部塞进去最后代码写了一万多行问起核心业务流程却说不清楚。这是毕设的大忌。建议先画一条主业务链路所有模块都围绕它展开。婚纱摄影预订的链路其实非常清晰用户注册登录、浏览婚纱套餐、查看摄影师信息、选择日期提交预约、管理员登录后台处理订单、订单状态推进到完成。这条链路里的关键动作是“预约”而预约的核心矛盾是“资源有限”——一个摄影师在某个热门日期只能服务一对新人。所以整个系统的业务重心应该放在订单与档期的关系上而不是花大量篇幅做花哨的页面特效。这个判断直接决定了后面所有设计数据表怎么建、接口怎么划分、事务加在哪里、答辩时重点讲哪段代码。想清楚这一点项目就成功了一半。你要让评委感觉到你是在设计一个真实可用的业务系统而不是在堆砌功能。1.2 角色权限与功能模块划分这个系统里我有三类角色功能划分如下角色核心功能说明游客浏览套餐、查看摄影师、阅读资讯只能看不能约注册门槛用来保证订单数据可追溯注册用户在线预约、个人订单管理、收藏套餐核心用户所有业务动作围绕它展开管理员套餐管理、摄影师管理、订单审核、用户管理、轮播图管理后台单独一套界面权限用拦截器控制这里要注意一个细节管理员和普通用户不要放在同一张表里硬做角色字段除非你确实需要“一个人既是用户又是管理员”的场景。婚纱摄影系统里这两种身份边界很清晰直接拆成user和admin两张表逻辑简单、查询也高效。我见过不少毕设把角色和权限设计得很复杂引入Spring Security加RBAC表结果答辩被追问几个问题就露馅了。能用拦截器解决的问题不要为了显得高级去上重框架。1.3 技术选型稳定好讲才是王道技术选型这块我直接给出结论和理由。Spring Boot版本选2.7.x不要选3.x。原因很实际3.x要求JDK 17起步很多毕设同学的电脑上的JDK还是8而且3.x版本下部分旧版依赖的兼容性会让人崩溃。2.7.18是2.x的最终版本Bug修得差不多了和JDK 8配合完美网上能搜到的资料也最多。持久层我用的是MyBatis-Plus而不是原生MyBatis或JPA。MyBatis-Plus在CRUD场景下几乎不需要写SQL自带分页插件、代码生成器能省下大量时间。更重要的是它的LambdaQueryWrapper写起来非常直观答辩时你拿着代码一讲评委立刻能看懂。JPA虽然也很强大但很多同学其实没搞懂它背后的懒加载机制用不好反而成为扣分项。前端这块有两种方案Thymeleaf服务端渲染或者Vue3 Element Plus前后端分离。如果求稳、害怕跨域和环境配置问题选Thymeleaf一套Spring Boot直接跑起来。如果想拿高分、简历上能写“前后端分离开发经验”就选Vue3分离版。我后面给的代码示例按分离版来讲因为现在的主流毕设方向确实是这个。认证方式用JWT而不是Session理由很简单前后端分离后Session管理跨域非常麻烦JWT无状态、自带过期时间、每个请求头部带Token即可是当前企业级项目的标配做法。2. 数据库设计把“预约”拆成能落地的状态机2.1 六张核心表怎么设计数据库设计这一步直接决定了后期写代码是顺利还是痛苦。我先给出最核心的表结构再挑几个容易踩坑的点专门说明。整套系统最核心的表是预约订单表它像一根线把用户、套餐、摄影师串在一起。用户表userid、username、passwordBCrypt加密存储、phone、email、avatar、create_time。密码绝不能用明文存这是底线。注册时用BCrypt加密登录时再校验哪怕后期数据库泄露也不会直接暴露用户密码。套餐表photography_packageid、title、cover、description、price、original_price、package_type室内/外景/旅拍、status0下架/1上架、sales虚拟销量、create_time。这里加original_price字段不是多余的前端可以展示“划线价”来营造优惠感这也是电商系统的常见玩法。摄影师表photographerid、name、avatar、style擅长风格、description、price单场拍摄价格、status。摄影师和套餐之间理论上应该是多对多关系但在毕设阶段为了控制复杂度可以直接让订单里同时记录套餐ID和摄影师ID不做额外关联表。如果你想体现设计能力加一张关联表也不难但要知道怎么解释清楚。订单表appointment_order是重头戏我给出建表SQL你建库的时候直接照着用就行。这张表我故意做了冗余设计后面详细解释。CREATE TABLE appointment_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL COMMENT 下单用户ID, package_id bigint(20) DEFAULT NULL COMMENT 套餐ID, package_name varchar(100) DEFAULT NULL COMMENT 套餐名称快照, package_price decimal(10,2) DEFAULT NULL COMMENT 套餐价格快照, photographer_id bigint(20) DEFAULT NULL COMMENT 摄影师ID, photographer_name varchar(50) DEFAULT NULL COMMENT 摄影师姓名快照, appointment_date date NOT NULL COMMENT 预约拍摄日期, appointment_address varchar(255) DEFAULT NULL COMMENT 拍摄地点, mobile varchar(20) DEFAULT NULL COMMENT 联系电话, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已确认 3已完成 4已取消, remark varchar(500) DEFAULT NULL COMMENT 用户备注, create_time datetime DEFAULT NULL COMMENT 下单时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_photographer_date (photographer_id, appointment_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个表的设计有三个关键点。第一套餐名称和价格是冗余存储的快照字段。如果管理员隔天把套餐价格从3999改成4999已下单的客户看到的订单仍然是3999因为订单表已经固化了当时的价格。这是电商系统里非常经典的设计思想答辩时主动讲出这一点会很有含金量。第二联合索引idx_photographer_date是给档期冲突查询用的下面马上讲。第三订单编号order_no不要直接用自增ID因为会暴露订单数量我习惯用“yyyyMMddHHmmss 四位随机数”来生成保证并发下也不会重复。2.2 订单状态流转用枚举管理而不是散落的魔法数字订单状态我设计了五个待支付、已支付、已确认、已完成、已取消。整个流转过程是这样的用户提交预约后生成订单初始状态为待支付用户在订单详情页点击支付毕设阶段用模拟支付即可进入已支付管理员在后台看到已支付的订单后电话联系用户确认档期将状态改为已确认拍摄完成交付精修照片后状态改为已完成期间用户或管理员随时可以取消未开始的订单进入已取消状态。这种“状态 动作”的建模方式在企业开发里非常普遍本质上就是个简化版的状态机。代码层面建议把状态定义成枚举而不是直接用int散落各处。public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(1, 已支付), CONFIRMED(2, 已确认), FINISHED(3, 已完成), CANCELED(4, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }使用枚举的好处是代码里不会出现一堆含义不明的数字比如order.getStatus() 3这种而是order.getStatus() OrderStatus.FINISHED.getCode()看了就懂。后续如果要增加新状态比如“改期中”只需要在枚举里加一项判断逻辑放在Service层统一管理不会漏改。2.3 档期冲突校验最容易翻车也最值得讲的业务逻辑婚纱摄影预订系统里真正有技术含量的地方就是同一个摄影师同一天不能同时接两单。这个逻辑用SQL描述很简单查一下appointment_order表里有没有符合条件photographer_id相同、appointment_date相同、订单状态不是已取消的记录。写出来大概是这样long count orderMapper.selectCount( new LambdaQueryWrapperAppointmentOrder() .eq(AppointmentOrder::getPhotographerId, photographerId) .eq(AppointmentOrder::getAppointmentDate, date) .notIn(AppointmentOrder::getStatus, OrderStatus.CANCELED.getCode()) ); if (count 0) { throw new BusinessException(该摄影师当天档期已被预约请选择其他日期); }但这里有个隐藏问题如果两个用户在同一瞬间提交订单都查到了档期空闲然后同时插入怎么办这是个典型的并发安全问题。毕设答辩时经常被问到。解决思路有三种一是给表加唯一约束用数据库层保证不重复但对“非取消状态”做唯一约束MySQL不太好实现二是在查询时加悲观锁FOR UPDATE锁住对应摄影师的记录或整张表三是用Redis分布式锁在秒杀场景下常用。对于毕设来说你能讲清楚这个问题、并架构上说明会选择哪种方案已经足够了。我当时在项目里用了悲观锁的思路先查询前用SELECT ... FOR UPDATE锁住摄影师的那一行记录再执行档期查询和订单插入整个操作包在事务里这样并发下单就不会出现双卖。3. 核心代码全拆解从登录到下单的完整链路3.1 项目初始化与基础配置新建项目这一步不赘述了IDEA里选择Spring InitializrJava版本选8依赖选Spring Web、MySQL Driver、Lombok。建好后手动往pom.xml里加三个关键依赖MyBatis-Plus、Hutool工具集、JWT库。MyBatis-Plus是操作数据库的核心Hutool提供日期格式化、随机数生成这些常用工具JWT负责登录认证。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.20/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency然后是application.yml的核心配置。这里有几个参数容易踩坑。第一个是数据库连接串的serverTimezone不配的话会报时区错误我直接用Asia/Shanghai。第二个是mysql-connector-java的版本Spring Boot 2.7默认管理版本就行不用手动指定。第三个是multipart上传限制Spring Boot默认上传文件最大只有1MB做婚纱摄影系统传照片肯定不够必须手动调大我已改成5MB单文件和20MB请求总量。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wedding_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto3.2 登录认证与JWT拦截器登录逻辑本身不复杂前端把用户名密码传到后端后端查user表用BCrypt校验密码通过则签发一个有效期7天的Token返回给前端。前端拿到Token后存到localStorage里后续每次请求都在Header里带上Authorization。这个Token就是用户的“通行证”后端拦截器会检查每个需要登录的接口。JWT工具类核心代码就这么几行public class JwtUtil { private static final String SECRET your-32-character-secret-key; private static final long EXPIRE 7 * 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }有了Token之后写一个拦截器统一校验登录状态。preHandle里取出Header中的Token调用parseToken解析解析成功就把userId放到request的attribute里后面的Controller可以直接取用。解析失败或者没有Token直接返回401状态码。然后在WebMvcConfigurer里注册拦截器同时配置好放行路径——登录注册、套餐列表、套餐详情、摄影师列表这些游客也能访问的接口必须放行否则前端还没登录就把自己拦住了。这里提醒一个很多同学都踩过的坑注册拦截器后前端所有请求都变成401了。先检查两个地方拦截器里有没有正确放行静态资源和登录接口以及跨域配置的优先级。拦截器是在跨域处理之前执行的如果拦截器直接返回401跨域配置根本来不及生效所以要确保OPTIONS请求在拦截器里直接放行。3.3 预约下单核心Service实现预约下单是整个系统最核心的接口我建议所有逻辑都放在Service层Controller只负责接收参数和返回结果。下单流程需要处理四个步骤校验套餐和摄影师是否存在且上架、检查摄影师档期是否冲突、生成唯一订单号、保存订单并返回详情。Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateRequest req) { // 1. 套餐校验 PhotographyPackage pkg packageMapper.selectById(req.getPackageId()); if (pkg null || pkg.getStatus() ! 1) { throw new BusinessException(套餐不存在或已下架); } // 2. 档期冲突校验这里可以加悲观锁处理并发 Long conflictCount orderMapper.selectCount( new LambdaQueryWrapperAppointmentOrder() .eq(AppointmentOrder::getPhotographerId, req.getPhotographerId()) .eq(AppointmentOrder::getAppointmentDate, req.getAppointmentDate()) .notIn(AppointmentOrder::getStatus, OrderStatus.CANCELED.getCode())); if (conflictCount 0) { throw new BusinessException(该摄影师当天档期已被预约); } // 3. 生成订单号 String orderNo DateUtil.format(new Date(), yyyyMMddHHmmss) RandomUtil.randomNumbers(4); // 4. 构建并保存订单 AppointmentOrder order new AppointmentOrder(); order.setOrderNo(orderNo); order.setUserId(UserContext.getUserId()); order.setPackageId(pkg.getId()); order.setPackageName(pkg.getTitle()); order.setPackagePrice(pkg.getPrice()); order.setPhotographerId(req.getPhotographerId()); order.setPhotographerName(photographerMapper.selectById(req.getPhotographerId()).getName()); order.setAppointmentDate(req.getAppointmentDate()); order.setMobile(req.getMobile()); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setCreateTime(new Date()); orderMapper.insert(order); return convertToVO(order); }我把整个createOrder方法加了Transactional(rollbackFor Exception.class)。rollbackFor这个参数非常关键Spring事务默认只在遇到RuntimeException时回滚如果你在方法里抛的是自定义的BusinessException必须让它继承RuntimeException或者在这里显式指定rollbackFor否则事务不会生效档期校验和订单插入就可能出现数据不一致。这个问题被问到的概率很大记住。档期冲突校验这段我建议你在Service方法里故意保留count查询的可读性然后给评委补一句“生产环境我会改成SELECT ... FOR UPDATE锁行或Redis分布式锁来防并发”这句话一出来整个项目的业务深度立刻就不一样了。3.4 文件上传与图片访问配置婚纱摄影系统离不开图片套餐封面上传、摄影师头像上传、作品展示都要用。Spring Boot处理文件上传很简单一个MultipartFile参数接收文件转存到本地目录就行。关键在于路径规划和访问映射。PostMapping(/admin/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(请选择上传文件); } String originalFilename file.getOriginalFilename(); String ext ; if (originalFilename ! null originalFilename.contains(.)) { ext originalFilename.substring(originalFilename.lastIndexOf(.)); } String filename UUID.randomUUID().toString().replace(-, ) ext; String datePath DateUtil.format(new Date(), yyyyMMdd); File dest new File(uploadDir datePath, filename); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return Result.ok(/files/ datePath / filename); }文件名千万不要用用户原始文件名一方面中文名会有编码问题另一方面万一两个用户传了同名文件就覆盖了。我用UUID重命名按日期分目录存放既避免重名又方便后期清理。数据库里只存相对路径比如/files/20250701/xxx.jpg完整URL由前端拼接域名。上传之后图片访问不到、404这是最常见的问题。原因是Spring Boot默认只把/static目录映射为静态资源路径你传到其他目录必须手动配置。加一个WebMvcConfigurer配置类重写addResourceHandlers方法把/files/**映射到本地的上传目录问题就解决了。这个配置要写在可以访问的公有配置里不能放在带权限拦截的配置模块下。3.5 前后端联调的跨域处理前后端分离项目里前端跑在5173端口后端跑在8080端口浏览器会拦截跨域请求。最省事的方案是在后端加一个全局CORS配置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(*)后面必须设置allowCredentials(true)否则前端如果用了withCredentials属性就会报错第二maxAge(3600)的意思是浏览器在1小时内可以直接发送真实请求不用每次都先发OPTIONS预检请求能明显降低前端接口响应时间。这种细节你调接口时感受最深改完之后整个联调过程顺畅很多。4. 打包部署与答辩准备常见坑和加分项4.1 新手最容易踩的坑问题速查表做这个项目时我整理过一份高频问题清单这里直接列出来你遇到同样的报错就知道去哪查了。问题根本原因解决方案接口返回日期是一串数字后端Date序列化后变成时间戳前端没格式化实体字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)或全局配置Jackson日期格式所有接口全部401拦截器放行路径没配好或OPTIONS请求被拦截检查拦截器是否放行登录接口、静态资源和OPTIONS请求上传图片超过1MB报错Spring Boot默认上传限制太小application.yml配置multipart.max-file-size和max-request-size事务不生效订单插入后查不到异常被try-catch吞掉或rollbackFor没指定自定义异常继承RuntimeException方法加Transactional(rollbackFor Exception.class)不要在Service内部自己catch异常数据库中文乱码连接串没指定编码或表结构不是utf8mb4连接串加characterEncodingutf8建表指定CHARSETutf8mb4同一个摄影师同一天能预约多次档期校验逻辑写了但没生效确认校验代码和插入代码在同一事务里检查状态过滤条件管理员页面图片全裂上传目录没做静态资源映射在WebMvcConfigurer中addResourceHandlers映射本地目录其中“同一个摄影师同一天能预约多次”这个问题我建议你在写上架之后的测试阶段专门用两个浏览器窗口模拟并发预约。如果发现都能预约成功说明事务或校验有问题正好可以在答辩时讲“我在测试中发现并解决了并发下的档期超卖问题”——这种从实战中来的经验比背八股文有说服力得多。4.2 答辩时怎么把项目讲出亮点很多同学做完项目答辩时只会演示页面讲代码就卡壳。我的建议是准备三条主线每个方向都能讲两分钟以上。第一条线是数据建模的冗余设计。主动讲订单表里为什么存套餐名称和价格的快照字段就算评委没往这个方向问你也主动提一句“套餐下架或改价后老订单依然能完整展示历史信息”评委立刻能get你的设计意识。第二条线是核心业务逻辑的可靠性。预约系统的档期冲突校验和事务控制是天然的亮点。面试官或评委最关心的就是“同一个人同一天能不能被预约两次”你要能讲清楚校验流程、并发隐患、以及你的解决方案这一段讲好了整个项目的技术含量立刻上一个台阶。第三条线是部署与拓展能力。演示完页面后补一句“当前项目是单体架构我先把它拆分成了用户端和管理端两个模块后续如果想上生产环境可以基于Redis对套餐热点数据做缓存订单提交环节改成MQ异步削峰”。这句话听起来像规划实际上是在展示你的架构视野。再具体一点你还可以给项目加上定时任务每天凌晨自动把超时未支付的订单置为已取消用Spring自带的Scheduled注解就能实现代码量不大却是一个完整的业务补全。我实际做完以后最深的感受是这个项目真正的难点不在CRUD而在“预约”这个动作背后的资源独占和状态管理。你把这块想透了不仅答辩有东西讲以后面试聊项目经历时也能拿出一个有业务深度的代表作品。如果不打算做前后端分离、想用Thymeleaf省事核心逻辑完全一样照着这套数据库设计和Service代码改改前端模板就能跑通。整个项目从建库到部署按我这个节奏走完整的一周时间足够了。