[{"data":1,"prerenderedAt":393},["ShallowReactive",2],{"\u002Fblog\u002Ffrom-booking-to-open-door":3},{"id":4,"title":5,"author":6,"body":7,"category":378,"date":379,"description":380,"extension":381,"headline":382,"image":6,"meta":386,"minRead":387,"navigation":388,"path":389,"seo":390,"stem":391,"__hash__":392},"blog\u002Fblog\u002Ffrom-booking-to-open-door.md","从一次预约到一扇门：无人门店系统如何可靠交付",null,{"type":8,"value":9,"toc":367},"minimark",[10,18,21,24,27,32,35,38,57,64,67,94,102,106,113,116,195,198,204,208,211,214,231,234,238,241,244,276,279,282,286,289,292,309,323,326,329,349,352,355,358,361,364],[11,12,13,14],"p",{},"做预约系统久了，我越来越觉得：",[15,16,17],"strong",{},"用户路径越短，背后的系统往往越复杂。",[11,19,20],{},"在无人门店里，用户只完成几个动作：选场地、选时间、付款、到店开门。看起来是一条很直的链路，但任何一个环节都连接着不同系统：小程序、支付平台、商家后台、预约服务、定时任务，以及真实世界里的门禁设备。",[11,22,23],{},"网页上的错误可以刷新，门打不开却是一次真实的服务失败。",[11,25,26],{},"这篇文章不讨论某个具体框架，而是整理我在预约与 IoT 项目中反复使用的一套思路：怎样把一条看似简单的用户路径，拆成能够可靠运行的系统。",[28,29,31],"h2",{"id":30},"先定义业务事实再设计页面","先定义业务事实，再设计页面",[11,33,34],{},"很多项目从页面开始：先画一个日历，再放一个支付按钮，最后补接口。这样做演示很快，但一进入真实运营，问题会集中出现。",[11,36,37],{},"更稳妥的起点，是先回答几个业务问题：",[39,40,41,45,48,51,54],"ul",{},[42,43,44],"li",{},"哪一个状态代表时段已经被占用？",[42,46,47],{},"用户支付成功，但回调尚未到达时，订单是什么状态？",[42,49,50],{},"退款成功后，时段何时重新释放？",[42,52,53],{},"用户提前到店，设备是否允许开门？",[42,55,56],{},"同一条开门指令被重复发送，会发生什么？",[11,58,59,60,63],{},"这些问题的答案不是 UI 状态，而是",[15,61,62],{},"领域事实","。页面、小程序和设备控制都应该消费同一套事实，而不是分别创造自己的判断。",[11,65,66],{},"以预约为例，我通常会把核心事实拆成四组：",[68,69,70,76,82,88],"ol",{},[42,71,72,75],{},[15,73,74],{},"订单事实","：待支付、已支付、已取消、退款中、已退款；",[42,77,78,81],{},[15,79,80],{},"履约事实","：未开始、可入场、进行中、已结束；",[42,83,84,87],{},[15,85,86],{},"资产事实","：余额、次卡、优惠券分别如何占用和退回；",[42,89,90,93],{},[15,91,92],{},"设备事实","：设备在线状态、授权时间窗、指令执行结果。",[11,95,96,97,101],{},"它们彼此有关，但不能混成一个巨大的 ",[98,99,100],"code",{},"status"," 字段。一个订单已支付，不等于现在允许开门；一次退款已发起，也不等于会员资产已经回滚完成。",[28,103,105],{"id":104},"状态机比大量-if-更可靠","状态机比大量 if 更可靠",[11,107,108,109,112],{},"当业务刚开始时，几个 ",[98,110,111],{},"if"," 足够解决问题。随着微信、支付宝、抖音和商家后台陆续接入，相同规则会在多个端重复出现，并慢慢产生差异。",[11,114,115],{},"我更倾向于把状态变化写成明确的转换：",[117,118,119,138],"table",{},[120,121,122],"thead",{},[123,124,125,129,132,135],"tr",{},[126,127,128],"th",{},"当前状态",[126,130,131],{},"事件",[126,133,134],{},"下一个状态",[126,136,137],{},"伴随动作",[139,140,141,156,169,182],"tbody",{},[123,142,143,147,150,153],{},[144,145,146],"td",{},"待支付",[144,148,149],{},"支付成功",[144,151,152],{},"已支付",[144,154,155],{},"确认时段、生成设备授权",[123,157,158,160,163,166],{},[144,159,146],{},[144,161,162],{},"超时",[144,164,165],{},"已取消",[144,167,168],{},"释放临时时段",[123,170,171,173,176,179],{},[144,172,152],{},[144,174,175],{},"商家同意退款",[144,177,178],{},"退款中",[144,180,181],{},"提交支付平台退款",[123,183,184,186,189,192],{},[144,185,178],{},[144,187,188],{},"退款成功",[144,190,191],{},"已退款",[144,193,194],{},"退回资产、撤销设备授权",[11,196,197],{},"这张表的价值不只是方便开发。产品、客服和工程师可以围绕同一套语言讨论异常：究竟是事件没有发生，还是转换失败，还是伴随动作没有完成。",[199,200,201],"blockquote",{},[11,202,203],{},"状态不是给页面显示的标签，而是系统对已经发生之事实的承诺。",[28,205,207],{"id":206},"把支付回调当成消息而不是一次请求","把支付回调当成消息，而不是一次请求",[11,209,210],{},"支付平台的回调可能延迟、重复，甚至先于前端的“支付成功”页面到达。系统如果假设每个请求只出现一次，线上很快就会出现重复扣减、重复通知和重复发放。",[11,212,213],{},"我的处理原则是：",[39,215,216,219,222,225,228],{},[42,217,218],{},"每一种外部事件都有稳定的业务唯一键；",[42,220,221],{},"先记录收到的原始事件，再执行状态转换；",[42,223,224],{},"状态转换必须具备幂等性；",[42,226,227],{},"耗时的副作用交给队列处理；",[42,229,230],{},"每个失败动作都可以单独重试。",[11,232,233],{},"例如支付成功后，确认订单、发通知、生成门禁授权不是一个不可拆分的大事务。订单确认属于核心状态变化；短信、数据统计和设备同步可以作为后续任务。这样，即使某个设备服务短暂不可用，也不会否定已经发生的支付事实。",[28,235,237],{"id":236},"设备控制要接受现实世界不可靠","设备控制要接受“现实世界不可靠”",[11,239,240],{},"纯软件系统里，我们容易把一次 HTTP 200 当作成功。但对门禁设备来说，接口返回成功可能只表示平台接收了指令，不代表门真的打开。",[11,242,243],{},"因此设备层需要比普通业务接口多几个概念：",[39,245,246,252,258,264,270],{},[42,247,248,251],{},[15,249,250],{},"统一设备身份","：不同厂商型号映射到内部设备 ID；",[42,253,254,257],{},[15,255,256],{},"统一操作语义","：开门、常开、关闭等动作有稳定接口；",[42,259,260,263],{},[15,261,262],{},"请求编号","：每次控制都有可追踪、可去重的编号；",[42,265,266,269],{},[15,267,268],{},"结果分层","：已接收、已下发、已执行、执行失败；",[42,271,272,275],{},[15,273,274],{},"人工兜底","：线上失败时，客服能够看到足够信息并采取替代方案。",[11,277,278],{},"接入多种门禁和控制器时，我不希望业务代码知道具体厂商。预约服务只表达“这个用户在这个时间窗拥有开门权限”，适配层再处理每种设备的协议差异。",[11,280,281],{},"这层隔离让更换设备供应商不至于侵入订单、会员和小程序代码。",[28,283,285],{"id":284},"多端一致不等于多端复制","多端一致，不等于多端复制",[11,287,288],{},"同一个预约业务可能同时存在于用户微信小程序、商家小程序、支付宝、抖音与 PC 后台。最危险的做法，是让每个端各自实现一遍业务判断。",[11,290,291],{},"真正应该复用的不是页面组件，而是：",[39,293,294,297,300,303,306],{},[42,295,296],{},"可用时段的计算规则；",[42,298,299],{},"价格、优惠和会员资产的计算结果；",[42,301,302],{},"订单允许执行的动作；",[42,304,305],{},"面向用户的状态解释；",[42,307,308],{},"错误码及恢复建议。",[11,310,311,312,315,316,315,319,322],{},"服务端返回的不应只有原始状态，还可以返回当前允许的操作。例如 ",[98,313,314],{},"can_pay","、",[98,317,318],{},"can_cancel",[98,320,321],{},"can_open_door","。前端仍然负责体验，但不再猜测业务规则。",[28,324,325],{"id":325},"为运营留下可观察性",[11,327,328],{},"系统稳定，不只是“没有报错”。对于交易和设备联动，我至少会保留三条可追踪链路：",[68,330,331,337,343],{},[42,332,333,336],{},[15,334,335],{},"业务链路","：用户、订单、支付单、退款单和会员资产；",[42,338,339,342],{},[15,340,341],{},"任务链路","：事件何时进入队列、执行几次、最终结果是什么；",[42,344,345,348],{},[15,346,347],{},"设备链路","：授权、控制指令、厂商响应和设备回报。",[11,350,351],{},"当客服说“用户付了钱但门打不开”，工程师应该能从订单出发，在几分钟内定位到具体环节，而不是登录几台服务器翻找没有关联 ID 的日志。",[11,353,354],{},"可观察性不是项目完成后的附加工作。它决定了系统出问题时，团队是否有能力恢复服务。",[28,356,357],{"id":357},"最后",[11,359,360],{},"一套无人门店系统真正交付的，不是几个页面，也不是一组接口，而是一条从数字订单延伸到物理空间的承诺。",[11,362,363],{},"要兑现这份承诺，需要先定义业务事实，用状态机约束变化，用幂等和异步任务吸收外部不确定性，再通过统一设备层与可观察性应对现实世界。",[11,365,366],{},"用户最终只需要看到一件事：预约完成，走到门前，门如期打开。",{"title":368,"searchDepth":369,"depth":369,"links":370},"",2,[371,372,373,374,375,376,377],{"id":30,"depth":369,"text":31},{"id":104,"depth":369,"text":105},{"id":206,"depth":369,"text":207},{"id":236,"depth":369,"text":237},{"id":284,"depth":369,"text":285},{"id":325,"depth":369,"text":325},{"id":357,"depth":369,"text":357},"工程实践","2026-08-16","用户看到的是一次预约和一次开门，系统背后却要让订单、支付、时段与设备状态在多个端保持一致。","md",[383,384,385],"从一次预约到一扇门：","无人门店系统","如何可靠交付",{},9,true,"\u002Fblog\u002Ffrom-booking-to-open-door",{"title":5,"description":380},"blog\u002Ffrom-booking-to-open-door","XHGy8dldzOWbk-dnnm0xGA_mKKdweYRbW9tHL6_px94",1789624776302]