做同城服务项目这几年我越来越觉得“按摩养生系统”这类本地生活项目是最适合拿来练手Java实战落地的场景之一。它不像电商那样纯拼并发也不像企业级OA那样追求流程堆砌而是把预约、派单、支付、会员、位置服务、订单状态机这些东西全部串在一条真实业务链路上。我帮本地一家连锁养生馆做过一套同城预约平台后端完全用Java落地今天就把这套“按摩养生系统”源码背后的核心设计拆开聊聊讲清楚每个模块为什么这么做、代码该怎么写、上线又会踩哪些坑。如果你正在学Java或者已经在做CRUD但想找个项目源码提升一下又或者准备面试中级开发岗位这套系统特别值得参考。它把Java基础、Spring Boot、MySQL、Redis、消息队列、分布式锁这些点全部用上了而且业务规则足够复杂不会让你看完源码只记住“增删改查”。我会从整体思路、数据库设计、预约下单链路、LBS派单、缓存与一致性、落地问题六个方向展开尽量用“过来人”的口吻把关键细节讲透。1. 同城按摩养生系统的整体设计思路1.1 这类系统到底要解决什么问题先抛开技术说说业务。按摩养生门店过去主要靠电话预约和线下排队后来挂到公域流量平台轻松是轻松但每一单都要交平台费而且客户资料不完全在自己手里。所谓“同城服务”本质是搭建一套属于自己的预约撮合体系用户在微信小程序或者APP上选门店、选技师、选服务时段下单支付后系统安排技师上门或者到店服务服务结束后订单完成、资金结算。这套系统最核心的价值不是“把门店搬上线”而是把三个角色用户、技师、门店运营之间的时间冲突、位置匹配、资金流转统一管起来。按摩养生业务的特殊性在于标准化程度低同一技师同一时段只能服务一个用户排班一旦冲突就会引发客诉和退单同时它又有极强的LBS属性用户在意的往往不只是价格还有“技师现在离我多远”“能不能马上到”。所以系统设计的重心要放在排班锁单、位置匹配和状态流转上。1.2 技术选型为什么是Java这一套我见过不少团队用PHP或者低代码平台做这类系统确实快但业务一旦复杂起来订单状态、派单规则、财务分账这些逻辑很容易写成一坨“面条代码”。Java的生态优势在这里体现得很明显Spring Boot框架成熟社区案例多招人相对容易Java强类型和工程化约束能让多人协作时的代码下限更高更重要的是后面如果要接支付、地图、短信、IM这类第三方能力Java的SDK和文档几乎都是最齐全的。具体到我做这套项目的版本选型用的是Spring Boot 3.2 JDK 17ORM选了MyBatis-Plus数据库MySQL 8缓存Redis 6消息队列RabbitMQ对象存储用云OSS地图服务接高德。微服务架构我一开始就没打算用。一个连锁养生品牌一天峰值订单量可能也就几千单单体应用完全能扛住反而微服务会引入服务治理、分布式事务的额外复杂度。我把项目结构按模块拆出来虽然代码在一个工程里但用户模块、订单模块、技师模块、支付模块边界清晰后面如果真要拆分也能顺滑地迁出去。1.3 核心业务模块划分按摩养生系统如果按端来分至少包括这么几块端核心功能关键模块用户端浏览门店/项目、选技师、预约下单、支付、优惠券用户中心、预约中心、支付技师端接单/拒单、查看行程、上下班、收益明细派单中心、行程管理、结算管理后台门店/技师/项目配置、订单处理、财务审核运营后台、财务中心基础服务消息推送、短信通知、位置服务、对象存储消息中心、LBS服务在做源码架构的时候我特别强调“业务状态统一收敛到后端”。前端只是展示所有状态流转必须由后端Service层控制尤其是订单状态绝对不能由前端按钮随便跳。这也是代码可维护性的关键后面读源码时你会发现大量坑都是因为状态散落导致。到这里整体思路已经清楚接下来要把业务落到数据模型上。2. 核心业务建模与数据库设计2.1 用户、技师、门店与订单的关系数据库设计决定了这套系统能走多远按摩养生系统不像标准电商那么简单核心实体包括用户、技师、门店、服务项目、排班、订单、优惠券、支付流水。这里我建议关系不要做太复杂能冗余就冗余能用ID引用就不要搞多级嵌套。订单表是整张数据模型里的“枢纽”。我的order_main表大概长这样CREATE TABLE order_main ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号唯一, user_id bigint NOT NULL COMMENT 下单用户ID, technician_id bigint NOT NULL COMMENT 技师ID, store_id bigint DEFAULT NULL COMMENT 门店ID上门单可为空, service_type tinyint NOT NULL COMMENT 服务方式1到店 2上门, order_status tinyint NOT NULL COMMENT 订单状态1待支付 2已支付 3已派单 4服务中 5已完成 6已取消 7退款中 8已退款, appoint_start datetime NOT NULL COMMENT 预约开始时间, appoint_end datetime NOT NULL COMMENT 预约结束时间, address varchar(255) DEFAULT NULL COMMENT 上门地址, lng decimal(10,6) DEFAULT NULL COMMENT 经度, lat decimal(10,6) DEFAULT NULL COMMENT 纬度, total_amount bigint NOT NULL COMMENT 总金额单位分, pay_amount bigint NOT NULL COMMENT 实付金额单位分, pay_status tinyint NOT NULL DEFAULT 0 COMMENT 支付状态0未支付 1已支付 2已退款, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_technician_appoint (technician_id, appoint_start, appoint_end), KEY idx_user_create (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;订单金额我强烈建议用整数分存不要用decimal或double。按摩项目里经常出现“满减、折扣、优惠券分摊”这些逻辑用double计算很容易出现0.10.2不等于0.3的问题月结时少一分钱都是事故。把金额做成分单位的长整型展示层再除以100这是Java后端的基本素养。2.2 预约排班与锁单怎么设计同城预约系统最怕“撞单”技师只有一个时段被重复预约。排班表我单独建了technician_schedule这就是技师的“时间库存”。每条记录代表技师在某一天某个时间段的可服务状态包含技师ID、日期、开始时间、结束时间、状态。插入订单的时候不能只检查技师schedule里的状态因为两个并发请求同时读到“空闲”就会双双通过。我的做法是先扣减排班库存再创建订单。核心SQL用“条件更新”代替“先查询后更新”int updated techScheduleMapper.update(null, new LambdaUpdateWrapperTechnicianSchedule() .eq(TechnicianSchedule::getId, scheduleId) .eq(TechnicianSchedule::getStatus, ScheduleStatus.FREE.getStatus()) .set(TechnicianSchedule::getStatus, ScheduleStatus.LOCKED.getStatus())); if (updated 0) { throw new BizException(该技师当前时段已被预约请更换时间); }这一步就是把“库存扣减”和“状态校验”合并成一个原子操作。数据库行锁会帮你挡住并发后面再创建订单哪怕中途失败也能通过事务回滚把排班状态改回FREE。这个设计思路和林超闲那个“超卖”问题是一样的不要在代码里先查再改要利用数据库的原子更新。2.3 服务项目与价格策略建模服务项目不是一张简单的商品表因为按摩养生系统的“SKU”维度比电商多。同一个“全身推拿”技师等级是初级、高级、金牌价格就不一样工作日和节假日价格也可能不一样上门服务还要另收上门费。我建议把“服务项目”和“项目价格策略”分开。service_item存项目名称、时长、默认图片、描述item_price_strategy存价格、技师资历等级、时段类型甚至按温度地区做不同价格。价格计算这块我踩过坑一开始做成了“前端传总价后端只校验数据库里的项目单价”结果前端改个金额后端就收错钱。正确的设计是后端根据项目ID、技师等级、服务日期、优惠券自己重新计算价格前端传的价格只能作为参考展示。这样不仅更安全也方便后续运营调整价格策略而不依赖App发版。到这里数据模型已经能支撑核心业务接下来重点看下单这条链路——也就是这套源码里最值得读的部分。3. 源码密码预约下单链路与状态机实现3.1 下单接口的详细时序预约下单是所有模块的交汇点一定要先把时序理清楚再写代码。我的下单接口调用链是这样的用户端提交预约参数技师ID、服务项目ID、预约开始时间、服务方式、上门地址或门店ID。后端校验用户状态、技师是否上下班、项目是否上架。根据项目时长和技师排班计算出“预约结束时间”。查询并锁定一个可用的schedule排班时段。计算价格项目价格 上门费 时段加价 - 优惠券抵扣。生成订单号创建订单状态为待支付。调用支付接口返回预支付参数给前端。订单创建成功但排班在未支付前不真正占用只做一个“临时锁”。你可能会问我上面刚说“条件更新排班状态”这里怎么又说未支付前不真正占用因为要区分“创建订单临时占位”和“支付成功后永久锁定”。我的设计是创建订单时给schedule加一个乐观锁版本号但不是改成LOCKED而是记录一个占用的order_no支付成功回调后再把状态改为LOCKED。这样用户如果不支付超时释放就只需要把order_no清空。3.2 订单状态机的设计要点订单一旦建立就进入状态流转。Java里实现状态机有很多方式Spring StateMachine很重我更喜欢用一个简单的枚举状态机代码量小而且面试时容易讲清楚。核心是一个Map维护“当前状态 - 可允许操作集合”。public enum OrderStatus { WAIT_PAY(1), PAID(2), ASSIGNED(3), SERVICE_START(4), FINISHED(5), CANCELED(6), REFUNDING(7), REFUNDED(8); private static final MapOrderStatus, SetOrderStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(WAIT_PAY, new HashSet(Arrays.asList(PAID, CANCELED))); TRANSITIONS.put(PAID, new HashSet(Arrays.asList(ASSIGNED, REFUNDING, CANCELED))); TRANSITIONS.put(ASSIGNED, new HashSet(Arrays.asList(SERVICE_START, CANCELED))); TRANSITIONS.put(SERVICE_START, new HashSet(Arrays.asList(FINISHED))); TRANSITIONS.put(REFUNDING, new HashSet(Arrays.asList(REFUNDED))); } public boolean canTransitTo(OrderStatus target) { return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target); } }真正执行状态变更时先调用canTransitTo校验再更新数据库里的status。注意这里不能只在内存里校验更新SQL还要加上“status旧状态”条件防止两个请求同时把订单从待支付改成其他状态。我在源码里用了一个统一的OrderStateManager所有状态变更都必须经过它避免Service层里到处出现order.setStatus(OrderStatus.FINISHED)这种自由修改。3.3 防重复下单与幂等方案预约系统里用户手一抖、前端重试、支付回调重复推送都会导致同一张订单被创建多次。我做了三层幂等防护。第一层前端提交时带上一个由UUID生成的幂等键后端收到后先查RedisSET order_idempotent_{userId}_{scheduleId} 1 NX EX 120。如果设置失败说明同一个用户同一个排班正在下单直接拦截。第二层order_main表中加一个biz_idempotent字段并建唯一索引。即使Redis失效数据库唯一索引也会兜底。第三层支付回调天然具有幂等性微信支付官方要求回调键是商户订单号我们直接用订单号去更新更新前先查状态如果已经是“已支付”就返回成功不再重复处理。3.4 支付回调与退款闭环按摩养生系统经常出现“上门前取消”“服务中觉得手法不满意要退款”所以支付和退款链路不能只有正向流程。支付回调收到后不要直接在回调方法里写一大堆业务逻辑。我的回调Controller只做两件事验签、投递MQ。真正处理订单状态和排班锁定的逻辑放在MQ消费者里好处是回调接口响应快微信不会因为超时而重试坏处是消息可能重复消费所以消费者里第一步要按订单号幂等。退款闭环也要用状态机用户发起退款 - 订单进入退款中 - 调用支付平台退款接口 - 回调成功 - 订单变已退款 - 释放排班。这里特别容易漏一点退款成功后必须把技师的排班恢复成FREE还要把优惠券使用的记录恢复。如果退款只改了支付状态排班仍然被占着技师端就会出现后面一整天没法接单。下单链路讲通了下一步是同城服务里另一个重头戏——怎么找附近技师、怎么派单。4. 同城派单与LBS距离计算的工程实践4.1 技师实时位置与附近搜索用户打开小程序后系统要拿到用户经纬度找到附近多少公里内能接单的技师。技师实时位置怎么来不能全靠手机GPS实时上报那样太耗电。我的做法是技师每次上下班打卡时上报一次经纬度如果技师正在上门途中则由App每5分钟上传一次位置后端把这些位置更新到Redis GEO同时落MySQL。查询附近技师时优先走Redis。Redis GEO的操作很简单GEOADD tech:location 116.397128 39.916527 tech_1001 GEORADIUS tech:location 116.397128 39.916527 5 km ASC COUNT 20GEORADIUS返回的技师列表默认按距离排序非常适合“最近技师”这种场景。但注意Redis GEO只适合粗筛真要精确算距离并且配合订单筛选、评分筛选还是得拉到业务库里再算一次。4.2 距离计算SQL还是GeoHash数据量小的时候直接MySQL计算距离反而最简单SELECT technician_id, 6371 * 2 * ASIN(SQRT(POW(SIN((#{lat} - lat) * PI() / 180 / 2), 2) COS(#{lat} * PI() / 180) * COS(lat * PI() / 180) * POW(SIN((#{lng} - lng) * PI() / 180 / 2), 2))) AS distance FROM technician_location HAVING distance 5 ORDER BY distance这串公式就是Haversine公式原理是求球面上两点间的最短大圆距离。因为表里加了(lat, lng)索引配合经纬度范围过滤这个SQL在几十万技师量的情况下依然能用。但如果日后做到多城市几百万技师就要考虑GeoHash方案把经纬度转成字符串前缀先通过前缀快速缩小范围再用Haversine精确过滤。GeoHash不是万金油边界问题要处理但对于同城服务半径几公里这个场景已经够用了。4.3 派单策略与手动派单兜底自动派单不是“谁近派给谁”否则技师永远只服务核心地段不赚钱的单子没人接。我用的派单打分公式是这样的score 距离分 * 0.5 评价分 * 0.3 空闲时长分 * 0.2距离分可以由GEORADIUS排序得出评价分取技师最近30天平均星级空闲时长分取技师距离上一单结束的分钟数。综合得分最高的技师进入候选队列然后推送App通知。这里一定要设置“等待接单超时”比如5分钟内不接单自动顺延给下一个候选人否则用户那边干等。很多团队会忽略手动派单。现实业务里经常出现“技师A正好在用户隔壁小区但系统推给了几公里外的技师B”这种情况运营后台必须能人工干预。我做了管理后台地图圈选运营人员在地图上画一个圈列出圈内技师手动点击指派。自动派单和手动派单共用同一个“确认接单”流程避免出现两套状态。5. 性能、缓存与数据一致性优化5.1 Redis缓存的正确使用姿势同城服务系统的数据一致性很重要但也不是所有数据都不能缓存。我的缓分层级是这样的门店列表、服务项目列表、公告资讯这类“读多写少”的数据用Redis缓存缓存时间5到10分钟技师的实时状态忙碌/空闲/离线也是高频读数据用Redis String或Hash存更新时直接写Redis再异步落库订单详情不走缓存因为订单状态变化频繁且强一致直接查MySQL更稳。缓存更新我用了Cache-Aside模式读时查缓存miss则查库回填写时先更新数据库再删除缓存。为什么是“删除缓存”而不是“更新缓存”因为并发场景下更新顺序很难控制删掉缓存让下一次查询回填反而更简单。为了避免删除失败导致的脏数据我加了延迟双删更新数据库后删除缓存等200毫秒再删一次防止请求A读旧数据回填后又被写进去。5.2 并发场景下的库存扣减与数据一致性这里的“库存”就是排班时段。涉及并发最关键的两把锁排班锁和优惠券锁。我前面讲的下单“条件更新”其实就是数据库行锁已经解决了排班并发。优惠券也一样用户用券下单时对优惠券表做条件更新把status从“未使用”改成“已锁定”更新条数为0就说明券已经被用了。还有一个反向一致性问题支付成功回调更新订单状态后Redis里的技师状态还是“空闲”如果不处理用户端会看到空闲技师点进去却预约不了。我在支付回调里通过MQ消费者更新Redis里的技师状态这个操作允许短暂延迟因为秒级延迟用户是可接受的。对账逻辑则放在每天凌晨以数据库订单表为基准核对Redis技师状态与真实订单把不一致的数据修正回来。5.3 优雅处理分布式事务很多Java面试题里喜欢问“分布式事务怎么解决”但实际项目里我不会上来就上Seata或者TCC。按摩养生系统的核心事务范围是“创建订单 锁定排班 扣减优惠券”这三个操作在同一个Spring事务里走本地数据库事务就够了。只有“支付成功”后发MQ通知技师这个动作才会跨系统。跨系统的最终一致性我用了一个很朴素但可靠的做法本地消息表。支付回调更新订单状态时在同一事务里插入一条message_publish记录状态为待发送事务提交后定时任务扫描待发送消息调用MQ生产接口发送成功再改状态。这样即使MQ宕机消息也不会丢最多延迟处理。如果消息消费方不成功就靠消费方的重试和手工补偿。你说这样麻烦吗有点麻烦但“下单锁排班”这个操作之前已经通过条件更新保证了实时一致剩下的消息延迟是业务可接受的。过度设计分布式事务最后只会让系统链路复杂到没人敢改。6. 项目落地踩坑实录与源码阅读建议6.1 本地生活服务最容易遇到的坑这个项目我从开发到上线遇到过的坑比想象中多得多挑几个最有代表性的记录在这里。第一个坑是支付回调里的“长任务”。第一版我直接在微信回调里写死了“更新订单、锁排班、发短信、推App通知”用户高峰期回调接口响应经常超时微信反复重试导致订单被更新两次。后来改成回调只做验签投递MQ不到100毫秒就返回成功问题彻底解决。第二个坑是技师端网络差。技师在电梯、地下停车场App上传位置经常失败客服电话被打爆。最后我做了“消息必达补偿”App端维护一个本地任务队列位置信息失败就存本地有网了再补报服务端允许位置上报时间戳稍微旧一点不强行要求实时。第三个坑是金额计算的精度。我有一次把优惠券抵扣的金额用double计算结果算出来实付金额比应收多一分钱客服花了两天对账。自从统一改成分单位Long后再没出过这类问题。我把常见问题整理成一个速查表方便遇事快速定位现象可能原因处理思路用户能下单但技师没有收到MQ消费失败或App推送token过期消息表重试 补推短信技师排班被重复占用支付回调重复消费订单幂等表 状态机校验附近搜不到技师坐标不是GCJ-02标准坐标统一转换后再存储/检索用户支付成功但订单还是待支付回调报文丢失定时主动查单/微信查单接口补偿优惠券用了但订单退款后没恢复退款流程漏处理统一走退款状态机更新代金券6.2 新手如何读源码才能看到重点很多刚学完Java基础的人拿到一套源码喜欢从启动类开始一行行读结果读到一半就放弃了。我的建议是盯着“一条主线”读比如订单这条链路Controller层入口 - Service层的下单方法 - Mapper层SQL - 支付回调 - MQ消费者。每读到一个方法就问自己三件事这个方法做了什么它被谁调用了它和订单状态有什么关系这套系统里的代码就非常适合这种读法因为里面的订单状态机、幂等、缓存与数据库一致性、数据一致性补偿全是Java开发面试里被问烂但在实战中又极易出错的点。你如果能把“创建订单到支付成功再到技师接单”这条完整的调用链讲清楚面试官基本就会认定你有真实项目经验。我当年带实习生的时候也让他们先画这条时序图再读代码效率能提升一倍。还有一点读源码一定要配合SQL日志。MyBatis-Plus可以配置sql日志输出到控制台把下单操作打开你能看到每一条SQL的执行顺序。然后试着关掉事务、制造一次数据异常观察排班是否回滚这就是调试能力。6.3 这类系统的后续扩展点同城按摩养生系统做完第一版之后可以扩展的方向非常多。可以把单纯的预约系统改造成会员体系加入次卡、充值卡、积分、分销裂变可以加多城市多门店把单体的数据权限做成行级权限每个门店只能看自己的订单和技师可以做更加智能的派单比如接入外卖那种“顺路单”让技师在下班途中顺便接一单也可以是业务做大了以后把订单、用户、支付、技师这四个模块拆成微服务引入服务注册与发现、配置中心、网关。但我真心建议如果没有明确的性能瓶颈和多人协作需求不要一上来就拆分。把单体做扎实模块边界清楚只换不拆是中小企业成本最低的路线。这套系统的技术栈也足够支撑你从单体平滑过渡到时候改造成本主要在代码移动而不是重构业务。最后分享一个我自己调试时的小技巧在上门地址和技师位置这类关键数据上我会在实体类里加一个lastUpdateTime打印日志时把它带出来。遇到“明明执行了却像没执行”的诡异问题时先看时间戳往往能直接看出是不是缓存没刷新、回调是不是走了旧链路。做这类系统最大的体会是技术难点从来不是单个接口有多难而是所有环节串起来以后如何保证数据最终一致。把状态机想清楚、把幂等做好、把排班锁做对这个项目的骨架就算稳了。希望这篇文章能给你在Java实战路上提供一点真实可用的参考。