research.md 7.6 KB

Research: 闪送订单状态变更消息推送

Date: 2026-09-17 | Spec: spec.md

研究方式:直接阅读现有代码与 i18n 资源(无外部依赖需要调研)。所有决策均基于仓库内已验证的事实。

D1: 通知服务的形态与放置

Decision: 新建 FlashDeliveryNotificationServiceruoyi-admincom.ruoyi.app.flashdelivery.service 包),完整镜像 DeliveryOrderNotificationService 的模式:TransactionSynchronizationManager 的 afterCommit 回调 + AsyncManager.me().execute(TimerTask) 异步执行 + PayPush.userPushHandleLocal / qsPushHandleLocal 发送。

Rationale:

  • DeliveryOrderNotificationServiceruoyi-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.userIduserPushHandleLocal)。
  • CANCELLED → order.riderId != null 时推骑手(qsPushHandleLocal),否则跳过。
  • 用户取消按状态机只可能发生在 WAITING_ACCEPTANCE/ACCEPTED;发生在 ACCEPTED 时必有骑手。平台取消可发生在任意非终态,WAITING_ACCEPTANCE 时 riderId 为空自然跳过(spec 边界场景 3)。

Rationale: 与 spec FR-001~FR-005 一一对应;riderId 判空即覆盖"未接单取消不推骑手"。

D5: 用户信息获取

Decision: 通知服务注入 InfoUserMapper,用 selectByIdcid

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.orderno.message.push.new.flash.order

Rationale: 推送对象与实际能抢单的人群一致,减少无效提醒;专属文案让骑手无需打开 App 即可区分业务类型。