spec.md 12 KB

功能规格:外卖订单超时无骑手自动取消并退款

功能标识:037-order-no-rider-auto-cancel

创建日期:2026-10-14

状态:已提交;数据库部署与联调待执行

输入:需求「外卖订单如果订单生效后 30 分钟没有骑手接单,订单自动取消,退款给用户」。specs/036-multi-store-checkout/spec.md:140-142,181 已明确将「最迟接单/无人接单自动取消」甩为本独立后续事项,不复用废弃的 TestTask。

已确认口径(2026-10-14 对话定稿)

口径 结论
生效事实与计时起点 新增 effective_at DATETIME NULL,仅在订单首次具备履约资格时写入;在线支付以服务端确认成功、到付以服务端确认订单可履约、转账以确认收款为准。pay_status=1 仍须参与资格校验,但不能单独充当生效时间;is_display / display_time 表示骑手开放状态和实际开放时间,不是生效事实。
未支付单 不处理(payStatus=0 一律不动)
到付单(payType=1) 创建时选择到付,或未支付外送单从在线方式合法切换到到付后,订单立即生效;到时自动取消但不退款(本就没收过钱)。
转账单(payType=6,已确认收款) 纳入取消;退款走线下人工(027 的「转账单不做超时自动取消」仅限未确认收款期间,spec.md:80,121;FR-006 确认收款后回归现有状态机)
范围排除 堂食/自取(delivery_status 为 NULL 天然不命中)、商家自配送(self_delivery=1 不进骑手池)、闪送(独立表独立状态机)、未支付单
阈值 数据字典配置(新 dict_type,value=30 分钟,DictUtils 读取,缺省 30)
预约单取消时点 max(effective_at, 预约时段开始时间, 实际骑手开放时间 display_time) + 阈值,且必须仍可接单;提前付款不能在预约开始前触发取消。
OMG 已付单退款 本轮实现正式环境自动退款;测试环境退款失败属预期,保留可查询的退款意图/未完成状态,不标记已退款,不调用正式网关进行测试。

已确认补充口径

  1. 预约单规则:提前付款或切换到付时立即记 effective_at,但不能从该时刻直接计 30 分钟并在预约开始前取消。delryTime 是 yyyy-MM-dd HH:mm-HH:mm 时间段字符串,取开始时间并与 effective_at、display_time 求最晚时间。若 15:00 生效、16:00 预约开始且正常开放,最早 16:30 判断;若 16:10 才开放,最早 16:40 判断。即时单从 effective_at 计时。
  2. OMG 正式自动退款:取消已实收 OMG 单时持久化幂等退款意图,正式环境工作任务通过信用卡明细查询判断授权状态,并按官方状态选择 N(已授权)、E 后 N(要关账且全额退款)或 R(已关账)。测试环境不调用正式退款接口,意图保持未完成并明确原因;不能把测试失败写成退款成功。超时或不确定网关结果不能盲重试同一动作,须查询事实再继续或进入人工核查。支付尝试保存创建时的 gateway_environment,支付查询沿用该环境;退款要求当前配置及尝试快照都为正式环境,避免将测试交易发送到正式退款接口。正式环境每个 MerchantID 还需配置对应 CreditCheckCode。参考官方文档:https://developers.omg.com.tw/payment/aio/api/05_refund.html 及 https://developers.omg.com.tw/payment/aio/api/04_order_query.html 。

设计方案

① 订单生效事实(新增 effective_at 列)

