上门服务系统里,用户改约、客服改档、师傅端日程不同步,是上线后最高频的扯皮点。常见反模式是:用户端改时间字段,师傅端再改一遍本地日历,两边各写各的。更好的做法是:改约是事件——服务端改槽位、写流水、发事件;师傅端只消费事件更新日程。
下文用表结构与伪代码说明事件模型、版本号、失败重推与验收口径。示例为教学示意。
问题:两端各改时间字段会怎样
- 用户端显示明天 10:00,师傅端仍是今天 15:00
- 推送乱序:先到的旧消息覆盖新时间
- 客服在后台改档,师傅 App 无感知,空跑一次
- 售后只听电话口述,系统里找不到「谁改过、何时改过」
验收标准应写成:改约成功后,师傅端日程与服务端槽位一致;旧推送不能盖新时间;后台能回放改约流水。
事件模型:改槽位 + 流水 + 发布
defreschedule(order_id:str,new_slot:str,actor:str)->dict:old=get_slot(order_id)ifold==new_slot:return{"ok":True,"noop":True}version=next_version(order_id)save_slot(order_id,new_slot,version=version)append_log(order_id,old=old,new=new_slot,actor=actor,version=version)publish("appointment.rescheduled",order_id=order_id,new_slot=new_slot,version=version)return{"ok":True,"version":version}表结构示意:
CREATETABLEservice_order(order_idVARCHAR(32)PRIMARYKEY,slot_startDATETIMENOTNULL,slot_versionINTNOTNULLDEFAULT1,worker_idVARCHAR(32)NULL,statusVARCHAR(32)NOTNULL);CREATETABLEappointment_log(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_idVARCHAR(32)NOTNULL,old_slotDATETIMENULL,new_slotDATETIMENOTNULL,actorVARCHAR(64)NOTNULL,versionINTNOTNULL,created_atDATETIMENOTNULL);槽位冲突检测也应在服务端做:同一师傅同一时段是否已有其它单。冲突时拒绝改约并返回可读原因,而不是先改成功再让师傅端报错。
师傅端消费:用版本号丢弃旧推送
defon_rescheduled(evt,local_calendar):item=local_calendar.get(evt.order_id)ifitemandevt.version<=item.version:return# 旧事件,直接丢local_calendar.upsert(order_id=evt.order_id,slot=evt.new_slot,version=evt.version)refresh_agenda_ui()用户端展示以服务端时间为准;本地缓存只作加速,冲突时拉详情接口覆盖。
defget_order_view(order_id:str)->dict:row=load_order(order_id)return{"order_id":order_id,"slot_start":row.slot_start.isoformat(),"version":row.slot_version,"status":row.status,}师傅端冷启动时,建议全量拉取当日日程,再增量订阅事件,避免只靠推送导致漏改约。
通知失败:重试队列 + 后台可重推
推送通道不稳时,不能只依赖一次 Push。建议:
- 事件先落出站队列表,异步投递
- 失败进入重试,带退避
- 后台提供「按订单重推改约」按钮,写审计
defenqueue_push(order_id:str,version:int)->None:outbox.add(topic="appointment.rescheduled",key=f"{order_id}:{version}",payload={"order_id":order_id,"version":version},)defretry_failed(max_n=100):forjobinoutbox.failed(limit=max_n):ifdeliver(job):outbox.mark_done(job.id)else:outbox.bump_retry(job.id)出站表的幂等键用order_id:version,避免同一版本被重复推送刷屏;新版本则允许再推。
和调度、结算的边界
改约事件只负责「时间真相」。调度算法(怎么派最近师傅)与结算核对(上门完成后如何对账)是相邻域,不要塞进同一个回调函数。改约成功可以触发「重新评估是否仍派原师傅」,但那是调度订阅改约事件,而不是改约函数里写死派单。
商务结算规则由客户确定;系统侧不抽成客户平台订单。系统提供的是槽位、流水与导出能力。
验收清单
- 用户改约后,师傅端 1 分钟内日程一致(或可手动刷新对齐)
- 故意先推旧版本再推新版本,端上最终为新时间
- 推送通道关闭时,出站表有失败记录,后台可重推成功
- 改约流水可按订单回放:谁、何时、旧值、新值
- 槽位冲突时改约失败且原因可读
成品怎么接住
光合同城上门服务模块成品覆盖预约、改约与师傅端日程同步:改约走服务端事件与流水,师傅端按版本更新。模块可单独部署,并共享中台能力底座,后期可叠加其它业务。私有化源码交付后,验收「改约后师傅可见、旧推送不覆盖」即可。
客服改约与用户改约如何统一入口
无论谁发起改约,都走同一服务端接口,只是操作者不同。不要给客服后台另写一套直接改库的脚本。统一入口后,流水、版本号、出站推送才能保证一致。
若业务允许「改约需师傅确认」,也把确认做成状态,而不是让师傅端本地改时间。例如先进入改约申请,确认后再推进槽位版本。这样用户端与师傅端始终读服务端真相。
节假日高峰时推送更不稳,出站表与可重推按钮几乎是刚需。把重推成功也记审计,避免客服重复点击造成骚扰。调度算法若要因改约重派,应订阅改约事件,而不是在改约函数里写死派单。
师傅端日汇总与后台排班表也应订阅同一槽位真相。若排班表另维护一套时间,改约后两边必撕。能合并就合并;不能合并也要由改约事件驱动排班更新,并写失败重试。
改约与时段容量、并发提交
上门时段常常有容量上限。改约成功前,先检查新时段是否还有名额;占用名额与释放旧时段应在同一事务或同一补偿流程里完成。否则会出现「用户以为改成功了,师傅端却提示时段已满」。失败时返回可读原因:时段满、师傅请假、距离超限等,并写入流水。
改约高峰期可对同一订单做短时锁,防止用户与客服同时提交互相覆盖。锁超时要可配置,流水里留下锁竞争失败。无论谁发起改约,都走同一服务端接口,只是操作者不同。需要师傅确认时,把确认做成状态机节点,而不是让师傅端本地改时间。师傅端日汇总与后台排班表也应订阅同一槽位真相,避免另维护一套时间。
推送通道降级策略
推送全挂时,师傅端下次打开应用必须能拉取当日日程全量对齐。拉取接口以服务端版本为准覆盖本地。后台重推只补通知,不改变槽位真相。把降级策略写进运维手册,节假日才不靠运气。出站表积压升高时先扩投递并发,再查通道配额。
纯技术小结
- 改约是事件,不是两端各写时间字段
- 槽位版本号单调递增;旧推送必须丢弃
- 出站队列表 + 可手动重推,托底通知失败
- 用户端以服务端时间为准;冲突检测放服务端
适合谁:已上线预约上门、改约纠纷开始增多的团队。不适合:仍靠电话改档、师傅端无日程同步的手工模式。
把上述做法写进下一次变更复查:只认证据,不认感觉。复查记录与配置版本号、发布单号交叉引用,方便半年后追溯。若人手不足,先保住可回滚与可审计,再追求体验细节。
对外沟通时用同一套术语:进度码、配置版本、审计流水、导出抽查。术语统一后,研发、运营、财务才不会各说各话。本篇清单可以作为术语对照的附件一起存档。
师傅端离线改本地时间一律无效;重新联网后以服务端槽位覆盖。把这条写进师傅端培训,能减少口头纠纷。后台排班若仍显示旧时间,按改约事件补偿刷新,并记失败重试。
把本段做法与前文验收清单交叉引用,形成可执行闭环:改完必验,验完留证,证与版本号同存。对外说明时强调:系统提供核对与权限能力,商务规则由客户确定;海外相关篇目中支付税务支持按需定制对接。