Bläddra i källkod

docs(spec-kit): plan native business push routing

qmj 2 veckor sedan
förälder
incheckning
c410bf47d2

+ 134 - 0
specs/033-native-push-routing/plan.md

@@ -0,0 +1,134 @@
+# 可切换原生业务推送 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 开发/生产地址。凭据路径和密码从外部环境注入,不提交密钥内容。
+- 只迁移在用的普通业务通知;音视频通话、已废弃入口和临时测试推送接口保持原行为。已有 `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` 和环境变量占位符;部署时分别提供 `business-push.apps.user`、`business-push.apps.merchant`、`business-push.apps.rider` 下的 `fcm-credentials-path`、`apns-certificate-path`、`apns-certificate-password`、`apns-topic`。`business-push.apns-environment` 为 `development` 或 `production`。只有 `native` 模式校验并加载三端凭据;配置中没有凭据内容。iOS 证书须对应所选环境与 topic。
+
+FCM 请求的 data 至少包含字符串形式的 `title`、`body`、原始 `payload`,并保留现有可解析 payload 的 `ddId/state/type/pushType` 字段;APNs 使用 `aps.alert.title/body`、普通通知 sound 和同名业务字段。骑手端沿用现有 `pushsound.caf`,其余端沿用现有默认声音。过大或无法解析的 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 环境、三端外部凭据路径与 topic 的配置绑定和原生模式校验 |
+| `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 protected filesystem paths, 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 selected-environment PKCS#12 into per-app TLS client, reuse JDK HTTP/2 connections, send to `/3/device/{token}`; 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 credential deployment (paths/env vars only, no values), dev/prod APNs 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"`。计划审阅通过后执行测试;此阶段没有运行编译或测试。

+ 6 - 5
specs/033-native-push-routing/spec.md

@@ -12,7 +12,7 @@
 
 当前业务推送通过 `PayPush` 调用用户、商家、骑手三套 uniCloud 云函数,部分路径通过 `PushEvent` 写入 `push_message`。`info_user` 已有 `cid`、`cidType`、`deviceToken`:`cid` 是 uniPush 标识,`deviceToken` 承载 Android FCM token 或 iOS APNs device token,两者不可互用。用户已确认同一账号只允许一台手机登录,新手机登录后旧手机登录状态失效,因此本功能沿用现有单设备字段,不新增推送设备表。
 
-本功能覆盖当前仍在使用的普通业务通知,包括订单、闪送、支付结果及平台/商家通知等现有发送路径;只替换发送通道,不增加新的业务触发时机或文案。不修改已废弃的支付/订单入口,也不改动音视频通话推送。App 端 token 获取、上报及通知展示不在本功能实现范围;服务端以已有上报字段为输入。
+本功能覆盖当前仍在使用的普通业务通知,包括订单、闪送、支付结果及平台/商家通知等现有发送路径;只替换发送通道,不增加新的业务触发时机或文案。不修改已废弃的支付/订单入口、临时测试推送接口,也不改动音视频通话推送。App 端 token 获取、上报及通知展示不在本功能实现范围;服务端以已有上报字段为输入。
 
 ## User Scenarios & Testing *(mandatory)*
 
@@ -44,7 +44,7 @@
 
 1. **Given** 通道为 `native`、接收方系统为 Android 且有有效 `deviceToken`,**When** 普通业务通知触发,**Then** 通过该接收端对应的 Firebase 项目向 `deviceToken` 发送一次;不得把 `cid` 传给 FCM。
 2. **Given** 用户、商家、骑手分别使用不同 Firebase 项目,**When** 三端各有通知触发,**Then** 每条通知使用对应项目凭据,不串用项目。
-3. **Given** 普通商家或夜市商家是接收方,**When** 原生通知触发,**Then** 均按商家端凭据路由。
+3. **Given** 普通商家、夜市商家或商家子账号是接收方,**When** 原生通知触发,**Then** 均按商家端凭据路由。
 
 ---
 
@@ -85,7 +85,8 @@
 - `deviceToken` 为空、失效或来自另一 App/项目:不把 `cid` 当作替代 token;记录失败结果,失效 token 的后续处理应避免误删新手机刚更新的 token。
 - 同一账号换手机或 token 刷新:后端始终以当前账号最新上报的 `cidType` 与 `deviceToken` 为发送目标;本功能不改变现有登录互斥机制。
 - 批量发送含不同接收端或系统:按接收端、系统及相应项目/证书分组,不把不同项目 token 混发,也不因部分失败重复发送成功项。
-- 业务代码直接调用底层 uniPush 方法:在范围内的普通业务通知必须纳入统一路由;已废弃代码及音视频通话流程除外。
+- 业务代码直接调用底层 uniPush 方法:在范围内的普通业务通知必须纳入统一路由;已废弃代码、临时测试推送接口及音视频通话流程除外。
+- 在用路径存在“CID 为空则不选择收件人/不触发推送”的前置判断:默认 uni 模式维持原判断效果;原生模式应以当前账号的 `deviceToken` 判断可发送性,不得因 CID 为空漏掉有原生 token 的收件人。
 
 ## Requirements *(mandatory)*
 