pos_order 加 effective_at DATETIME NULL。该字段只在首次合法生效时写一次,不因支付重试、重复回调或状态重放覆盖;渠道实际支付时间仍由支付渠道事实表记录。现有独立订单按以下服务端事实写入:

  • 到付外送:UserOrderController.java:301 创建时服务端确认订单可履约;到付尚未实际收款,不能把该时间称为支付时间。
  • LINE:LinePayFactService.applyPaidFactInternal 调用 PosOrderMapper.xml:378 的 markLinePaid 成功时,随订单支付事实原子写入。
  • OMG:OmgPaymentNotifyService.applyVerifiedFacts 调用 OmgPaymentAttemptMapper.xml:136 的 markOrderPaid 成功时,随订单支付事实原子写入;:205 只是修改用于推送的对象,不是数据库写点。
  • 线下转账:商家 PosOrderShOprateController.confirmTransferPayment:668 或平台 OrderLifecycleService.confirmOfflinePayment:286 确认收款时写入。其他在用的线下确认入口实施前再按外送资格核对;自取、堂食不在本任务范围。
  • 废弃写点不改:TestTask.java:145、NewebpayPayController.java:323

切换支付方式:现有 POST /pay/switch 仅允许 payStatus=0 的独立订单,但错误地限制外送单不能切换为到付;本功能应允许未支付外送单从 LINE Pay 等在线方式切换到可用的到付(现金 payType=4 仍按原订单类型规则)。在线方式之间切换只改变 pay_type,effective_at 保持 NULL,待新渠道可信付款事实使订单首次生效时写入。切换到到付则须在同一事务内校验订单与目标方式可用、原渠道没有已确认实收;有未决尝试先查询现有支付事实,无法确认时暂缓切换。确认未实收后原子更新 pay_type=1、pay_status=1、effective_at=NOW(),仅在提交后开放接单。已生效订单禁止切换,重复付款或支付重试不得清空、覆盖生效时间。实现时将 PayMethodSwitchService 当前的整对象 updateById(order) 改为仅更新必要列的条件更新,条件至少包括 pay_status=0 AND effective_at IS NULL 及可切换状态,防止与支付回调并发时写回旧值。旧渠道若仍有迟到实收,只记渠道收款事实并创建幂等退款意图,不重开订单或再次改写 effective_at。新多店批次逐子单改方式及到付确认仍按 036 的父批次生效规则处理,不套用本独立订单入口。

不回填历史:effective_at IS NULL 的存量单不参与本功能,从上线起干净生效(避免任务首跑批量取消历史滞留单;旧滞留单人工处理)。

多店批次接入(2026-10-16 已确认、尚待实现):本轮在 036 FR-042/SC-030、T023 中接入现有阈值;全部有效成员就绪后以父批次统一生效、首次实际开放和预约开始三者最晚值计时,单子单先付款不启动倒计时,部分取消不续时。未承接配送组超时原子取消剩余有效成员,与接组互斥、逐交易退真实实收;无组的独立配送成员沿独立方式但不早于批次统一生效计时。现有 037 独立订单实现和验证不代表这段扩展已完成,普通单规则保持回归覆盖。

② 匹配条件(镜像骑手接单 CAS,保证「骑手本可以接但没人接」)

type=0 AND state=0 AND delivery_status=0 AND pay_status=1
AND after_sale_status=0 AND qs_id IS NULL AND IFNULL(self_delivery,0)=0
AND effective_at IS NOT NULL AND is_display=1
计时:即时单  effective_at + 阈值分钟 ≤ now
     预约单  max(effective_at, 预约时段开始时间, display_time) + 阈值分钟 ≤ now

state=0 即「骑手池」状态——商家接单反而要求骑手已接单(PosOrderShOprateController.acceptOrder:337 requireRiderAssigned),不存在「商家已接单但无骑手」的中间态。本条件与骑手接单 CAS(OrderLifecycleService.acceptDeliveryByRiderLocked:171-181:state=0 + deliveryStatus=0 + payStatus=1 + afterSaleStatus=0 + qsId IS NULL)完全对偶。

