做这个理发店会员预约网站管理系统起因是我一个开理发店的朋友一到周末就跟我吐槽店里五张椅子排队的人堵到门口有人等半小时直接甩脸走人工作日又空得能打羽毛球。会员充值、扣费全靠一个纸质本子店里的员工随手记一笔顾客说“我上个月充了500现在应该还有300多”账本一翻消费记录早就超过了双方都尴尬。他被我怼回去你这情况用SpringBoot搭一个会员预约管理系统不就解决了吗我当时没想太多真动手做起来才发现这个看起来“不就是个预约功能”的小系统涉及的点还真不少会员余额怎么算才不出错、预约时间冲突怎么避免两个顾客同时占同一把椅子、临期预约怎么自动提醒、还有理发师端和管理后台怎么各管一摊。这篇文章我就把整套设计和实现过程掰开揉碎讲一遍给准备做类似管理系统的朋友一个完整的参考。无论你是要做毕业设计还是接了个小店的定制单子这套思路基本都能直接抄作业。1. 理发店排队乱象背后的系统设计切入点1.1 从“小本子记账”到“线上预约”的需求映射我先跟朋友在店里蹲了一个下午把真实业务捋了一遍。理发店的日常运转其实就三类角色顾客、理发师、店长通常老板自己兼。痛点是分散的顾客端不知道哪个理发师现在有空不知道要等多久会员卡里余额剩多少全靠店员口头说心里没底。理发师端被现场顾客插队手头活儿还没干完下一位已经在旁边催了预约记录记在微信聊天里翻起来费劲。店长端每个理发师一天服务了多少人、业绩怎么算靠月底翻本子会员充值数据更是糊涂账想做个营销活动都不知道该给谁推。把痛点翻译成功能需求系统的边界就清晰了顾客能注册登录、浏览服务项目、看到理发师的空闲时间并在线预约、查自己的会员余额和积分、取消预约理发师能看当天预约列表、标记服务完成店长能在后台管理员工和项目、查看预约流水和经营统计。这里要提醒一句做系统最忌讳一开始就贪大。我列完功能清单后朋友说“能不能顺便做个优惠券秒杀”“微信小程序能不能也来一个”“能不能对接美团”我一律先记到“二期计划”里。预约管理系统的核心价值是解决“时间安排”和“会员账目”这两个痛点先把主链路跑通远比铺一堆半成品功能重要。1.2 功能清单的取舍哪些功能先做哪些砍到二期我最终定的MVP最小可用版本范围是这样的用户注册与登录手机号密码即可不做短信验证码的强制流程降低演示环境部署成本。服务项目列表包含名称、价格、预计耗时、项目图片。在线预约顾客选择理发师、服务项目、时间系统自动检测冲突。会员管理支持充值、余额消费、消费积积分按积分自动升级等级并享受折扣。我的预约展示预约记录支持在约定时间前一定时长取消。管理后台维护理发师信息、服务项目、查看预约流水和会员列表。砍掉的功能在线支付预约先到店付款避免接入支付接口的资质和回调麻烦、短信通知用站内消息预留接口代替、小程序端先做响应式网页店里的手机和电脑都能访问。这么砍不是偷懒而是把最复杂的并发、权限、状态流转问题先做好支付和消息推送以后都能按同一个接口扩展上去。2. 技术选型SpringBoot MyBatis MySQL为什么够用2.1 为什么是SpringBoot而不是SpringMVC或者更重的框架做选型的时候我很明确这个项目要跑在店铺的一台普通Windows电脑或云服务器上维护的人大概率不专职懂技术所以“部署简单、出问题好查”排在第一位。SpringBoot在这方面的优势是碾压级的。以前做SSM项目spring-context、spring-mvc、mybatis、数据库驱动、连接池每个jar包版本都得自己对配错一个直接起不来。SpringBoot的starter机制把依赖打包成了“开箱即用”的组合只需要在pom.xml里引一个spring-boot-starter-web内嵌的Tomcat就一起带进来了后端代码直接打成可执行jar包扔到服务器上java -jar就能跑连外部容器都不用装。更深一层的原因是它的自动装配机制。SpringBootApplication这个注解里藏着EnableAutoConfigurationSpringBoot启动时会扫描AutoConfiguration.imports文件里的所有自动配置类再通过ConditionalOnClass、ConditionalOnMissingBean这类条件注解决定“当前环境需要装配哪些Bean”。比如你引入了spring-boot-starter-data-redis但没配Redis地址它就不会强行装配RedisTemplate你引入了MySQL驱动和DataSource配置它才帮你把数据源和事务管理器配好。理解这个原理之后排错至少能少走一半弯路——很多奇怪的问题本质都是“自动配置没有生效”或“被自己的配置覆盖了”。2.2 项目结构与分层职责我没有用复杂的多模块Maven结构单模块分层就足够清晰了。目录结构如下com.barber.shop ├── controller # 接口层接收请求、参数校验、返回统一结果 ├── service # 业务层接口 │ └── impl # 业务实现 ├── mapper # MyBatis的Mapper接口 ├── entity # 数据库实体类 ├── dto # 前端传入的参数对象 ├── vo # 返回给前端的视图对象 ├── config # 配置类跨域、WebMvc、MyBatisPlus等 ├── common # 工具类、常量、统一返回结果、异常处理 └── task # 定时任务分层的核心逻辑是单向依赖controller只做“收参数、调service、返回结果”不写业务判断service写业务流程和事务控制mapper只做SQL数据访问。这样做的最大好处是出问题时用日志就能定位到是参数错了、业务判断错了还是SQL写错了而不是在一个几百行的controller里找一堆纠缠不清的逻辑。2.3 application.yml里那些不能省的关键配置配置文件看着就几行但每一项都有讲究server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/barber_shop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true这里最容易踩坑的是serverTimezoneAsia/Shanghai。MySQL驱动8.0以上版本如果不指定时区默认会取服务器本地时区一旦服务器是UTC时间你存进数据库的时间就会比北京时间早8个小时预约界面显示的时间就跟实际对不上。同样Jackson的time-zone也要配成Asia/Shanghai否则后端返回给前端的JSON时间字符串会按默认时区序列化又差8小时。这俩坑我当年都踩过现在写配置一律先写上。3. 表结构设计六张核心表把会员、员工、预约串起来3.1 核心表怎么划分每张表负责什么数据库是这套系统真正的地基我设计的时候反复推敲过。最终落地的六张核心表如下表名核心字段设计说明userid、username、password、phone、role统一账号表role区分顾客、理发师、管理员memberid、user_id、balance、total_points、level会员扩展表余额用decimal类型积分单独存barberid、name、avatar、introduction、status理发师信息表与管理账号解耦service_itemid、name、price、duration_minutes、status服务项目duration用于冲突检测appointmentid、customer_id、barber_id、service_id、start_time、end_time、status、amount预约单一次性冗余了金额和结束时间trading_recordid、user_id、type、amount、balance_after、create_time余额/积分变动流水所有资金操作都有据可查其中appointment表里的end_time和amount是刻意冗余的。end_time在创建预约时就能通过start_time duration_minutes算出来存下来之后冲突检测的SQL就能直接走索引比较不用每次临时计算amount冗余的是下单时的项目价格因为服务项目表里的价格以后可能会调整预约记录不能跟着变。3.2 预约单状态机从待支付到已完成的流转预约状态是整个系统里最容易写乱的地方。我一开始也想用状态字段随便存个字符串但越写越发现不同状态之间的转换条件是会互相打架的。后来老老实实整理了一张状态机表状态值含义可流转到PENDING待支付预约后X分钟内未支付自动取消PAID、CANCELLEDPAID已支付/已确认预约生效COMPLETED、CANCELLED、NOSHOWCOMPLETED服务已完成终态CANCELLED用户主动取消终态NOSHOW爽约未到店终态为什么不引入复杂的工作流引擎因为这个场景只有一条线性的状态流转一个枚举类加上几个校验方法就足够了。引入Activiti或者Flowable纯属杀鸡用牛刀反而把部署成本和维护成本拉高。代码层面我建议把所有状态流转的判断收口到一个方法里比如“能否取消”就统一写在AppointmentStatus工具类中禁止在service里到处散落if (status.equals(PAID))这种魔法字符串判断。3.3 预约查询的索引设计高频SQL必须走索引系统上线后最高频的SQL就是“查某个理发师在某个时间段是否有预约”。这行SQL的执行性能直接决定顾客预约时的等待时间。对应的索引设计如下ALTER TABLE appointment ADD INDEX idx_barber_time (barber_id, start_time, end_time); ALTER TABLE appointment ADD INDEX idx_customer_status (customer_id, status);第一个复合索引支撑的是冲突检测——按理发师过滤后再按时间范围扫描效率很高第二个索引支撑的是“我的预约列表”顾客进入个人中心时按用户和状态查。另外start_time这种连续值字段不适合加普通索引后就无脑全表扫但配合复合索引使用效果就很理想了。还有一个设计上的提醒不要为了省事给状态字段加唯一约束预约表的状态天然不唯一同一顾客可以有多条不同状态的预约记录。唯一约束只能用在真正业务上唯一的字段比如“同一顾客同一时段只允许有一条有效预约”如果要做可以和冲突检测逻辑配合着来但单独靠唯一索引是防不住时间重叠这种复杂条件的。4. 会员模块实现余额扣费、计次消减与并发安全4.1 余额和流水为什么要分开落库会员充值和消费是资金敏感操作我见过一些新手设计的表只有“当前余额”一个字段消费时直接把余额字段减掉结果对不上账因为根本没有记录可查。我的做法是member表里只存“当前余额”这个快照任何余额变动都同步往trading_record表插入一条流水记录类型、金额、变动后的余额。扣费的Service逻辑不是简单的“先查余额够不够再UPDATE余额”那两步操作中间一旦发生并发两个请求同时读到余额充足就会导致余额变负数。正确写法是把余额扣减做成一条条件UPDATETransactional(rollbackFor Exception.class) public boolean deductBalance(Long userId, BigDecimal amount) { int rows memberMapper.deductBalance(userId, amount); if (rows 0) { throw new BusinessException(余额不足); } memberMapper.insertTradingRecord(userId, CONSUME, amount.negate(), memberMapper.selectBalance(userId)); return true; }对应的Mapper SQL是关键UPDATE member SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount}这条SQL由数据库层面保证“余额充足才扣减”UPDATE影响行数为0就说明余额不足直接抛异常触发事务回滚前面插入的流水也不会留下脏数据。4.2 计次服务的并发扣减乐观锁是底线除了按余额消费理发店还有一种很常见的会员权益——办一张“全年12次剪发卡”每次剪发消减一次。计次扣减的并发风险跟余额扣费一样而且更隐蔽因为次数是整数大家容易忽略并发问题。我采用的方案是带条件判断的乐观锁式更新UPDATE member SET appointment_count appointment_count - 1 WHERE user_id #{userId} AND appointment_count 0同样影响行数为0说明次数不足业务层直接报错。这样即使两个请求同时发起数据库的行锁也会保证只有一个请求能成功扣减另一个会等到锁释放后重新检查appointment_count 0条件最终正确失败。这里有一个很多人忽略的点乐观锁方案虽然叫“乐观”但配合事务使用时要注意加锁顺序。扣减次数和创建预约单必须在同一个事务里先扣次数再插入预约记录顺序不能反。如果先插预约单再扣次数扣减失败时前面插入的预约单就得靠事务回滚一旦事务边界没包住就会产生一条没有实际扣费的预约记录。4.3 会员等级折扣计算放Service层而不是SQL里会员等级和折扣规则我建议做成一张配置表而不是写死在代码里。表结构很简单等级名称、所需累计积分、折扣比例。比如普通会员0积分9.9折银卡会员500积分9.5折金卡会员1500积分8.8折。计算订单金额时先查会员等级取折扣比例然后用BigDecimal计算public BigDecimal calculatePayAmount(BigDecimal price, Integer level) { BigDecimal discount memberLevelMapper.getDiscountByLevel(level); BigDecimal amount price.multiply(discount); // 保留两位并四舍五入 return amount.setScale(2, RoundingMode.HALF_UP); }这里我用的是BigDecimal而不是double这个坑做资金计算的都懂——double的浮点误差会导致金额计算出现类似0.10.20.30000000000000004的结果存库之后对不上账用户投诉起来非常麻烦。折扣计算放在Service层而不是SQL里一方面便于加日志排查另一方面也方便以后接营销活动的时候统一改逻辑。5. 预约模块实现时间冲突检测与状态机转移5.1 时间窗口重叠检测这是预约系统的核心技术点预约冲突检测是本系统最核心的逻辑没有之一。判断“某个理发师在某段时间是否有预约”不能只看“开始时间是否相等”而是要判断两个时间段是否重叠。假设新预约是[startTime, endTime)已有预约是[existingStart, existingEnd)两者冲突的条件是existingStart #{endTime} AND existingEnd #{startTime}我对应的查询SQL是SELECT COUNT(*) FROM appointment WHERE barber_id #{barberId} AND status IN (PAID, PENDING) AND start_time #{endTime} AND end_time #{startTime}这个查询返回大于0就说明该理发师在这个时间窗口里已经有预约了不能继续创建。逻辑上稍微想一下只要已有预约的开始时间早于新预约的结束时间并且已有预约的结束时间晚于新预约的开始时间两个时间窗口必然有交集。边界情况比如上一单在15:00结束、下一单从15:00开始用和严格比较就不会误判为冲突。到这里要泼一盆冷水只靠Java代码做冲突检查是不够的高并发下两个请求同时通过检查然后同时插入数据库里就会出现两条重叠预约。我当时的兜底方案是给(barber_id, start_time, end_time)建联合唯一索引再加一个status过滤但唯一索引没法直接处理“时间窗口重叠”这种复杂条件。真正稳妥的做法是在事务里使用SELECT ... FOR UPDATE对理发师当天的预约记录加悲观锁串行化冲突检测或者接受极低概率的冲突事后用定时任务扫描修复。一个小理发店的预约并发量根本打不到这个级别所以代码层校验加数据库兜底已经足够不必过度设计。5.2 一次完整预约的接口流程创建预约接口的完整流程我用代码骨架展示一下Transactional(rollbackFor Exception.class) public AppointmentVO createAppointment(CreateAppointmentDTO dto) { // 1. 校验服务项目和理发师都存在且处于启用状态 ServiceItem item serviceItemMapper.selectById(dto.getServiceId()); Barber barber barberMapper.selectById(dto.getBarberId()); if (item null || barber null || barber.getStatus() ! 1) { throw new BusinessException(服务项目或理发师不可用); } // 2. 计算预约开始/结束时间 LocalDateTime startTime dto.getStartTime(); LocalDateTime endTime startTime.plusMinutes(item.getDurationMinutes()); // 3. 时间冲突检测 int conflictCount appointmentMapper.countConflict( dto.getBarberId(), startTime, endTime); if (conflictCount 0) { throw new BusinessException(该时段已被预约请选择其他时间); } // 4. 计算金额会员打折 BigDecimal amount calculatePayAmount(item.getPrice(), customerLevel); // 5. 插入预约记录 Appointment appointment new Appointment(); appointment.setCustomerId(getCurrentUserId()); // ... 其他字段赋值 appointmentMapper.insert(appointment); return convertToVO(appointment); }这段流程看起来简单但每一步的顺序都经过考量先校验基础信息再算时间再检测冲突最后才插入。如果把冲突检测放在插入之后一旦检测失败还得回滚事务开销更大而且逻辑上更容易出bug。参数校验还有一个细节前端传来的startTime不能直接信任。我要求在DTO里限制只能选整点或半点开始且不能选择早于当前时间的时间段。后端用NotNull和自定义的FutureOrPresent校验注解双重把关否则顾客往数据库里塞一条“昨天下午的预约”整个状态机就直接乱套了。5.3 取消预约与爽约处理取消预约涉及退款和状态流转两个问题。我定的规则是距离预约开始时间超过2小时可以无条件取消会员余额或计次全额退回距离不足2小时取消需要店长审核避免理发师被放鸽子后空等。这个规则在AppointmentStatus状态机工具类里统一实现。爽约处理是预约系统最容易被人忽略的部分。我在预约记录里增加了一个noshow_count统计如果一个顾客一个月内爽约超过2次系统自动将其预约时所需积分门槛提高或者限制其只能选择到店排队。这个功能初期可以不做前端展示但在后端逻辑里预留好方便店长手动调整。6. 预约提醒Scheduled定时任务与消息通知6.1 Scheduled的定时策略怎么定预约提醒是会员体验的加分项也是SpringBoot定时任务的典型应用场景。我当时定了两个提醒节点预约成功当天发送一条“您已成功预约”的站内信预约开始前2小时扫描一次所有PAID状态的预约给顾客发短信通知。SpringBoot的Scheduled有三种调度方式cron表达式、fixedRate、fixedDelay。这里我的策略是预约成功通知在业务代码里同步触发不走定时任务。临期提醒用cron表达式固定每天整点扫描一次Component public class AppointmentRemindTask { Scheduled(cron 0 0 * * * ?) // 每小时的第0分钟执行 public void remindUpcomingAppointments() { // 查询两小时后即将开始、状态为PAID的预约 ListAppointment list appointmentMapper .selectByStatusAndStartTimeBetween(PAID, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusMinutes(119)); for (Appointment appointment : list) { notifyService.sendRemind(appointment); } } }cron表达式更适合这种“按自然小时整点触发”的需求。注意这里查询区间我刻意做成了now2h到now119min避免同一个预约在扫描过程中被重复提醒因为上一轮扫描已经消费了该记录的提醒状态我可以把提醒标记字段加在预约表上更稳妥的做法是appointment表加一个reminded布尔字段通知成功后更新为true。6.2 通知渠道的取舍短信、微信模板消息还是站内信做通知功能时朋友第一反应是“直接发短信啊”。我查了一圈短信服务平台的报价一条短信几分钱对理发店来说长期下来也是一笔不小开支。微信模板消息虽然免费但需要公众号服务号认证还有很多行业限制小店主自己搞不定。最终我的落地方案是把通知抽象成一个NotifyService接口先实现站内信用户登录后在“我的消息”里看到提醒短信接口留好实现类但不接真实服务商配置文件里用一个开关切换。这样演示时方便以后店长愿意买短信套餐了只需要新增一个SmsNotifyServiceImpl替换Service注解的默认实现就行业务代码一行不用改。6.3 定时任务的并发与重复执行问题定时任务还有一个容易踩的坑如果项目是用java -jar在单机跑Scheduled没问题但如果以后部署到多台服务器做负载均衡同一个定时任务会在每台机器上都执行一遍预约提醒就会重复发。解决办法有几种用Redis的分布式锁、用XXL-Job这类分布式任务调度平台、或者用数据库的唯一约束做幂等。对于单店小系统单机部署完全够用但我建议在定时任务方法上加一个注解或注释说明“此任务必须单实例执行”防止以后接手的人贸然改动部署架构。7. 上线前踩过的坑事务、映射、时区与跨域7.1 Transactional失效的三种典型死法这个系统开发过程中我在事务问题上交过不少学费。最典型的三个场景新手几乎必踩第一种同类内部方法调用导致事务失效。假设Service里有一个createAppointment方法它内部调用了同类中的this.deductBalance()而deductBalance标了Transactional。由于Spring事务是通过AOP代理实现的this调用不会经过代理对象注解完全不生效扣款和插流水就不在一个事务里万一后面步骤失败钱扣了流水没记。正确做法是把事务方法拆到另一个Service或者注入自身代理对象Service public class AppointmentServiceImpl implements AppointmentService { Autowired private MemberService memberService; Transactional(rollbackFor Exception.class) public void createAppointment(CreateAppointmentDTO dto) { // 预约逻辑 memberService.deductBalance(userId, amount); // 这个事务生效 } }第二种异常被catch吞掉。Transactional默认只对RuntimeException回滚如果你在方法里把异常catch住不抛出就相当于告诉Spring“一切正常”前面的数据库操作全部提交。所以要么catch之后手动throw new RuntimeException要么在注解里明确rollbackFor Exception.class把可检查异常也纳入回滚范围。第三种数据库表引擎不支持事务。MySQL的MyISAM引擎根本不支持事务你注解写得再对数据该错乱还是错乱。SpringBoot默认的表引擎取决于配置我建表时习惯写死ENGINEInnoDB避免迁移到别的库时行为不一致。7.2 MyBatis映射的那些“诡异”问题appointment表字段是下划线风格start_time对应实体类属性是startTime。如果忘了在application.yml里开启map-underscore-to-camel-case: true查询结果里这个字段就是null而且不会报错非常难排查。我排查过一次查了半天代码最后发现是配置项没开。XML里的特殊字符也容易踩坑。SQL里有start_time #{endTime}这个小于号在Mapper XML里直接写会被解析成标签开始导致XML解析报错。解决办法是转义成lt;或者用一个更优雅的写法![CDATA[ start_time #{endTime} AND end_time #{startTime} ]]另外尽量少用SELECT *多列明确的字段这样返回的Map或DTO结构你可控不会因为数据库表加了一个字段导致前端多返回一堆你不想暴露的数据。7.3 时区与日期格式化差8小时的灵异事件之前提过的时区问题再展开说。我曾在部署后收到反馈顾客晚上8点预约后台显示是中午12点。排查链路是前端传的2025-01-05 20:00:00被Jackson序列化时因为后端time-zone未配置默认按UTC转换成12:00:00Z传给了MySQLMySQL连接串也没配serverTimezoneJDBC驱动又按系统时区处理了一遍。两次偏移叠加就差了8小时。这个问题的根因就是全链路时区不一致。我的统一规范是前端传ISO格式时间字符串后端一律用LocalDateTime接收Jackson统一序列化为yyyy-MM-dd HH:mm:ss数据库连接串固定serverTimezoneAsia/Shanghai数据库字段全部用DATETIME类型。这里不建议用TIMESTAMP因为它会隐式转换时区排查起来更加混乱。7.4 跨域问题与Vue打包进SpringBoot如果你的前端用的是Vue有两种集成方式。开发阶段用前后端分离后端地址localhost:8080前端Vite默认在5173端口直接跨域了。解决方式是在后端加一个CorsFilter配置类Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这里有一个我实际踩过的细节addAllowedOrigin不能和setAllowCredentials(true)一起用*通配符部分浏览器和旧版本框架会直接拦截。必须写明确的分发源或者用allowedOriginPatterns(*)这个兼容写法。上线部署时我选择把Vue打包后的dist目录内容拷贝到SpringBoot的src/main/resources/static下这样后端一个jar包直接包含前端页面部署最省心。但要注意如果前端用了Vue Router的History模式刷新一个非根路径比如/booking时后端Tomcat找不到对应的静态资源会返回404。解决办法是加一个路由转发把所有非API请求转发到index.htmlController public class SpaForwardController { RequestMapping(value {/booking/**, /admin/**}, method RequestMethod.GET) public String forward() { return forward:/index.html; } }我把这个系统的开发过程复盘完最大的体会是SpringBoot确实把开发的“最后一公里”铺得很平jar包一跑界面就能用但真正的复杂度永远在业务逻辑和数据一致性上。一个预约冲突检测、一次余额扣减背后要考虑的并发和状态问题远比框架本身多。如果后面要继续扩展我大概率会先把Redis引进来做热门时段缓存和分布式锁再把预约数据做成异步落库这些都是这个系统后续自然演进的路线。毕竟理发店的生意只要在涨系统就得跟得上客流。