@@ -93,12 +94,12 @@
 
 - **FR-001**: 服务端 MUST 提供普通业务推送的全局通道配置,取值为 `uni`/`native`,未配置时默认为 `uni`;修改后重启生效,不新增管理后台实时开关。
 - **FR-002**: 默认 `uni` 模式 MUST 保持现有用户、商家、骑手 uniCloud 发送行为与消息中心入库行为;不得要求原生凭据存在。
-- **FR-003**: `native` 模式 MUST 根据接收端类型和 `cidType` 路由:Android 使用对应 App 的 FCM,iOS 使用对应 App 的 APNs;普通商家和夜市商家均属于商家端。
+- **FR-003**: `native` 模式 MUST 根据接收端类型和 `cidType` 路由:Android 使用对应 App 的 FCM,iOS 使用对应 App 的 APNs;普通商家、夜市商家及商家子账号均属于商家端。
 - **FR-004**: 原生发送 MUST 只使用 `deviceToken`,uniPush MUST 只使用 `cid`;不得相互复制或替代,`voIPToken` 不得用于普通通知。
 - **FR-005**: 用户、商家、骑手三套 Firebase 项目及 APNs 普通通知证书 MUST 独立配置并正确选择;iOS MUST 区分开发与生产环境。
 - **FR-006**: 原生凭据及证书密码 MUST 从服务端受保护的外部配置读取,不得写入源码、规格、日志或打包进公开构件。
 - **FR-007**: 原生模式的必要凭据不完整时 MUST 在启动阶段明确失败;默认 uni 模式 MUST 可在无原生凭据时正常启动。
-- **FR-008**: 所有当前在用的普通业务推送路径 MUST 使用一致的通道选择规则,包括单发、批量及直接调用现有底层方法的路径;不改变业务触发时机、文案和 payload 语义。
+- **FR-008**: 所有当前在用的普通业务推送路径 MUST 使用一致的通道选择规则,包括适用的单发、批量及直接调用现有底层方法的路径;不改变业务触发时机、文案和 payload 语义。临时测试推送接口不在范围内。
 - **FR-009**: 单次业务通知 MUST 只使用一个外部发送通道;原生发送失败或缺少 token 时 MUST NOT 自动回退到 uniPush,且 MUST NOT 使原业务操作失败。
 - **FR-010**: 对现有会入库的推送,通道切换 MUST NOT 增加或漏掉 `push_message` 记录;消息中心记录与外部发送结果保持解耦。
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。

+ 30 - 0
specs/033-native-push-routing/tasks.md

@@ -0,0 +1,30 @@
+# Tasks: 可切换的原生业务推送通道
+
+**Input**: `specs/033-native-push-routing/spec.md`、`plan.md`
+
+**Execution**: 当前 `test-202609v2` 分支;不创建新分支。每项先写并运行失败测试,再实现、验证、核对暂存范围并提交。默认模式始终为 `uni`。
+
+## Phase 1: 默认通道与配置(US1)
+
+- [ ] T001 [US1] 在 `ruoyi-admin/src/main/java/com/ruoyi/app/push/` 定义接收端枚举、`business-push` 配置及原生模式启动校验;`ruoyi-admin/src/main/resources/application.yml` 仅设置默认 `uni` 与非秘密占位符。
+- [ ] T002 [US1] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushPropertiesTest.java` 覆盖默认启动、原生凭据缺失/无效和三端配置。
+
+## Phase 2: 原生发送器(US2、US3)
+
+- [ ] T003 [US2] 在 `FcmPushSender.java` 实现三命名 FirebaseApp 和按接收端 FCM 发送;在 `FcmPushSenderTest.java` 验证项目隔离、payload、失效错误与无敏感日志。
+- [ ] T004 [US3] 在 `ApnsPushSender.java` 实现三端证书型 APNs HTTP/2 发送及环境选择;在 `ApnsPushSenderTest.java` 验证 topic、payload、证书错误与响应分类。
+
+## Phase 3: 统一路由与业务集成(US1–US4)
+
+- [ ] T005 [US1–US4] 在 `BusinessPushService.java` 实现 uni/native 选择、当前账号 token 读取、地址判定及异常隔离;在 `BusinessPushServiceTest.java` 覆盖默认 uni、六种原生组合及不自动回退。
+- [ ] T006 [US1–US4] 修改 `PayPush.java` 的普通业务包装方法委托 T005;保留低层 uniCloud 方法供 uni 模式及排除路径使用,验证消息中心不重复入库。
+- [ ] T007 [US2–US4] 将在用普通业务直连与 CID 前置过滤迁移到统一路由:`MerchantNotificationRouter.java`、`LinePayOrderNotificationService.java`、`OmgPaymentNotifyService.java` 和当前有效订单 Controller;同步更新相关测试。不改音视频、废弃入口或临时测试推送接口。
+
+## Phase 4: 文档与验证
+
+- [ ] T008 在 `specs/033-native-push-routing/quickstart.md` 记录三端外部凭据配置、默认关闭、启用/回退、真机检查;更新 `specs/012-im-user-integration/code-status.md` 与 `docs/order-push-overview.md`。
+- [ ] T009 使用 JDK 21 运行定向测试和 `ruoyi-admin` 模块构建;核对完整 diff、暂存范围及未执行的外部 FCM/APNs 真机验证,再提交文档收口。
+
+## Dependencies
+
+T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APNs 发送器可以独立评审,但本任务按当前会话顺序执行,不启动 Agent。