③ 取消动作(逐单事务)

  1. CAS UPDATE pos_order SET state=4 WHERE id=? AND 全套池条件——与骑手抢单天然防竞态,影响行数=0 则跳过该单(本轮已被抢/已变化)。
  2. LINE 已付(payType=3):linePayRefundService.createRefundIntent(paymentId, "AUTO_NO_RIDER")——幂等退款意图落 pos_order_line_refund,实际网关退款由现有 LinePayReconcileTask 异步执行(复用 USER_CANCEL/STORE_CANCEL 的既有模式)。
  3. 到付不退款;OMG 已付创建正式退款意图并由工作任务按网关状态执行,测试环境保持未完成;已收款转账进入持久化人工退款待办,不能仅凭一条应用日志充当待办。
  4. orderLogHelper.logSync(operatorType=0 系统,文案「超时无骑手自动取消」,与取消事务一致)。
  5. 推送:用户(订单已取消;LINE 单附退款说明)+ 商家门店(merchantNotificationRouter.sendStoreNotification)。骑手无接收人(qsId 为空)。注意 notifyOrderCancelled(DeliveryOrderNotificationService.java:104-132)仅 type=0 且 qsId!=null 才推,不能直接复用,需用户侧推送变体。推送文案新增后端 i18n key(沿用 no.message.push.* 模式,四语言)。
  6. payStatus / afterSaleStatus 不随取消改动(已付单 payStatus 由退款事实异步回写,同现有取消链路)。

④ 调度

@Component("posOrderNoRiderTimeoutTask") 公开方法 + sys_job 注册(cron 每分钟),样板照抄 FlashDeliveryAutoCompleteTask(其类注释明确「由若依任务调度调 Bean 公开方法,避免应用内 @Scheduled 重复执行」)。SQL(sys_job INSERT)登记 updatesql/sql.md 由开发者手动执行。逐单 CAS 幂等,双跑安全。

⑤ 阈值配置

数据字典:新 dict_type(如 pos_order_auto_cancel,字典项「无骑手自动取消分钟数」=30),任务每次执行时 DictUtils 读取,读取失败/未配置缺省 30。INSERT 登记 updatesql/sql.md。

关键代码事实(实现锚点)

  • 骑手接单 CAS:OrderLifecycleService.java:169-181(只更新 deliveryStatus=1 + qsId,state 不变)
  • 骑手池精确画像(同本功能匹配条件):PosOrderQsOprateController.orderTabWrapper newTask 分支 :447-451
  • 用户取消样板(CAS + LINE 退款意图 + 日志 + 推送):UserOrderController.cancelOrder:746-790
  • LINE 退款:LinePayRefundService.createRefundIntent:178(幂等);LinePayCancellationCompensationService(source=CANCEL_SCAN 补偿「已取消已付无退款意图」的单——注意:本功能上线后该补偿扫描可能把 AUTO_NO_RIDER 取消的 OMG/到付单也补出 LINE 意图吗?不会,补偿只针对有 PAID LINE payment 的单,实现时验证 source 语义即可)
  • 开放状态:UserOrderController.handleIsDisplay:477-491 即时单下单即写、预约单临近才写;远期预约翻显示的原定时任务已随 TestTask 废弃。骑手新任务列表在 PosOrderQsOprateController.orderTabWrapper:445 要求 is_display=true;实施预约自动取消前须补持久化开放与重启补扫。
  • pos_order 现无订单生效时间列;渠道支付时间分别在 pos_order_line_payment.pay_time / pos_order_omg_attempt.payment_date,不能替代到付和多店批次的生效时间。
  • 状态值域:state 0待处理/1已接单/2已出餐/3已完成/4已取消(specs/006-orderstate);payStatus 0未支付/1已支付/2已退款;afterSaleStatus 0无售后…
  • 任务样板:ruoyi-admin/.../flashdelivery/task/FlashDeliveryAutoCompleteTask.java;sys_job 登记样例 updatesql/sql.md:1346-1368

部署与联调

  1. 开发者按 updatesql/sql.md 的 2026-10-15 节手动执行 DDL、字典和调度任务;历史订单不回填 effective_at。
  2. 测试环境只用模拟网关响应验证 OMG 退款状态机,测试退款意图维持未完成。正式环境配置 omgpay.environment=PRODUCTION 和各 MerchantID 的 CreditCheckCode 后,再用真实交易验收查询及退款。
  3. 验证记录与未通过的全量回归项见 plan.md。