ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于SpringBoot的理发店会员预约系统实战:从数据库设计到并发控制

基于SpringBoot的理发店会员预约系统实战:从数据库设计到并发控制 理发店会员预约网站这类管理系统在Java技术栈里可以说是常青树级别的实战项目。它业务边界清晰——会员管理、预约排班、订单流水、统计报表每一块都足够典型又不会复杂到失控正好用来把SpringBoot的核心能力完整串一遍。这篇文章我会从一个实际开发者的角度把整个系统的设计思路、表结构、关键代码、踩坑记录全部拆开来讲不仅讲怎么实现更讲为什么这么实现。1. 项目整体设计与技术选型思路1.1 为什么选SpringBoot而不是传统SSH/SSM很多人在做这类管理系统时第一反应是纠结技术栈。我个人的建议非常明确以SpringBoot为基底。原因有三层。第一层是开发效率。SpringBoot的自动装配机制把大量繁琐的配置收进了starter里。传统SSM项目要写一堆XML配置数据源、事务管理器、MyBatis映射而SpringBoot只需要引入spring-boot-starter-web和spring-boot-starter-data-jpa或者mybatis-spring-boot-starter再配一个application.yml一个能跑起来的Web工程就成型了。你可以把SpringBoot的自动装配理解成装修房子时的“拎包入住”——框架已经帮你把水电、墙面、地板都铺好了你只管往里面摆家具。核心入口就是SpringBootApplication这个组合注解它里面藏着EnableAutoConfiguration框架会扫描META-INF/spring.factories文件里注册的所有自动配置类按条件注解ConditionalOnClass、ConditionalOnMissingBean决定哪些配置生效。第二层是生态兼容。理发店会员预约系统需要权限认证、接口文档、缓存加速、定时任务这些在SpringBoot生态里都有非常成熟的starter。用spring-boot-starter-security做登录授权、springdoc-openapi生成Swagger文档、spring-boot-starter-data-redis做缓存、Scheduled做定时任务基本不需要自己造轮子。第三层是部署便捷。项目打包成可执行JAR内嵌Tomcat部署时只需要java -jar一行命令。这个优势在后期演示和交付时特别明显不需要在目标机器上单独装Tomcat、配环境变量。1.2 业务模块划分与功能矩阵理发店会员预约系统表面看是一个信息管理网站实际拆开看核心业务链路是顾客注册/登录 → 浏览理发师与服务项目 → 选择时段提交预约 → 到店消费 → 会员储值/结算 → 积分与等级成长 → 数据统计。围绕这条链路我规划了六个核心模块会员管理注册、登录、个人信息维护、会员卡储值、消费明细查询。服务项目管理理发项目剪发、染发、烫发、护理的增删改查、价格维护、时长设置、上下架状态。理发师管理技师档案、擅长项目、服务时段配置、排班状态。预约管理在线选店、选技师、选时段、提交预约、取消预约、预约状态跟踪。订单与结算到店后生成消费订单、扣减储值余额或现金结算、生成积分。统计报表日/周/月营业额、热门项目排行、理发师业绩排名、会员增长趋势。这里有个容易犯的错一上来就堆功能把系统做成大杂烩。我在设计时坚持一个原则——核心链路优先辅助功能其次。预约模块是绝对的核心先把它做扎实营销类的满减优惠、分享裂变等功能在核心链路跑通后再迭代加。项目进度可控性会好很多。1.3 技术栈选型的取舍技术栈我选了以下组合每一层都有明确理由层次选型理由核心框架SpringBoot 2.7.x稳定成熟资料多避免过高版本带来的兼容问题持久层MyBatis-Plus单表CRUD零XML复杂的统计查询用注解SQL或XML数据库MySQL 8.0InnoDB引擎事务支持完善8.0窗口函数方便做排行缓存Redis缓存首页数据、验证码、热点服务项目减轻数据库压力认证JWT无状态登录前后端分离场景下扩展性好前端Vue 3 Element Plus组件化开发快后台管理的表格、表单场景完美覆盖这套组合的逻辑是数据库用MySQL保证事务可靠缓存用Redis扛住高并发读接口层用SpringBoot统一收口前端用Vue做SPA。整个链路没有花哨的成分每一环都是实践中验证过的高性价比方案。2. 核心功能拆解与数据库设计2.1 数据库设计的核心矛盾这类管理系统的数据库设计最大的难点不在表多而在业务状态的一致性。预约要保证时段不冲突储值要保证金额不超扣订单要保证流水可追溯。这三个“保证”决定了表结构不能拍脑袋随便建。我的设计思路是用主表记录核心实体用流水表记录每一次变动用状态字段标记当前所处阶段。比如会员卡余额一定不要只靠一个balance字段死撑——万一有人并发充值、并发消费余额就错乱了。正确做法是有一张member_transaction流水表每一笔充值和扣费都记一条流水balance作为冗余字段用于快速展示实际对账以流水表为准涉及金额变更的接口必须开启数据库事务。2.2 核心表结构详解这里挑几张关键的表展开说。会员表member核心字段有id、member_no会员编号建议用业务规则生成比如M年月日四位流水号、name、phone、password、balance储值余额Decimal(10,2)、level_id关联等级表、total_consumption累计消费用于评级、created_at。其中total_consumption是累计消费额不是当月消费它是会员等级晋升的判断依据。等级又分普通、黄金、铂金、钻石几档每档对应不同的折扣率。这里要注意折扣率不能直接写死在会员等级表里因为后续做活动时可能出现“全场8折但储值会员再享95折”这种叠加场景。为了不过度设计我在等级表放了discount字段结算时取会员等级折扣与服务项目原价相乘叠加活动优惠用单独的coupon表处理互不干扰。理发师表barber字段包含id、name、avatar、title如首席设计师、高级设计师、years_of_exp、skill_tags擅长项目标签用逗号分隔字符串存储、status在职/休息、service_times每天的服务时段JSON格式存储。重点是service_times。理发师不是全天候接单的中午休息、每周轮休这些都要可配置。我用了JSON字段存每天的时段比如[{start:10:00,end:12:00},{start:13:30,end:19:00}]。这种设计在配置灵活性上碾压固定每天9点到18点的硬编码虽然查询时不能直接走索引但这种低频配置数据完全无所谓。服务项目表service_item核心字段是name项目名称、price原价、duration_minutes服务时长、category剪发/染发/烫发/护理、status上架/下架、image、description。duration_minutes这个字段很多人会忽略但它恰恰是预约冲突检测的基础。剪发30分钟、染发90分钟不同项目的时长不同预约排班时必须以服务时长为单位做时间窗计算。预约表appointment字段包括id、member_id、barber_id、service_item_id、appointment_date、start_time、end_time、status待确认/已确认/已完成/已取消/爽约、remark、created_at。预约表是系统的核心枢纽所有并发冲突问题都集中在这张表上。end_time的设计非常关键它不是用户选的而是后端根据start_time service_item.duration_minutes自动计算出来的目的是为了后续做时间冲突检测时有确定的区间边界。2.3 预约时间冲突检测的算法设计这是整个系统技术含量最高的一块。预约的本质是在特定理发师的时间轴上分配一段不重叠的时间区间。我采用的是经典的区间重叠判断。假设新预约的时间区间是[newStart, newEnd)已存在的预约区间是[existStart, existEnd)两者存在冲突的条件是newStart existEnd AND newEnd existStart这个公式可以覆盖所有相交的情况新预约完全在已有区间内、部分重叠、旧区间完全包含新区间。SQL写出来就是SELECT COUNT(*) FROM appointment WHERE barber_id #{barberId} AND appointment_date #{date} AND status IN (待确认, 已确认) AND (start_time #{newEnd} AND end_time #{newStart})这里有个细节为什么状态要过滤掉“已取消”和“已完成”因为取消的预约已经释放了时间片完成的预约也过去了不应该阻塞未来的排班。另外end_time在设计时用开区间处理即一个预约16:00结束另一个预约16:00开始不算冲突。这符合门店的实际体验——前一个客人刚结束技师需要收拾一下所以我在判断结束时间时加了一个缓冲字段buffer_minutes默认15分钟清理准备时间防冲突时再叠加。2.4 会员储值与订单结算的事务控制储值和消费涉及的金额变动必须放在数据库事务里。一个典型的消费结算流程是校验会员状态正常、余额充足。计算订单金额使用会员等级折扣。扣减会员余额。插入订单记录。插入储值流水金额为负。更新会员累计消费额和积分。这六步任何一步失败都必须全部回滚。在SpringBoot中我直接在服务层方法上加了Transactional(rollbackFor Exception.class)。这里有个新手容易踩的坑Transactional默认只在抛出RuntimeException时回滚如果你在方法里try-catch吞掉了异常事务是不会回滚的。所以要么不catch要么catch后throw new RuntimeException重新抛出。再细一层扣减余额时不能先查再算再更新要用乐观锁/条件更新防超扣UPDATE member SET balance balance - #{amount} WHERE id #{memberId} AND balance #{amount}如果返回的影响行数为0说明余额不足直接抛出业务异常。这种写法比先SELECT balance再在Java代码里判断更安全因为数据库层面的原子操作天然避免了并发问题。3. SpringBoot关键原理在项目中的落地3.1 自动装配原理与自定义starter理解用SpringBoot写了这么多业务代码很多人其实没搞懂框架是怎么把一切串起来的。我在做这个项目期间认真梳理了自动装配的完整链路对排查问题帮助特别大。一切从SpringBootApplication开始它等同于SpringBootConfiguration、EnableAutoConfiguration、ComponentScan三个注解的集合。EnableAutoConfiguration会通过AutoConfigurationImportSelector加载classpath下META-INF/spring.factoriesSpringBoot 2.7及以下或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsSpringBoot 3.0中声明的自动配置类。比如引入spring-boot-starter-data-redis后RedisAutoConfiguration会被加载而它里面的ConditionalOnClass(RedisOperations.class)会检测classpath下是否存在Redis的客户端类如果有就自动创建RedisTemplate和StringRedisTemplate交给容器管理。这就是为什么你什么都不写就能直接Autowired一个RedisTemplate用——框架在启动阶段就帮你准备好了。理解了这套机制后我就琢磨着把项目中重复的“登录鉴权逻辑”抽成一个自定义starter给团队内部复用。做法是在resources/META-INF/spring.factories里配置自定义自动配置类在配置类中用ConditionalOnProperty控制开关实现“引入依赖即生效配置开关可关闭”的效果。这个思路放到理发店会员系统里最合适的落地场景就是通用的Token校验拦截器写一个AuthInterceptor自动配置类把所有需要登录才能访问的接口统一拦下来做JWT校验。3.2 JWT无状态登录的设计要点这个系统的登录认证我用的是JWT而非传统的Session方案。Session模式在单体应用里其实也没问题但JWT的无状态特性让后续扩展移动端小程序、前后端分离部署都更从容。JWT的核心逻辑是用户登录成功后服务端签发一个三段的Token——Header声明算法、Payload携带用户ID、手机号、过期时间、Signature签名。后续请求在Authorization头带上Token服务端验签通过即认为身份合法。落地时有两个容易忽略的点。第一个是Token过期策略。我只签发AccessToken有效期设为2小时。有同学建议加RefreshToken做续签但对这种轻量系统来说过度设计了用户重新登录的成本很低。真要提升体验可以在拦截器里做“剩余有效期不足30分钟自动续签”返回新Token让前端替换。第二个是秘钥管理。绝不硬编码在代码里放在application.yml中用环境变量占位符引用jwt: secret: ${JWT_SECRET:defaultSecretKeyForDev}上线时通过系统环境变量注入真实的秘钥。顺带说一句JWT虽然叫无状态但一旦发生用户被封禁已签发的Token在过期前依然有效。要解决这个问题最简单的方案是把“用户状态版本号”放进Payload里改密码或封禁时把版本号1验签时对比版本号不一致就拒绝。我在这个系统的“用户修改密码后强制重新登录”需求里就用到了这个思路。3.3 全局异常处理与参数校验一个管理系统的接口如果没有统一的异常处理那前端收到的报错信息会长得五花八门。我在项目里建立了一套完整的异常处理体系。先定义一个业务异常类BizException extends RuntimeException携带code和message两个字段。然后在全局异常处理器RestControllerAdvice中分三层处理第一层捕获BizException返回业务错误码和提示信息第二层捕获MethodArgumentNotValidException把参数校验失败的具体字段名和错误消息组装好返回第三层兜底捕获Exception记录完整堆栈日志返回“系统繁忙请稍后重试”。同时所有的DTO参数都使用JSR-303规范注解校验。比如预约请求对象public class AppointmentRequest { NotNull(message 理发师ID不能为空) private Long barberId; NotNull(message 预约日期不能为空) JsonFormat(pattern yyyy-MM-dd) private LocalDate appointmentDate; NotNull(message 预约开始时间不能为空) JsonFormat(pattern HH:mm) private LocalTime startTime; }这样Controller层根本不需要写一堆if判断框架在参数绑定阶段就完成了校验非法请求直接进全局异常处理器。3.4 XSS安全过滤的全局实现这类管理系统免不了有留言或备注字段如果不做安全过滤攻击者在填入scriptalert(xss)/script前端渲染时就会执行恶意脚本。我在项目中用了一个全局过滤器来处理上传数据里的XSS风险。做法是继承OncePerRequestFilter重写doFilterInternal方法用HttpServletRequestWrapper包装原始请求在getParameter、getHeader等取值方法里对返回值做HTML标签清洗。用一个工具类把、、、等危险字符转义成安全的实体字符。只过滤text类型的参数JSON请求体里的内容由MappingJackson2HttpMessageConverter配合自定义的反序列化器处理。这里要注意不能无脑过滤所有内容比如富文本编辑器上传的内容本身就含HTML标签过度转义会导致显示异常。我在过滤器里加了白名单只有配置了过滤开关的URL路径才生效这个开关在配置文件中开合。4. 实操过程与核心环节实现4.1 从零搭建SpringBoot工程把这个项目的搭建过程完整梳理一遍直接照着操作就能复现。先解决环境问题JDK用1.8或11都行Maven用3.6IDE用IDEA。新建项目时通过Spring Initializr勾选依赖Spring Web、MyBatis-Plus Framework或MySQL Driver MyBatis、Validation、Lombok。不需要勾选Spring Security因为JWT已经自己实现了减少框架组合的复杂度。工程建好后pom.xml里引入关键依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependenciesapplication.yml的核心配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/barber_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 3 mybatis-plus: mapper-locations: classpath*:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有个热词提到“SpringBoot版本太高”的担忧很现实。SpringBoot 3.x要求JDK 17且javax.*包迁移到了jakarta.*很多旧教程的代码直接跑不起来。如果你跟着我的版本走2.7.x JDK 8一切都会顺利很多。另外IDEA创建项目时默认的SpringBoot版本可能已经很高记得在Initializr界面切换成2.7.18。启动类上标注MapperScan(com.example.barber.mapper)让MyBatis-Plus扫描到所有的Mapper接口。到这里一个空白的SpringBoot工程就已经能启动了。4.2 前端页面的组织方式这个系统的前端我选Vue 3 Element Plus但考虑到有的同学不熟悉前后端分离的完整工程配置这里至少要把结构说清楚。前端工程用Vite创建目录结构按views、components、api、router、store分好。核心页面有登录页、首页仪表盘、会员管理页、预约日历页、订单管理页、统计报表页。接口请求统一封装在utils/request.js里基于axios拦截器里做两件事——请求带Token、响应统一处理业务码。预约日历页是全系统交互最复杂的页面左边是理发师列表中间是时间轴点击空白格弹出预约弹窗选择服务项目后自动计算时长并渲染区间。前后端联调时最大的坑是跨域。我在SpringBoot里配置了全局CORSConfiguration 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); } }需要提醒的是上线部署时如果前端和接口通过Nginx映射到同一个域名下用location /api反代跨域问题从根本上就消失了不需要在代码里放开CORS。4.3 预约流程的完整实现链路预约功能从点击到最后落库完整链路是前端提交AppointmentRequest→ Controller接收并做参数校验 → Service层开启事务 → 校验会员身份和状态 → 校验理发师当天排班时间内是否合规 → 执行时间冲突检测SQL → 计算end_time→ 插入预约记录 → 返回预约成功信息。关键代码集中在时间冲突检测。这里用MyBatis-Plus的QueryWrapper可以这样写long conflictCount appointmentMapper.selectCount( new QueryWrapperAppointment() .eq(barber_id, request.getBarberId()) .eq(appointment_date, request.getAppointmentDate()) .in(status, Arrays.asList(1, 2)) // 待确认、已确认 .lt(start_time, endTime) .gt(end_time, startTime) ); if (conflictCount 0) { throw new BizException(400, 该理发师在所选时间段已有预约请更换时间); }这里用的是start_time endTime AND end_time startTime也就是我前面讲的区间重叠公式。endTime的计算是LocalTime.parse(request.getStartTime()).plusMinutes(serviceItem.getDurationMinutes() bufferMinutes)。有个细节容易被忽略预约日期边界。如果预约时间跨天比如晚上10点开始服务时长90分钟endTime到了23:30但部分服务可能做到午夜。虽然理发店很少这么晚但保险起见我做了跨天处理日期存LocalDate时间存LocalTime跨天预约场景先不做直接限制最晚预约开始时间为21:00。该限制写在前端表单校验和后端Service校验两层里双保险。4.4 排班管理与定时任务处理理发师的排班我做成了一张独立的barber_schedule表字段是barber_id、work_date、start_time、end_time、status正常/休息。这样比在barber表里存JSON更规范也方便后期做调休管理。预约提交时除了查预约冲突还要先查该理发师当天是否有排班、预约时间段是否落在排班区间内。两个校验都通过才允许落库。另外我写了一个定时任务每天早上6点把当天排班信息加载进Redis缓存key是barber:schedule:{date}value是JSON数组。前端查询“某天某理发师的空闲时间”时直接走缓存大幅降低数据库压力。定时任务用SpringBoot自带的Scheduled(cron 0 0 6 * * ?)并在启动类上加EnableScheduling开启定时支持。要注意Redis缓存里的排班信息如果有变更比如理发师临时请假必须主动删除对应key让下次查询时回源数据库而不是等待缓存过期。我实现了一个CacheService专门管理这把缓存排班变更接口里调用删除逻辑。4.5 统计报表的SQL编写心得统计报表是这类系统的门面功能老板最关心的就是“这个月赚了多少”“哪个理发师最赚钱”。我做了三个核心报表营业额日报按天聚合订单金额SELECT DATE(create_time) AS day, SUM(amount) AS total_amount, COUNT(*) AS order_count FROM orders WHERE create_time #{startDate} AND create_time #{endDate} GROUP BY DATE(create_time) ORDER BY day;理发师业绩排行按月统计每个理发师的完单数和营业额SELECT b.name AS barber_name, COUNT(o.id) AS finish_orders, SUM(o.amount) AS total_amount FROM orders o JOIN barber b ON o.barber_id b.id WHERE o.status 3 AND o.create_time #{monthStart} GROUP BY b.id, b.name ORDER BY total_amount DESC;热门项目排行分析服务项目销售占比SELECT s.name AS service_name, COUNT(od.id) AS sale_count, SUM(od.amount) AS total_amount FROM order_detail od JOIN service_item s ON od.service_item_id s.id GROUP BY s.id, s.name ORDER BY sale_count DESC LIMIT 10;MySQL 8.0支持窗口函数如果要做“环比增长率”“Top N”这种分析直接用ROW_NUMBER() OVER(PARTITION BY ...)解决比在Java代码里多次查询再内存计算高效得多。4.6 后端API列表一览为了方便前端对接我把主要的API设计整理成一个对照表模块请求方式路径功能说明认证POST/api/auth/login会员/管理员登录发放JWT认证POST/api/auth/register会员注册会员GET/api/member/profile获取当前会员信息会员PUT/api/member/balance/recharge储值充值会员GET/api/member/transactions会员储值流水理发师GET/api/barber/list理发师列表筛选在职理发师GET/api/barber/{id}/schedule某理发师某天的排班服务GET/api/service/list服务项目列表仅上架预约POST/api/appointment新增预约预约GET/api/appointment/mine我的预约列表预约PUT/api/appointment/{id}/cancel取消预约统计GET/api/report/daily每日营业额统计5. 常见问题与排查技巧实录5.1 预约时间冲突检测失效的排查这是我在测试阶段踩过最大的坑。场景是A用户预约了10:00到11:00的剪发B用户紧接着预约10:30到11:30的染发系统居然提示预约成功。查问题花了一个下午最后发现根源在于LocalTime的JSON反序列化格式不匹配。前台传的是字符串10:30后端DTO里的字段类型是LocalTime如果没有加JsonFormat(pattern HH:mm)注解Jackson默认反序列化格式是HH:mm:ss直接抛异常。我是在全局异常兜底里看到的错——前端收到了“系统繁忙”但具体原因被吞了还以为是自己代码逻辑写错了。排查思路给各位参考打开MyBatis的SQL日志log-impl: org.apache.ibatis.logging.stdout.StdOutImpl看实际执行的SQL是不是预期的冲突条件。检查DTO字段的时间格式确认反序列化没报错。手工在数据库里插入一条冲突记录验证SQL逻辑本身是否正确。检查事务边界确认冲突检测和插入预约是否在同一个事务里避免检测完还没插入另一个请求就插入了。这里明确一点冲突检测和落库插入必须同一个事务否则两个请求同时通过检测然后先后插入依然会撞车。不过要彻底解决并发问题光靠事务不够还需要给预约表加唯一约束。我的做法是加了一个uk_barber_date_start唯一索引字段是barber_id appointment_date start_time数据库层面兜底双保险。5.2 会员余额并发扣减导致负数上线后遇到一个bug两个订单同时结算都通过了“余额是否充足”的校验结果余额被扣成了负数。根因是传统的SELECT balance再在Java代码里比较再UPDATE的模式存在时间差并发请求全都读到了同一个旧的余额值。解决方案就是我前面提到的条件更新SQLUPDATE member SET balance balance - #{amount} WHERE id #{memberId} AND balance #{amount}这条SQL在数据库层面原子地完成了“判断余额够不够”和“扣减”两步操作。Java代码里判断影响行数如果是0说明余额不足或会员不存在直接抛异常。这个方案简单、可靠完全够用不需要引入分布式锁这种重型方案。顺带补充如果是高并发秒杀类的场景条件更新也可能因为InnoDB的行锁等待产生性能瓶颈但理发店的并发量级完全不用担心。5.3 Redis缓存与数据库数据不一致做首页展示时我把“热门服务项目”和“推荐理发师”缓存到了Redis大大加快了首页加载速度。但出现了另一个问题管理员在后台修改了项目价格或下架了某个项目前台首页却没有立刻生效还是旧数据。原因是修改接口只更新了数据库没有清理Redis缓存。解决方案是在写操作成功的事务提交后主动删除对应缓存keyTransactional public void updateService(ServiceItem item) { serviceItemMapper.updateById(item); cacheService.delete(home:hot_services); }需要留意的是删除时机。如果在事务提交前删缓存另一个请求可能先查询到旧数据库值再写回缓存依然不一致。稳妥的做法是用TransactionalEventListener监听TransactionPhase.AFTER_COMMIT事件在事务真正提交后再删除缓存。虽然步骤多了一层但可以做到万无一失。5.4 前端上传图片失败的排查理发师头像和项目图片用的是本地磁盘存储Nginx映射静态资源路径。开发环境一切正常部署到服务器后头像上传一直报404。排查了一圈发现是Nginx配置的问题前端上传请求到了服务端服务端保存成功但前端回显图片路径时浏览器直接发起了对图片URL的请求Nginx没把那个路径映射到磁盘目录。解决方法是调整Nginx的静态资源映射location /uploads/ { alias /data/barber_shop/uploads/; expires 7d; }另外图片上传接口要限制文件大小和类型直接在SpringBoot配置spring.servlet.multipart.max-file-size为5MB、max-request-size为10MB超出返回友好提示。5.5 JWT过期与前端路由跳转的处理前端遇到的另一个典型问题是登录后闲置半小时再点击页面操作接口返回401页面直接白屏。前端 axios 拦截器收到 401 后需要做两件事清除本地Token、跳转登录页并携带redirect参数让登录成功后回到原页面。// request.js 响应拦截器 if (error.response error.response.status 401) { localStorage.removeItem(token); window.location.href /login?redirect encodeURIComponent(window.location.pathname); }同时后端需要在拦截器里放过“登录、注册、获取验证码”等公开接口避免登录页自身也走鉴权导致循环跳转。可以用一个Whitelist集合做路径匹配集中管理放行规则。5.6 常见问题速查表现象可能原因解决方案预约时提示“该时段已有预约”但界面显示有误冲突检测SQL时间边界写反用newStart existEnd AND newEnd existStart严格走开区间余额充足却结算失败条件更新影响行数为0存在并发修改检查同账号是否有多个会话并发操作打印SQL确认条件JWT登录成功但请求接口401Token未放在请求头或密钥不一致确认Authorization: Bearer token格式确认JWT_SECRET一致前端看到日期少一天时区问题JDBC URL加serverTimezoneAsia/ShanghaiJackson配置time-zone定时任务没执行启动类没有EnableScheduling补注解确认cron表达式含义预览图片404Nginx没映射静态目录或映射路径错检查/uploads/的alias配置确保和存储路径一致Maven依赖冲突导致启动失败SpringBoot版本与MyBatis-Plus版本不匹配统一用2.7.x搭配MyBatis-Plus 3.5.x用mvn dependency:tree查冲突5.7 如何把项目从源代码管理到部署上线项目代码管理我用Git早期就初始化仓库每次功能模块完成就commit一次写清楚的commit message——比如feat: 完成会员储值模块、fix: 修复预约时间冲突检测边界。如果团队协作建议用分支开发再合并到主分支。部署环节我提供两个方案。方案A是JAR包直接部署适合演示和轻量上线。执行mvn clean package -DskipTests打包把生成的JAR传到服务器java -jar barber-shop-1.0.0.jar --spring.profiles.activeprod启动。配合Systemd服务配置实现开机自启和崩溃重启。方案B是Docker化部署。写一个简单的Dockerfile基于openjdk:8-jre-alpine镜像把JAR打进去用docker-compose.yml同时编排MySQL、Redis和应用容器。这样整套环境一条命令拉起环境一致性问题彻底解决。数据库初始化脚本用Flyway管理版本每次表结构变更新建一个V{版本号}__description.sql脚本启动时自动执行避免手动同步数据库结构的烦恼。这个实践在生产环境尤其重要。5.8 后续功能扩展的几个方向项目做到这个程度核心链路已经完整。如果还有余力我建议往这几个方向扩展难度递增但收益明显一是营销模块。加优惠券系统包含满减券、折扣券、新人券。给会员等级再加一档“充值返还”活动这个业务规则不难但能显著提升系统的“完整感”在演示和答辩时也是一个加分点。二是消息通知。预约成功、预约提醒、消费结算都通过短信或微信模板消息推送给用户。先用SpringBoot自带的JavaMailSender发邮件做低配版接第三方短信平台也不是难事。三是数据分析面板。目前SQL已经能统计营业额画成ECharts折线图、饼图、柱状图能显著提升系统的“高级感”。做一个“本月会员增长趋势”和“项目收入占比”老板看了直点头。四是多门店支持。在核心表都加store_id字段把预约、理发师、服务项目范围收敛到门店系统就从单店版升级成了连锁版。涉及重构但表结构预留了扩展空间这个方向优先级可以放后面。6. 写在最后的几点真话整个项目做下来我最大的体会是技术栈只是工具业务逻辑才是骨架。SpringBoot把很多底层细节封装的非常“省心”这反而要求开发者在业务设计上想得更深——表结构合不合理、并发场景有没有兜底、状态流转有没有漏洞这些才是系统能不能稳定运行的关键。最后分享一个我踩过的小坑开发机上一切正常但部署到服务器后系统时间比本地晚8个小时。预约记录显示的时间完全错乱。排查到头是JVM默认时区和MySQL连接时区设置不一致。解决方式是在启动参数加-Duser.timezoneAsia/ShanghaiJDBC连接串带serverTimezoneAsia/Shanghai前端请求后端返回的时间格式统一用时间戳或标准字符串。这一下子解决了我大半的时间和日期相关的诡异问题。如果你照着这个思路做一个类似的会员预约系统祝少踩坑。遇到问题别慌先把日志翻出来看再对照我上面整理的排查清单找方向。动手过一次你才能真正理解SpringBoot的自动装配、事务控制、缓存应用这些东西在实际业务里是怎么运转的。
返回列表