做同城服务项目这几年,我越来越觉得“按摩养生系统”这类本地生活项目,是最适合拿来练手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 17,ORM选了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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单金额我强烈建议用整数分存,不要用decimal或double。按摩项目里经常出现“满减、折扣、优惠券分摊”这些逻辑,用double计算很容易出现0.1+0.2不等于0.3的问题,月结时少一分钱都是事故。把金额做成分单位的长整型,展示层再除以100,这是Java后端的基本素养。
2.2 预约排班与锁单怎么设计
同城预约系统最怕“撞单”:技师只有一个,时段被重复预约。排班表我单独建了technician_schedule,这就是技师的“时间库存”。每条记录代表技师在某一天某个时间段的可服务状态,包含技师ID、日期、开始时间、结束时间、状态。
插入订单的时候,不能只检查技师schedule里的状态,因为两个并发请求同时读到“空闲”就会双双通过。我的做法是先扣减排班库存,再创建订单。核心SQL用“条件更新”代替“先查询后更新”:
int updated = techScheduleMapper.update(null, new LambdaUpdateWrapper<TechnicianSchedule>() .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 Map<OrderStatus, Set<OrderStatus>> 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生成的幂等键,后端收到后先查Redis:SET 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实战路上提供一点真实可用的参考。