# Research: 闪送订单状态变更消息推送 **Date**: 2026-09-17 | **Spec**: [spec.md](spec.md) 研究方式:直接阅读现有代码与 i18n 资源(无外部依赖需要调研)。所有决策均基于仓库内已验证的事实。 ## D1: 通知服务的形态与放置 **Decision**: 新建 `FlashDeliveryNotificationService`(`ruoyi-admin` 的 `com.ruoyi.app.flashdelivery.service` 包),完整镜像 `DeliveryOrderNotificationService` 的模式:`TransactionSynchronizationManager` 的 afterCommit 回调 + `AsyncManager.me().execute(TimerTask)` 异步执行 + `PayPush.userPushHandleLocal / qsPushHandleLocal` 发送。 **Rationale**: - `DeliveryOrderNotificationService`(`ruoyi-admin/.../order/DeliveryOrderNotificationService.java`)是外送订单推送的现行实现,模式经过生产验证。 - afterCommit 保证只在事务提交后触发(满足 FR-011:状态变更实际成功才推送);无事务同步时直接执行(便于测试)。 - AsyncManager 异步执行保证推送 HTTP 调用不阻塞、失败不上抛(满足 FR-009)。 - `PayPush.*HandleLocal` 系列封装了 locale 解析、`MessageUtils.message(...)` 多语言渲染、`PublisherEvent` 入库(满足 FR-006/FR-008)与 cid 判空跳过。 - 放 `ruoyi-admin` 符合 CLAUDE.md 模块边界(依赖外部 HTTP 的集成代码不进 ruoyi-system)。 **Alternatives considered**: - 在 Controller 层发推送:现有闪存 Controller 无业务逻辑,且 accept 等操作在应用服务锁内完成,Controller 拿不到可靠的成功信号;放弃。 - 直接在 `FlashDeliveryApplicationService` 里调 PayPush:把推送基础设施混进 1200+ 行的应用服务,且无法单测路由;放弃。 - Spring `@TransactionalEventListener`:项目无此用法先例,与现有模式不一致;放弃。 ## D2: 推送文案——复用现有 i18n key,不新增 **Decision**: 全部复用现有 key(已验证 4 个 key × 6 个语言文件全部齐备,含 th_TH): | 事件 | title key | content key | zh_CN 文案 | |------|-----------|-------------|-----------| | 骑手抢单 (→ACCEPTED) | `no.message.push.message` | `no.message.push.delivery.personnel.receiving.order` | 骑手已接单 | | 骑手取件 (→PICKED_UP) | `no.message.push.message` | `no.message.push.delivery.personnel.qspsz.order` | 骑手配送中 | | 骑手送达 (→DELIVERED) | `no.message.push.message` | `no.message.push.delivery.personnel.qsysd.order` | 骑手已送达 | | 订单取消 (→CANCELLED) | `no.message.push.message` | `no.message.push.order.cancelled` | 订单已取消 | **Rationale**: - 语义与闪送场景一一对应,且与外卖订单推送文案保持一致(同一 App 内统一体验)。 - 6 个文件(messages.properties=vi 默认、vi、zh_CN、zh_TW、en_US、th_TH)均已有这些 key,i18n 工作量为零,规避漏翻风险。 - `PayPush.*HandleLocal` 会在 content 后拼接 `,NO:{订单号}`(满足 FR-007)。 **Alternatives considered**: 新增 `flash.delivery.push.*` 专属 key(如"閃送騎手已取件"):文案可更精确区分闪送与外卖,但要新增 4×6=24 条翻译且收益有限;如后续需要区分再追加。 ## D3: 推送 payload——复用 OrderPushBodyDto,pushType 新增 3=闪送 **Decision**: payload 用 `OrderPushBodyDto.getJson(orderNo, status, -1, 3)`:`ddId` 字段承载闪送 `orderNo`(形如 `FD` + 24 位),`state` 承载状态枚举字符串(如 `ACCEPTED`),`type=-1`(非语音提醒),`pushType=3` 标识闪送订单(现有取值 1=外卖、2=充值)。 **Rationale**: 客户端已按 `OrderPushBodyDto` 的形状解析 payload 并按 `pushType` 路由;复用形状让客户端只需新增一个 pushType 分支。`orderNo` 是面向用户的订单标识(与外卖 `ddId` 同角色)。 **Alternatives considered**: 新建 `FlashDeliveryPushBodyDto`:字段更语义化但客户端要适配两种形状;放弃。 ## D4: 各事件的接收人判定 **Decision**: - ACCEPTED / PICKED_UP / DELIVERED → 寄件用户 `FlashDeliveryOrder.userId`(`userPushHandleLocal`)。 - CANCELLED → `order.riderId != null` 时推骑手(`qsPushHandleLocal`),否则跳过。 - 用户取消按状态机只可能发生在 WAITING_ACCEPTANCE/ACCEPTED;发生在 ACCEPTED 时必有骑手。平台取消可发生在任意非终态,WAITING_ACCEPTANCE 时 riderId 为空自然跳过(spec 边界场景 3)。 **Rationale**: 与 spec FR-001~FR-005 一一对应;riderId 判空即覆盖"未接单取消不推骑手"。 ## D5: 用户信息获取 **Decision**: 通知服务注入 `InfoUserMapper`,用 `selectById` 取 `cid`。 **Rationale**: 闪送模块(`FlashDeliveryApplicationService`)现用 `InfoUserMapper` 而非 `IInfoUserService`,模块内保持一致。 ## D6: 测试策略 **Decision**: 镜像 `DeliveryOrderNotificationServiceTest` 的手法: - 通知服务单测:`spy` 服务 + `doNothing` 打断包级可见的 send 方法(其内部是真实 HTTP 调用,不可单测),验证接收人判定与跳过逻辑;无事务同步时 `runAfterCommit` 同步执行,无需 Spring 上下文。 - 应用服务单测:mock `FlashDeliveryNotificationService` 注入,verify 各状态流转方法成功路径调用对应 notify、失败路径与排除事件(complete、cancelExpiredScheduledOrder、加小费)`never()` 调用。仓库已有 `MockedStatic` 先例(`FlashDeliveryApplicationServiceTest`)可参考构造方式。 **Rationale**: 与项目现有测试风格完全一致,不引入新测试设施。 ## D7: 消息入库与无 cid 行为 **Decision**: 不额外开发——`PayPush.userPush/qsPush` 内部先 `pushEventService.PublisherEvent(...)`(写 `push_message`,即 FR-008 的消息记录)再判 cid;cid 为空只记 error 日志并跳过云端推送。两个行为均由复用通道免费获得。 ## 结论 无 NEEDS CLARIFICATION 遗留;不新增接口端点。2026-09-18 追加的新单推送需要 1 个字段、2 个专属 i18n key、1 个独立任务与骑手候选查询,具体见 D8-D10。 ## D8: `is_display` 同时控制列表可见性与通知资格 **Decision**: 立即单创建为 true;预约单创建为 false。预约任务在半开时间窗 `[scheduledPickupStartAt, scheduledPickupEndAt)` 内执行带完整业务条件的 `false -> true` 更新,只有影响 1 行者发送通知。 **Rationale**: 若列表仅依赖时间表达式,而推送由分钟任务触发,骑手会先看到订单、最多一分钟后才收到通知。统一使用数据库门闩后,展示与通知资格由同一次原子写入开启;每秒调度把正常延迟控制在约一秒内,数据库条件更新承担多实例幂等。 ## D9: 独立每秒任务 **Decision**: 新建 `FlashDeliveryAvailabilityTask.openScheduledOrders()`,Quartz 表达式 `0/1 * * * * ?`,禁止同任务并发;不把逻辑放入既有 `FlashDeliveryAutoCompleteTask`。 **Rationale**: 开放展示属于可抢单生命周期,和送达自动完成、预约过期取消的职责及频率不同。独立 Bean 便于单独调整、监控和停用;批内单笔失败隔离,下一秒可重试尚未成功开放的订单。 ## D10: 候选骑手与专属新单文案 **Decision**: 候选骑手须为在线骑手,支持 FLASH(空值按历史兼容)、车型匹配、处于配置半径内,并复用普通/加急闪送接单互斥规则;按距离选最近 20 人。外卖和闪送分别使用 `no.message.push.new.food.order`、`no.message.push.new.flash.order`。 **Rationale**: 推送对象与实际能抢单的人群一致,减少无效提醒;专属文案让骑手无需打开 App 即可区分业务类型。