# 可切换原生业务推送 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在不改变默认 uniPush 行为的前提下,为当前在用的普通业务通知增加可通过服务端配置和重启启用的三端 Android FCM / iOS APNs 原生发送能力。 **Architecture:** `PayPush` 的业务封装与现有直接业务发送点统一委托给 `BusinessPushService`。服务按全局 `business-push.mode` 选择现有 uniCloud 方法或原生发送器;原生时按显式接收端类型与数据库中当前账号的 `cidType/deviceToken` 选择三套独立凭据。`PushEvent` 继续由原调用点处理,路由层只负责外发。 **Tech Stack:** Java 21、Spring Boot、现有 Firebase Admin SDK、JDK HTTP/2 `HttpClient`、PKCS#12 APNs 普通通知证书、JUnit/Mockito。 **Spec:** `specs/033-native-push-routing/spec.md` ## Global Constraints - 当前工作分支为 `test-202609v2`;用户要求不创建新分支。保留工作区已有未提交修改,尤其不修改与功能无关的 `InfoUserController.java` 测试接口。 - `business-push.mode` 只允许 `uni`/`native`,缺省为 `uni`,切换后重启生效;不做后台实时开关。默认模式不能要求原生凭据。 - `cid` 仅用于 uniPush,`deviceToken` 仅用于普通 FCM/APNs,`voIPToken` 不参与;单账号单设备,不改数据库结构。 - 三端 Firebase 服务账号属于不同项目,必须初始化三个命名 `FirebaseApp`;iOS 使用三端各自的普通推送 `.p12`,按部署环境选择 APNs 开发/生产地址。2026-09-29 追加要求将三端真实凭据明确命名后提交 `resources/business-push/`,随 JAR 打包;仓库及构建产物按含密钥材料管理,禁止日志输出私钥或密码。 - 只迁移在用的普通业务通知;音视频通话、已废弃入口和临时测试推送接口保持原行为。已有 `PushEvent` 入库时机与次数不改变,外发失败不影响业务且不自动转发 uniPush。 - 后端集成代码放在 `ruoyi-admin`,不让 `ruoyi-system` 反向依赖应用层;不执行数据库 SQL。仅当前 Maven 命令临时使用 JDK 21,保留现有换行风格。 ## Review Focus 1. 原生模式有 `deviceToken`、无 `cid` 的用户仍能被收件人筛选并发送;默认 uni 模式仍按 CID 判断。 2. 用户、骑手、商家(含夜市及子账号)不会混用 Firebase 项目或 APNs topic;相同设备的商家收件人去重按当前通道地址。 门店订单收件人仍是分管账号优先、主账号兜底,不同时发给两类账号。 3. iOS 开发/生产网关切换复用同端证书;缺失、过期、无法解锁或无私钥的证书在原生模式启动时明确失败。 4. LINE Pay/OMG 等本来已经自行发布 `PushEvent` 的路径不会因迁移产生第二条消息记录,且 CID 为空时原生发送仍可进行。 5. `VideoCallController`、`/testPushMessage` 和测试批量接口继续走现有行为;业务通知 payload 的 `ddId/state/type/pushType` 不丢失。 --- ## Technical Context - 现有发送点:`ruoyi-admin/src/main/java/com/ruoyi/app/utils/PayPush.java` 的三端包装方法、外卖/闪送通知服务、商家路由,以及 LINE Pay/OMG 支付通知的直接调用。 - 现有持久化:`ruoyi-system` 的 `InfoUser` 和 `PushEvent -> push_message`,无需 Entity、Mapper 或 SQL 变更。 - 参考实现:`D:/QtwCode/CTE/cte_server` 的 `FirebaseConfig`/`FCMPush` 可借鉴 SDK 用法,但其单项目初始化和用 CID 发 FCM 不可照搬。 - 本机全局 Java 为 17;`C:/Users/qmj/.jdks/graalvm-jdk-21.0.7` 不存在。执行时仅在命令环境使用已发现的 `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1`,不改全局配置。 ## External Configuration Contract `application.yml` 保持 `business-push.mode: uni`,三端文件路径设为有 `user`、`merchant`、`rider` 前缀的 `classpath:business-push/...` 资源。三端各提交一份独立 FCM 服务账号、一份 APNs 普通 `.p12` 和一份含 `packetName/pwd` 的配置 JSON;原生模式从该配置读取 topic 和证书密码。`business-push.apns-environment` 为 `development` 或 `production`,两环境使用每端同一张证书,只切换网关。原有外部文件路径及显式密码/topic 配置继续兼容。默认 `uni` 模式不读取凭据。 FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `payload`,并保留现有可解析 payload 的 `ddId/state/type/pushType` 字段;APNs 使用 `aps.alert.title/body`、普通通知 sound 和同名业务字段。iOS 骑手端沿用现有 `pushsound.caf`,其余 iOS 端使用默认声音;Android FCM 使用默认声音,并为骑手标记 `qsRing` 通知渠道,最终自定义铃声取决于 App 是否创建该渠道(App 端不在本任务内)。过大或无法解析的 payload 必须记录可定位的失败类别,不得以截断方式改变业务字段。 ## File Structure | 文件 | 职责 | |---|---| | `ruoyi-admin/src/main/java/com/ruoyi/app/push/RecipientApp.java` | USER / MERCHANT / RIDER 显式接收端标识 | | `ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushProperties.java` | 全局模式、APNs 环境、三端 classpath/外部凭据路径与 APNs 配置路径绑定和原生模式校验 | | `ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java` | 三个命名 FirebaseApp;Android 单发、请求构造与失效错误分类 | | `ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java` | 三个证书型 HTTP/2 客户端;APNs alert、custom payload 与响应处理 | | `ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java` | 统一外发路由、当前账号读取、通道地址判断及安全日志 | | `ruoyi-admin/src/main/java/com/ruoyi/app/utils/PayPush.java` | 原业务包装方法转委托;三端 uniCloud 低层方法保留给 uni 模式及排除的调用方 | | `ruoyi-admin/src/main/resources/application.yml` | 显式默认 `uni`,仅放非秘密配置与环境变量占位符 | | 在用业务调用点 | 移除只看 CID 的前置排除,把普通业务外发接到统一路由;不改触发时机和入库 | | `ruoyi-admin/src/test/java/com/ruoyi/app/push/*Test.java` | 配置、路由、FCM/APNs 构造及错误单测;相关现有业务测试覆盖直连点 | | `specs/033-native-push-routing/quickstart.md`、`specs/012-im-user-integration/code-status.md`、`docs/order-push-overview.md` | 安全配置、启用/回退、现状说明 | ## Task 1: 配置与默认模式 **Files:** Create `RecipientApp.java`, `BusinessPushProperties.java`, `BusinessPushPropertiesTest.java`; modify `application.yml`. **Interfaces:** `BusinessPushProperties.Mode { UNI, NATIVE }`; `BusinessPushProperties.getApp(RecipientApp)` returns one app's FCM credentials path and APNs certificate path/password/topic; `getApnsEnvironment()` returns DEVELOPMENT/PRODUCTION. - [ ] Write tests: missing mode resolves to UNI; `uni` accepts empty native credentials; `native` rejects missing any of three FCM paths, APNs certificate path/password/topic, or invalid environment. Credentials content errors belong to Tasks 2–3. - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=BusinessPushPropertiesTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21; verify the new tests fail before implementation. - [ ] Implement property binding and native-only startup validation; keep all secret values out of `application.yml` and logs. - [ ] Re-run the directed test and confirm pass; stage only Task 1 files, inspect staged names, commit. ## Task 2: 三项目 Android FCM 发送 **Files:** Create `FcmPushSender.java`, `FcmPushSenderTest.java`. **Interfaces:** `void send(RecipientApp app, String token, String title, String content, String payload)`; only available in NATIVE mode. The sender owns three named `FirebaseApp` instances and closes them on shutdown. - [ ] Write tests: each app resolves its own named Firebase project; sends to `token`, never CID; title/content plus existing payload fields survive as string data; `UNREGISTERED` and project mismatch are classified without full token logging; repeated initialization does not conflict. - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=FcmPushSenderTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify failure before implementation. - [ ] Initialize credentials from the configured classpath or filesystem path, build FCM notification + Android high-priority data, and send through the app-specific `FirebaseMessaging` instance; do not adopt CTE's single-default-app/CID assumptions. - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 2 files. ## Task 3: 三端 iOS APNs 普通通知 **Files:** Create `ApnsPushSender.java`, `ApnsPushSenderTest.java`. **Interfaces:** `void send(RecipientApp app, String token, String title, String content, String payload)`; only available in NATIVE mode. The request uses `apns-topic` from that app's config and `apns-push-type: alert`. - [ ] Write tests: each app selects its own topic/certificate; DEVELOPMENT/PRODUCTION selects sandbox/production host; alert title/body, sound and custom payload preserve business fields; malformed/expired/no-private-key PKCS#12 fails initialization; non-2xx response is categorized without printing secrets. - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=ApnsPushSenderTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify failure before implementation. - [ ] Load the app-specific PKCS#12 into per-app TLS client, reuse JDK HTTP/2 connections, send to `/3/device/{token}` on the selected environment's gateway; use ordinary alert push, not VoIP; use rider `pushsound.caf` and the existing default sound for other apps. - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 3 files. ## Task 4: 统一外发路由与现有包装方法 **Files:** Create `BusinessPushService.java`, `BusinessPushServiceTest.java`; modify `PayPush.java`. **Interfaces:** `void send(RecipientApp app, Long userId, String cid, String title, String content, String payload)`; `String addressKey(InfoUser recipient)` returns null if the configured channel lacks a valid address. Native mode reloads current `InfoUser` by `userId` to obtain `cidType/deviceToken`; platform values are trimmed and matched case-insensitively, with unknown values rejected. `PayPush` keeps its existing static wrapper signatures and retrieves the Spring-managed router through the project's `SpringUtils` bridge; existing low-level instance methods remain unchanged for excluded callers. - [ ] Write tests: default uni routes to the three existing low层方法且不读取 native sender;native 六种端/系统组合选正确 sender/token;空/未知 `cidType` 或空 token 不发送;sender 异常不传播且不回退;切换不增加 `PushEvent` 次数。 - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=BusinessPushServiceTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify failure before implementation. - [ ] Implement `BusinessPushService` and delegate `PayPush.userPush/shPush/qsPush` to it after原有 `PublisherEvent`;不改变低层 uniCloud 方法,以隔离视频/测试调用。 - [ ] Re-run the same directed command and confirm pass; inspect staged names, commit only Task 4 files. ## Task 5: 迁移在用普通业务发送点及 CID 前置判断 **Files:** Modify `MerchantNotificationRouter.java`, `LinePayOrderNotificationService.java`, `OmgPaymentNotifyService.java`, `PosOrderShOprateController.java`, `PosOrderQsOprateController.java`, `PosOrderController.java`; modify their相关现有测试。仅确需的其他在用业务调用点纳入本任务,禁止修改 `VideoCallController`、废弃入口或测试推送接口。 **Interfaces:** Direct业务外发调用 `BusinessPushService.send(...)`;收件人资格和去重调用 `addressKey(InfoUser)`,以当前通道的地址而非固定 CID 判定。 - [ ] Write/adjust tests: CID 空而 native token 有效的商家子账号、用户及骑手仍可收;uni 模式 CID 空保持原判断;分管账号可用时只通知分管账号、不可用时才回退主账号;商家同一地址只外发一次但原有消息入库保持;LINE Pay/OMG 发布事件各一次且原生可发;测试/视频路径不被路由改动。 - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=MerchantNotificationRouterTest,DeliveryOrderNotificationServiceTest,OmgPaymentNotifyServiceTest,PosOrderShOprateControllerTest,PosOrderQsOprateControllerTest,PosOrderAdminStatusControllerTest,LinePayControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` with JDK 21 and verify the new assertions fail first. - [ ] 迁移在用直接调用,替换仅 CID 判断,保持原事务后、异步及文案时机;不碰失效方法。 - [ ] Re-run the same directed command and confirm pass; inspect staged names and commit only Task 5 files. ## Task 6: 文档与整体验证 **Files:** Create `specs/033-native-push-routing/quickstart.md`; update `specs/012-im-user-integration/code-status.md`, `docs/order-push-overview.md`, `specs/033-native-push-routing/tasks.md`. - [ ] Document bundled classpath credentials (filenames only, no secret values), dev/prod APNs gateway selection, default-off, restart cutover/rollback, certificate expiry monitoring and real-device smoke checklist. - [ ] Run `mvn -pl ruoyi-admin -am -Dtest=BusinessPushPropertiesTest,FcmPushSenderTest,ApnsPushSenderTest,BusinessPushServiceTest,MerchantNotificationRouterTest,DeliveryOrderNotificationServiceTest,OmgPaymentNotifyServiceTest,PosOrderShOprateControllerTest,PosOrderQsOprateControllerTest,PosOrderAdminStatusControllerTest,LinePayControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` under JDK 21; then run `mvn -pl ruoyi-admin -am -DskipTests package` and inspect exit codes. - [ ] Inspect `git diff --check`, full diff and staged file list for unrelated edits, secrets, encoding or line-ending churn; commit only documentation/verification updates. Record any unrun live FCM/APNs test explicitly. ## Spec Coverage | Spec | Tasks | |---|---| | FR-001–002, US1 | 1, 4, 6 | | FR-003–007, US2–3 | 1, 2, 3, 4 | | FR-008–011, US4 | 4, 5, 6 | | FR-012 / no schema | 1–6 (no schema task) | ## Verification Command Setup PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1'` 和 `$env:PATH="$env:JAVA_HOME\bin;$env:PATH"`。2026-09-29 已运行 13 类定向测试(83 个用例,0 失败)、本次全模块 933 个测试(0 失败)及 `mvn -pl ruoyi-admin -am -DskipTests package`,退出码均为 0;已验证三端随包凭据可在本地加载,真实 FCM/APNs 设备投递和 App 展示未在本机验证,启用前按 `quickstart.md` 执行真机验收。 ## Task 7: 随包 classpath 凭据(2026-09-29 追加) **Files:** `BusinessPushProperties.java`、`FcmPushSender.java`、`ApnsPushSender.java`、对应测试、`application.yml`、`resources/business-push/` 九个按端命名的凭据文件、`spec.md`/`tasks.md`/`quickstart.md`。 - [x] 先写失败测试:三端 classpath 凭据可独立读取;APNs 配置的 topic/密码用于证书和请求;缺失/无效资源原生模式启动失败,默认 uni 不加载。 - [x] 从用户给出的三端文件各复制一份 FCM JSON、一份普通 `.p12`、一份配置 JSON 到资源目录,名字含接收端;`ios.p12` 与 `iosdev.p12` 内容相同,不重复提交,不使用 VoIP 证书。 - [x] 保留外部文件路径兼容,增加 classpath InputStream 加载;`application.yml` 默认指向按端命名资源且模式为 `uni`。不更改并行地图配置。 - [x] JDK 21 运行定向测试、模块构建,核对 JAR 中的九个资源和 Git 暂存范围;文档说明凭据入库风险及真机未验证,形成单独提交。 ## Task 8: 原生多骑手新单批量发送(2026-09-29 追加) 外卖和闪送仍沿用各自的骑手筛选、最多 20 人及事务提交后通知时机。仅 `native` 分支异步处理筛选结果:读取骑手当前 `cidType/deviceToken`,Android 按翻译后的标题、正文与订单 payload 分组,调用骑手端 FCM `sendEachForMulticast`,并按返回顺序记录每个失败类别;iOS、无有效原生地址的骑手保留原有逐人路径。每位骑手的 `PushEvent` 仍各发布一次;不改 `uni` 分支、旧批量方法、其他业务通知或数据库。 先用 `FcmPushSenderTest`、`NativeRiderBatchPushServiceTest` 及外卖/闪送通知测试证明缺少批量路由,再完成实现;最后运行受影响测试、模块回归和构建。真机投递仍需部署环境验证。 本次 JDK 21 全模块回归共 938 个测试,0 失败/错误/跳过;`mvn -pl ruoyi-admin -am -DskipTests package` 退出码为 0。真实 FCM/APNs 多设备投递未验证。 ## Task 9: 常驻匿名原生推送测试接口(2026-10-23 追加) 保留普通业务 `business-push.mode` 的现有行为。在 `ruoyi-admin` 增加 `POST /system/push/testNative`:DTO 接收 `appType`(user/merchant/rider)、`platform`(android/ios)、原生 `deviceToken`、标题、正文及可选 payload,不查询用户数据。FCM/APNs 发送器在测试调用时只加载当前端凭据,iOS 测试固定使用生产网关;发送成功返回“服务商已接受”,失败返回脱敏错误类别。接口按用户要求允许匿名访问,文档明确公网滥用风险与真机测试前提。用发送器和 Controller 定向测试证明默认 `uni` 下仍可测试,且业务路由不改变。