# 功能规格:外卖订单超时无骑手自动取消并退款 **功能标识**:`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` 的存量单不参与本功能,从上线起干净生效(避免任务首跑批量取消历史滞留单;旧滞留单人工处理)。 **多店批次后续接入**:`specs/036-multi-store-checkout` 当前仍是草案。其新批次须以全部有效子单就绪后父批次原子生效的状态及父批次生效时间为准;单个子单先付款不启动整组无人接单倒计时。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](plan.md)。