Explorar o código

test(push): verify scene forwarding and document frontend integration

qmj hai 1 semana
pai
achega
9aff95dae6

+ 16 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushSenderTest.java

@@ -38,6 +38,22 @@ class ApnsPushSenderTest {
     @TempDir
     Path tempDir;
 
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.EnumSource(UserNotificationScene.class)
+    void userSceneStaysInFullPayloadAndKeepsDefaultSound(UserNotificationScene scene) throws Exception {
+        StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT));
+        sender.initialize();
+        String payload = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "2", -1, 3, scene);
+        sender.send(RecipientApp.USER, "a".repeat(64), "title", "content", payload);
+        var captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
+        verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
+        JSONObject body = JSON.parseObject(bodyOf(captured.getValue()));
+        assertEquals(payload, body.getString("payload"));
+        assertFalse(body.containsKey("eventScene"));
+        assertEquals("default", body.getJSONObject("aps").getString("sound"));
+        assertEquals("alert", captured.getValue().headers().firstValue("apns-push-type").orElseThrow());
+    }
+
     @Test
     void routesThreeAppsToOwnCertificateTopicAndSandbox() throws Exception {
         StubSender sender = new StubSender(nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT));

+ 44 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java

@@ -33,6 +33,50 @@ class BusinessPushServiceTest {
     private final ApnsPushSender apns = mock(ApnsPushSender.class);
     private final PayPush uni = mock(PayPush.class);
 
+    @Test
+    void scenePayloadIsUnchangedAcrossUniFcmAndApnsRouting() {
+        BusinessPushService router = service();
+        String payload = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "2", -1, 1,
+                UserNotificationScene.ORDER_DELIVERING);
+        router.send(RecipientApp.USER, 9L, "cid", "title", "content", payload);
+        verify(uni).apppush("cid", "title", "content", payload);
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        InfoUser user = user(9L, "android", "token"); when(users.getById(9L)).thenReturn(user);
+        router.send(RecipientApp.USER, 9L, null, "title", "content", payload);
+        verify(fcm).send(RecipientApp.USER, "token", "title", "content", payload);
+        user.setCidType("ios");
+        router.send(RecipientApp.USER, 9L, null, "title", "content", payload);
+        verify(apns).send(RecipientApp.USER, "token", "title", "content", payload);
+    }
+
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.EnumSource(BusinessPushProperties.Mode.class)
+    void noAddressStillInsertsOneSceneMessageWithoutExternalSend(BusinessPushProperties.Mode mode) {
+        properties.setMode(mode);
+        when(users.getById(9L)).thenReturn(user(9L, null, null));
+        BusinessPushService router = service();
+        var repository = mock(com.ruoyi.system.service.IPushMessageService.class);
+        var listener = new com.ruoyi.app.utils.event.PushEventListener();
+        org.springframework.test.util.ReflectionTestUtils.setField(listener, "pushMessageService", repository);
+        var events = new PushEventService();
+        var publisher = mock(org.springframework.context.ApplicationEventPublisher.class);
+        org.mockito.Mockito.doAnswer(call -> {
+            listener.onApplicationEvent(call.getArgument(0)); return null;
+        }).when(publisher).publishEvent(any(com.ruoyi.app.utils.event.PushEvent.class));
+        org.springframework.test.util.ReflectionTestUtils.setField(events, "eventPublisher", publisher);
+        String body = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "0", -1, 1, UserNotificationScene.RIDER_ACCEPTED);
+        try (var spring = mockStatic(SpringUtils.class)) {
+            spring.when(() -> SpringUtils.getBean(BusinessPushService.class)).thenReturn(router);
+            PayPush.userPush(new PayPush(), events, 9L, null, "title", "content", body, "");
+        }
+        var message = org.mockito.ArgumentCaptor.forClass(com.ruoyi.system.domain.PushMessage.class);
+        verify(repository).insertPushMessage(message.capture());
+        assertEquals(body, message.getValue().getBody()); assertEquals(9L, message.getValue().getUserId());
+        verify(uni, never()).apppush(any(), any(), any(), any());
+        verify(fcm, never()).send(any(), any(), any(), any(), any());
+        verify(apns, never()).send(any(), any(), any(), any(), any());
+    }
+
     @Test
     void defaultModeUsesOnlyExistingUniCloudEndpoints() {
         BusinessPushService service = service();

+ 17 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java

@@ -31,6 +31,23 @@ class FcmPushSenderTest {
     @TempDir
     Path tempDir;
 
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.EnumSource(UserNotificationScene.class)
+    void userSceneStaysInFullPayloadAndKeepsDefaultSound(UserNotificationScene scene) throws Exception {
+        StubSender sender = new StubSender(nativeProperties()); sender.initialize();
+        String payload = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "2", -1, 3, scene);
+        sender.send(RecipientApp.USER, "user-token", "title", "content", payload);
+        var captured = org.mockito.ArgumentCaptor.forClass(Message.class);
+        verify(sender.messaging.get(RecipientApp.USER)).send(captured.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, String> data = (Map<String, String>) field(captured.getValue(), "data");
+        assertEquals(payload, data.get("payload"));
+        assertFalse(data.containsKey("eventScene"));
+        Object notification = field(field(captured.getValue(), "androidConfig"), "notification");
+        assertEquals(Boolean.TRUE, field(notification, "defaultSound"));
+        assertEquals(null, field(notification, "channelId"));
+    }
+
     @Test
     void sendsEachRecipientThroughItsOwnProjectWithOriginalPayload() throws Exception {
         BusinessPushProperties properties = nativeProperties();

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/event/PushEventListenerTest.java

@@ -26,6 +26,18 @@ class PushEventListenerTest {
         ReflectionTestUtils.setField(listener, "pushMessageService", pushMessageService);
     }
 
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.EnumSource(com.ruoyi.app.push.UserNotificationScene.class)
+    void preservesCompleteUserSceneBodyWithoutNewColumns(com.ruoyi.app.push.UserNotificationScene scene) {
+        String body = com.ruoyi.app.order.dto.OrderPushBodyDto.getUserJson("ORDER-1", "PICKED_UP", -1, 3, scene);
+        listener.onApplicationEvent(new PushEvent(9L, "title", "content", body));
+        var captured = ArgumentCaptor.forClass(PushMessage.class);
+        verify(pushMessageService).insertPushMessage(captured.capture());
+        assertEquals(body, captured.getValue().getBody());
+        assertEquals("ORDER-1", captured.getValue().getDdId());
+        assertEquals(3, captured.getValue().getPushType());
+    }
+
     @Test
     void extractsDdIdAndPushTypeFromBodyJsonWhenInserting() {
         listener.onApplicationEvent(new PushEvent(9L, "訊息", "您有新的外送訂單",

+ 17 - 26
specs/035-user-notification-voice/checklists/requirements.md

@@ -1,31 +1,22 @@
-# Specification Quality Checklist: 用户业务通知语音提醒
+# Specification Quality Checklist: 用户通知 eventScene
 
-**Created**: 2026-10-09
+**Created:** 2026-10-09。**Feature:** [spec.md](../spec.md)。
 
-**Feature**: [spec.md](../spec.md)
+**进度:**实现中。以下为文档自检,不是生产代码测试或真机验证。
 
-**进度**:实现中;以下是规格文本自检,不是生产代码测试、资源验收或真机验证。
+- [x] CHK001 仅现有用户通知新增 eventScene,不另发语音通知。
+- [x] CHK002 七场景及外卖/闪送/自配送实际触发边界明确。
+- [x] CHK003 保留 ddId/state/type/pushType,不复用 type=0 或正文猜场景。
+- [x] CHK004 不增加事件 ID、语种、接收用户或时间字段,无改名替代。
+- [x] CHK005 记录无设备地址仍入库、成功提交后通知及失败隔离。
+- [x] CHK006 App、音频、云函数、PC、声音/渠道不修改,前端只做前台播放。
+- [x] CHK007 记录前端自行选语言及无法保证精准去重、排序、跨账号识别的限制。
+- [x] CHK008 现有 DTO 工厂扩展单字段,不创建额外事件封装类。
+- [x] CHK009 spec、plan、tasks、接入文档字段与方法一致,无已上线声明。
+- [x] CHK010 2026-10-09 原书面规格已确认,随后边界按用户反馈修订。
+- [x] CHK011 用户已确认继续,按单字段精简计划原分支实施。
+- [x] CHK012 后端 149 项定向测试及模块构建通过;真实联调未执行,已明确交接,不以文档检查替代。
 
-## 范围与需求自检
+## 变更记录
 
-- [x] CHK001 记录同一条通知同时提供文字/短语音,不额外发第二条语音通知。
-- [x] CHK002 覆盖截图三个核心节点和其他实际存在的用户通知,列出逐场景文案。
-- [x] CHK003 明确外卖/闪送/商家自配送差异,部分取件和跳过开始配送不产生虚构事件。
-- [x] CHK004 保留旧 payload,明确不能复用 type=0 或根据旧 state 猜场景。
-- [x] CHK005 记录无 CID/token 消息中心仍入库,事务失败/回滚不产生成功通知。
-- [x] CHK006 仅做前台语音,记录事件去重、历史不补播、转后台停止播放及平台声音限制。
-- [x] CHK007 记录四语言实际前台音频、App 打包资源及旧客户端普通通知回退验收。
-- [x] CHK008 排除云函数修改、后台/锁屏语音、通话、PC、商家/骑手新单语音、缺失通知补建和远端部署。
-- [x] CHK009 记录现有云端 payload 透传需联调验证与源码分支事实,未声称线上或真机已经验证。
-- [x] CHK010 使用当前 spec-kit 模板必需的用户故事、验收场景、需求、实体、可衡量结果和依赖,无模板占位符。
-
-## 进入下一阶段
-
-- [ ] CHK011 用户审阅并确认书面 spec 后进入 plan;尚未开始编写实现计划、任务分解或产品代码。
-- [ ] CHK012 实现计划确定后再生成 tasks,并选择执行方式;不得把本清单的自检结果计为实现完成。
-
-## 用户已确认与待审阅的区别
-
-用户已确认总体方案和原分支 spec-kit 工作方式,不重复询问这些已确认事项。本次新增书面 spec 需按方案设计技能审阅,再进入计划与实现;期间不创建/切换分支,不提交或推送产品代码。
-
-2026-10-09 用户收缩本期范围:只做前台语音提醒,后台/锁屏保持原有通知,云函数不修改、不部署。此变更已同步到规格范围、用户故事、需求和验收条件;产品代码尚未开始。
+2026-10-09 已由前后台语音全流程收缩到前台,并排除云函数;之后按用户要求 App 由前端人员负责,只交付后端与接入文档。用户确认 eventScene 的用途并拒绝其他新增字段,随后确认继续实施。后端已接入,统一验证收口中;分支未创建/切换。

+ 85 - 0
specs/035-user-notification-voice/frontend-integration.md

@@ -0,0 +1,85 @@
+# 用户前台语音提醒:前端接入文档
+
+**日期:**2026-10-09。
+
+**状态:**后端已验证,未部署;本地七场景接入、149 项定向测试及模块构建通过。App 实现与真实云端/真机联调由前端完成;示例不代表线上已返回该字段。
+
+## 责任与范围
+
+后端在原来同一条用户订单通知中只增加 eventScene,保留文字与既有字段,不增加第二条推送、声音 URL 或语音接口。App 接收/播放、语言、音频和真机由前端人员完成,本任务不修改 App 或云函数。
+
+仅在 App 前台收到新通知时播报;后台/锁屏声音保持现状。点击通知、消息列表加载、恢复 App 不补播,不使用通话/VoIP/后台保活绕过系统限制。
+
+## 读取与字段
+
+- uniPush:读取消息 payload,可能是 JSON 字符串或对象,按实际 SDK 解包。
+- FCM:完整业务 JSON 在 data.payload;APNs 在 userInfo.payload,通常为字符串。
+- 消息中心:已有记录 body 保存同一 JSON,但读取历史 body 不触发声音。
+- 新字段只在完整 JSON 内,不要求同名顶层扁平字段;JSON 错误、缺场景或未知场景只保留原文字,不按正文猜声音。
+
+| 字段 | 类型 | 用途 |
+| --- | --- | --- |
+| ddId | 原类型不变,通常 string | 订单标识 |
+| state | 原类型不变,通常 string | 原状态,不是统一语音分类依据 |
+| type | number | 保持原值;0 已有其他用途,不表示用户语音场景 |
+| pushType | number | 1 外卖、2 充值、3 闪送,保留原契约 |
+| eventScene | string,可缺省 | 唯一新增字段,下表场景之一 |
+
+不新增 eventId、voiceLanguage、recipientUserId、eventTime,也不以别的字段名替代。前端直接按 eventScene 决定语句,再按 App 自身语言设置选择音频。后端文字仍按用户 Locale,本期不能从 payload 得知其语种或保证声音/文字语言完全一致。
+
+## 场景与短语音
+
+| eventScene | 简中建议语音 | 既有通知范围 |
+| --- | --- | --- |
+| RIDER_ACCEPTED | 骑手已接单 | 外卖/闪送骑手实际接单 |
+| ORDER_DELIVERING | 您的订单已派送 | 外卖取餐、闪送全部取件、自配送开始 |
+| ORDER_DELIVERED | 您的订单已送达 | 外卖/闪送送达、自配送完成 |
+| MERCHANT_ACCEPTED | 商家已接单 | 现有商家自配送接单用户通知 |
+| MERCHANT_READY | 商家已出餐 | 现有商家出餐用户通知 |
+| PAYMENT_SUCCEEDED | 您的订单已支付 | 在用 LINE/OMG 用户支付成功通知 |
+| ORDER_CANCELLED | 您的订单已取消 | 现有外卖取消链路实际通知用户的情况 |
+
+不增加通知时机:闪送部分取件不发派送,当前闪送取消只通知骑手,本期不补用户取消;自配送直接送达不补造开始配送。平台旧入口的数字 state 含义不同,不能用 state=3 一律判断送达。
+
+## 示例(后端协议示例,未部署)
+
+```json
+{
+  "ddId": "DD-DEMO-001",
+  "state": "0",
+  "type": -1,
+  "pushType": 1,
+  "eventScene": "RIDER_ACCEPTED"
+}
+```
+
+闪送全部取件使用 pushType=3、state="PICKED_UP"、eventScene="ORDER_DELIVERING"。支付通知即使保留 type=0,也按 PAYMENT_SUCCEEDED 选语音。无 eventScene 的旧通知不自动播报。
+
+## 前端处理建议及限制
+
+1. 只在收到消息且 App 确在前台、已登录时处理场景;点击、冷启动恢复和历史查询只展示/导航。
+2. 使用一个统一入口决定展示/播放责任,避免 uniPush 和原生监听器各自播放。SDK 若提供原有消息 ID,可用于本地去重;本期不提供后端事件 ID,不能保证精准跨回调去重。
+3. 不只按 ddId 去重,否则同单各状态互相覆盖;也不永久按 ddId+eventScene 去重,因为同场景可能合法再次发生。简单短时间去抖是前端权衡,不是恰好一次投递保证。
+4. 按场景和 App 自身语言映射本地短音频,单播放器串行播放,不叠加铃声。不播订单号/联系人/地址,不接受任意远程音频路径。
+5. 转后台、锁屏、登出或账号切换时停止/清空队列,恢复不补播。没有接收用户字段,payload 本身不能判别换号后迟到的消息归属,须依赖现有会话/设备绑定机制。
+6. 没有事件时间/版本,仅凭 payload 无法完整识别迟到、乱序或历史;不能把所有收到的消息当作最新订单状态。详情仍以业务查询为准。
+7. 音频缺失、播放失败或系统限制时保留文字,不请求后端补推,不强制音量,不启动后台保活。
+
+前台播放器闲置且系统允许发声时,目标为实际收到通知后 2 秒内开始播报;具体实现、声音设置及效果由前端核验,后端不保证投递时延或播报成功。
+
+## 点击与验收
+
+点击按 pushType 区分实际外卖/闪送详情页,不把闪送 ddId 当外卖查询;不因点击再播声音。消息入库不代表外部已投递,外部到达也不代表声音已播放;本期无播放回执、可靠补发或跨通道自动回退。
+
+前端联调检查:
+
+- [ ] 七个场景选句正确,App 语言与资源对应,缺资源降级保留文字。
+- [ ] 统一前台接收入口,检查多回调叠音风险;不声称只有本字段即可精准去重。
+- [ ] Android/iOS 前台播放;后台/锁屏不新增语音,恢复/点击/历史不补播。
+- [ ] 多单连续通知、时序异常、账号切换、音频中断、静音/权限等按现有信息测试并记录限制。
+- [ ] 旧 App 收新 JSON、新 App 收旧 JSON 保持通知,无 JSON 崩溃。
+- [ ] 测试环境完整 payload 保留 eventScene;实际云端版本未验证,丢字段先反馈,不擅改云函数。
+
+## 当前验证记录
+
+七场景已接入,JDK 21 统一定向测试 149/149 通过,`mvn -pl ruoyi-admin -am -DskipTests package` 为 BUILD SUCCESS;整体审查和扩展回归另记于 tasks。App、插件、音频、云函数未修改,真实云端/部署和真机未验证。新字段仅在完整 payload/body 内,不应从顶层查找 eventScene。

+ 111 - 0
specs/035-user-notification-voice/plan.md

@@ -0,0 +1,111 @@
+# 用户通知 eventScene Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for inline execution; use superpowers:subagent-driven-development only if the user selects that method. Steps use checkbox syntax for tracking.
+
+**Goal:** 只在既有用户订单通知中新增 eventScene,供前端控制前台语音,并交付接入文档。
+
+**Architecture:** 使用现有 OrderPushBodyDto 增加一个可选场景及用户专用 JSON 工厂,实际成功动作明确传入枚举。用户通知入库与外部发送复用同一个 body;旧工厂和商家/骑手通知不增加场景。App、音频、云函数及声音配置均不修改。
+
+**Tech Stack:** Java 21、Spring Boot、既有 JSON 工具、事务同步、JUnit 5/Mockito、uniPush/FCM/APNs。
+
+**Spec:** [spec.md](spec.md)
+
+**Date / Branch / Progress:** 2026-10-09 / 当前 test-202609v2 / 实现中(用户已确认执行,最终验证收口中,未部署)。
+
+## Global Constraints
+
+- 原分支工作,不创建 worktree、不切换/合并/改写其他分支。
+- 只新增 eventScene,不生成事件 ID,不传语音语种、接收账号或时间,不提供替代字段/额外接口。
+- 不修改 App、原生插件、云函数、PC、数据库结构、声音/通知渠道、支付退款或配送状态机。
+- 保留 ddId/state/type/pushType;不占用 type=0,不靠中文正文或跨入口的通用 state 判断场景。
+- 只覆盖当前实际用户通知,不新增闪送用户取消、超时、退款等通知时机。
+- 文字保留当前 Locale;前端按自身设置选音频,不能保证文字/声音语种一致。
+- 无设备地址仍写用户消息;推送失败不改变业务结果,事务回滚不发成功通知。
+- 关键代码写注释,保留文件编码和换行;集成代码放 ruoyi-admin。
+- 前端采用统一接收入口;本期没有足够字段保证精准去重、乱序识别或跨账号过滤,不在文档承诺这些能力。
+
+## Review Focus
+
+1. 旧 JSON 和新 JSON 的差异仅 eventScene;Task 1 精确断言键集合、旧字段值。
+2. 同一动作通知用户/商家/骑手时不能污染非用户 body;Task 2/3 分别捕获参数。
+3. 无 CID/token 时不能跳过用户消息记录;Task 2/4 断言入库一次、外发零次。
+4. 自配送直接送达、闪送部分取件、失败/回滚不能虚构成功场景;Task 2 断言调用次数。
+5. 推送异常和默认声音不能因场景改动改变订单结果或渠道;Task 3/4 回归;客户端去重等限制由文档交接。
+
+## Technical Context / Constitution Check
+
+push_message.body 已保存完整 JSON,FCM/APNs 已转发完整 payload;无需新表、SQL、顶层字段或云函数改动。.specify/memory/constitution.md 是未填写模板,按 AGENTS.md 和用户最新范围执行,无需例外。
+
+本次实现及本地验证不运行真实支付、数据库操作或生产推送,不读取凭据。已取消此前 UserNotificationPayload 类、事件 ID/语言映射封装和用户推送重载的计划,不改 PayPush 本地化逻辑。
+
+## Project Structure / Interfaces
+
+后端根目录 D:/QtwCode/foodie/foodie-server;以下生产路径以 ruoyi-admin/src/main/java/com/ruoyi/app/ 为基准,测试路径以 ruoyi-admin/src/test/java/com/ruoyi/app/ 为基准。
+
+- 新建 push/UserNotificationScene.java:七个允许值,枚举名称为协议值。
+- 修改 order/dto/OrderPushBodyDto.java:新增可选 String eventScene;新增静态工厂:
+  `getUserJson(String ddId, String state, int type, int pushType, UserNotificationScene scene): String`。
+- 工厂使用原构造方式,scene 非空才设置场景。旧 getJson/getMap 保持输出,null 场景不输出该 key,不新增其他字段。
+- 闪送用户方法改为 `sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey, UserNotificationScene scene): void`。
+- 自配送用户方法改为 `pushSelfDeliveryStatus(PosOrder order, String contentKey, String stateStr, UserNotificationScene scene): void`,按原事务存在与否在成功提交/持久化后调度。
+- 其他现有用户包装签名不变,收到带 eventScene 的 body 后照常入库/发送,不重新解释场景。
+- 文档集中在本目录 spec.md、plan.md、tasks.md、frontend-integration.md、checklists/requirements.md。
+
+## Task 1: 单字段场景契约(US2/US3)
+
+**Files:** 新建 push/UserNotificationScene.java、测试 order/dto/OrderPushBodyDtoTest.java;修改 order/dto/OrderPushBodyDto.java。
+
+**Interfaces:** 产出上述 getUserJson 和七场景枚举;不再创建额外 payload 封装类。
+
+- [x] 写测试 `preservesLegacyFieldsAndAddsOnlyEventScene`:旧四字段原值不变,新键集合仅多 eventScene;`oldFactoryOmitsEventScene` 断言旧 getJson/getMap 无新增 key;覆盖枚举七值及 null 场景。
+- [x] 运行 Task 1 定向测试,确认因新工厂/场景缺失的预期失败;实现可选字段、用户工厂和 enum,并加兼容注释。
+- [x] 重跑要求零失败,核对 DTO 序列化没有额外 null 字段,检查差异和暂存范围后独立本地提交,不推送。
+
+## Task 2: 外卖、闪送、自配送和出餐(US1/US4)
+
+**Files:** 修改 order/PosOrderQsOprateController.java、order/PosOrderController.java、order/PosOrderShOprateController.java、flashdelivery/service/FlashDeliveryNotificationService.java。扩展测试 order/PosOrderQsOprateControllerTest.java、order/PosOrderAdminStatusControllerTest.java、order/PosOrderShOprateControllerTest.java、flashdelivery/service/FlashDeliveryNotificationServiceTest.java、flashdelivery/service/FlashDeliveryMultiPickupTest.java。
+
+**Interfaces:** 使用 Task 1 getUserJson,仅用户 body 带场景。现代 deliveryStatus 1/2/3 的成功动作对应 RIDER_ACCEPTED/ORDER_DELIVERING/ORDER_DELIVERED;现有有效旧入口内部 state 3/4/12 分别明确对应这三个动作,不改 state 值。
+
+- [x] 写并运行预期失败测试:成功三节点场景正确、无地址消息不丢、失败/回滚零成功通知、部分取件零派送、全部取件一次派送、自配送直接完成仅送达、非用户 body 无场景。
+- [x] 成功动作传明确枚举;自配送商家接单 MERCHANT_ACCEPTED、出餐 MERCHANT_READY、开始/完成 ORDER_DELIVERING/ORDER_DELIVERED,不误播骑手接单。
+- [x] 移除仅针对用户入库的地址前置阻断,外发仍由原通道判断;存在事务时 afterCommit,无事务时持久化成功后发送,不扩张支付/发票/账单事务。
+- [x] 跑 Task 2 测试捕获实际调用参数,零失败后审阅并独立本地提交,不推送。
+
+## Task 3: 现有支付成功与取消(US3/US4)
+
+**Files:** 修改 pay/LinePayOrderNotificationService.java、omgpay/OmgPaymentNotifyService.java、order/DeliveryOrderNotificationService.java;扩展同目录对应三份现有测试。
+
+**Interfaces:** 用户专用 getUserJson body 复用于 PublisherEvent/BusinessPushService;商家/骑手用原始独立 body。
+
+- [x] 写并运行预期失败测试:用户支付 PAYMENT_SUCCEEDED、用户取消 ORDER_CANCELLED;type=0 原值不变,同动作非用户 payload 无 eventScene,无地址仍入库。
+- [x] 仅改现有提交后用户通知,不改 OMG/LINE 验签、核销、幂等、退款、查询或通知对象;不新增闪送用户取消。016 plan 已读。
+- [x] 跑三组定向测试,包括重复支付不新增成功通知,检查差异后独立本地提交,不推送。
+
+## Task 4: 兼容验证和文档交接(全部故事)
+
+**Files:** 扩展测试 push/BusinessPushServiceTest.java、push/FcmPushSenderTest.java、push/ApnsPushSenderTest.java、utils/event/PushEventListenerTest.java,收口本目录文档。
+
+**Interfaces:** 验证现有完整 payload 保留 eventScene,不增加顶层字段、不改声音。只有测试确证 uni 用户 GET 编码破坏 JSON 时,才在 utils/PayPush.java 修该路径参数编码,不改云函数。
+
+- [x] 写并运行通道/入库测试:完整 JSON 场景保持、body 一致、缺地址仅入库、通道异常不补发或影响业务、旧 payload 和 default sound/qsRing/pushsound.caf 不变。
+- [x] 对照最终代码收口接入文档;明确仅一个新增字段、前端选语言、无事件 ID 等限制,记录后端与 App 验证边界。
+- [x] 跑下方定向测试和模块构建、diff 和暂存检查,零失败后本地提交。不要自动推送、部署或生产试发。
+
+## Verification Commands
+
+执行时每 Task 缩小同一 -Dtest 列表,先证明预期失败再通过;既有通道透传只增加回归测试,不改发送实现。控制器公共场景/事务夹具集中在 UserOrderNotificationSceneTest。
+
+```powershell
+$env:JAVA_HOME = 'C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1'
+$env:PATH = "$env:JAVA_HOME/bin;$env:PATH"
+mvn -pl ruoyi-admin -am '-Dtest=OrderPushBodyDtoTest,UserOrderNotificationSceneTest,PosOrderQsOprateControllerTest,PosOrderShOprateControllerTest,PosOrderAdminStatusControllerTest,FlashDeliveryNotificationServiceTest,FlashDeliveryMultiPickupTest,LinePayOrderNotificationServiceTest,OmgPaymentNotifyServiceTest,DeliveryOrderNotificationServiceTest,BusinessPushServiceTest,FcmPushSenderTest,ApnsPushSenderTest,PushEventListenerTest' '-Dsurefire.failIfNoSpecifiedTests=false' test
+mvn -pl ruoyi-admin -am -DskipTests package
+git diff --check
+```
+
+预期测试零失败/零错误、两次 BUILD SUCCESS、diff 检查退出码 0。Java 环境只对当前进程生效,不改全局配置。实际云端场景透传及 App 真机声音由联调核验,未执行必须单独记录。
+
+## Dependencies / Handoff
+
+Task 1 → Task 2 → Task 3 → Task 4;详单见 tasks.md。用户已确认继续,按本计划顺序实施;不创建分支或 worktree。收口时依执行计划技能安排一次只读整体审查,不分派实现任务。

+ 46 - 38
specs/035-user-notification-voice/spec.md

@@ -4,24 +4,26 @@
 
 **Created**: 2026-10-09
 
-**Status**: 实现中(specify:书面规格待用户审阅;尚未进入 plan/tasks/implement)
+**Status**: 实现中(单字段方案已确认并实施;最终构建与审查收口中,未部署)
 
-**工作分支**:后端沿用 `test-202609v2`;用户 App 沿用本地当前 `master`。不创建分支或 worktree,不切换、合并或改写其他分支。
+**工作分支**:后端沿用 `test-202609v2`。不创建分支或 worktree,不切换、合并或改写其他分支;不修改任何 App 仓库。
 
 **Input**: 用户要求现有用户消息通知同时提供简短语音提醒,例如“骑手已接单”“您的订单已派送”“您的订单已送达”;已同意沿用现有通知、随 App 打包音频、前台统一播放、事件去重以及消息中心独立入库,采用 spec-kit 流程实施。2026-10-09 用户进一步收缩范围:本期只做前台语音,不做后台/锁屏语音,不修改云函数。
 
+**最新责任边界(2026-10-09)**:App 改动由前端人员完成;本任务只实现后端 eventScene、定向验证及前端接入文档。用户已明确取消此前计划的 eventId、voiceLanguage、recipientUserId、eventTime,不以改名、隐藏字段或额外接口替代。不制作音频、不修改 App/原生插件或云函数;客户端播放条件是交接要求,不计为本任务已验证能力。
+
 ## 已确认方案及范围
 
-- 在同一条既有业务通知中增加场景信息和语音,不额外发送第二条语音消息。
-- 后端、用户 App 的前台统一接收和播放,以及为前台分流必需的 Android/iOS 接收代码在范围内。用户本次批准的跨端方案包含 App 配套,覆盖此前其他任务“不修改 App”的局部要求。
+- 在同一条既有用户业务通知中只新增 eventScene,供前端选择短语音,不另发语音消息。此前事件唯一标识和语言元数据方案已被用户最新要求取消。
+- 只修改后端通知代码、定向测试和本 feature 文档。前端接入文档覆盖前台统一接收、播放、Android/iOS 分流、资源及验收要求,实际 App 实现由前端人员负责。
 - 云函数不修改、不部署;后台和锁屏不新增语音、不调整自定义 sound 或通知渠道,保持原有通知行为。用户恢复 App 或点击通知时也不补播后台事件。
-- 平台与商家 PC 不改;商家和骑手 App 的既有新单铃声不改。
-- 默认 `uni` 通道不变;新增场景、语言及事件标识沿现有 payload 传递,已有 FCM/APNs 也保留这些信息供前台接收。不自动启用原生通道,不改凭据、通知声音或设备登录互斥。
+- 用户/商家/骑手 App 及其原生插件、平台和商家 PC 均不修改;商家和骑手的既有新单铃声不改。
+- 默认 uni 通道不变;eventScene 沿现有完整 payload 传递,FCM/APNs 保留完整 JSON。不新增语言、用户、时间、事件标识,不改凭据、通知声音或设备登录互斥。
 - 只给当前实际向用户发送的通知增加语音,不新增取消、退款、预约超时等缺失的业务通知,不修改支付/退款或配送状态机。
 - 不使用通话、PushKit、VoIP token 或音频后台保活来绕过系统通知限制。
 - 不新增消息表、音频表、设备表或用户设置页;场景与事件信息保存在现有消息 `body`。若计划阶段证明需要新增数据库字段,必须先说明原因,迁移 SQL 仅登记,不执行。
 
-## 现有代码事实(只读检查,非部署验证)
+## 实施前代码事实(非部署验证)
 
 - `PayPush` 用户业务包装分别发布 `PushEvent` 和调用 `BusinessPushService`;监听器写入 `push_message`,外部发送成功不代表消息中心入库成功,反之亦然。
 - `BusinessPushProperties` 和 `application.yml` 默认 `uni`;FCM/APNs 已有发送代码。未读取线上环境变量,不声称线上已启用原生通道。
@@ -39,7 +41,7 @@
 
 **Why this priority**: 接单、开始配送和送达是截图明确要求的三个节点。
 
-**Independent Test**: 用户 App 保持前台,分别在外卖和闪送真实成功的接单、取餐/取件、送达节点检查用户通知事件与声音。
+**Independent Test**: 后端定向测试捕获外卖、闪送、自配送通知,验证成功场景及失败/回滚零通知;实际播放由前端按接入文档另行真机验收。
 
 **Acceptance Scenarios**:
 
@@ -49,17 +51,17 @@
 4. **Given** 外卖为商家自配送,**When** 商家开始配送或确认送达,**Then** 使用同样的派送/送达短语音,不误播“骑手已接单”。
 5. **Given** 抢单、取件或送达失败,或者事务回滚,**When** 请求返回,**Then** 不生成该次成功状态通知或语音事件。
 
-### User Story 2 - 只在前台播放且不重复 (Priority: P1)
+### User Story 2 - 前端有明确的前台语音接入契约 (Priority: P1)
 
-通知在 App 前台由统一接收逻辑播放;后台/锁屏保持原有通知,不启动语音播放器,不依赖 JavaScript 在后台持续运行。
+后端仅给出 eventScene,文档指导前端用自己的语言设置选择音频、在 App 前台统一接收和播放;后台/锁屏保持原有通知。未增加事件 ID,不能承诺跨回调精确去重或仅凭本 payload 区分重复/合法再次发生的同场景动作。
 
-**Independent Test**: Android 和 iOS 前台收到同一场景只播一次;后台、锁屏、点击通知和恢复 App 均不新增或补播语音。
+**Independent Test**: 契约测试校验只有 eventScene 为新增字段;文档列出前台统一接收、后台/锁屏不新增播放、历史不补播,以及没有额外字段时的能力限制。
 
 **Acceptance Scenarios**:
 
 1. **Given** 用户 App 在前台,**When** uniPush 或原生通知到达,**Then** 统一处理场景并播放一次,不叠加普通铃声、第二条本地通知或另一个监听器的语音。
 2. **Given** App 在后台或锁屏,**When** 通知正常到达,**Then** 保持原有系统通知行为,不设置新的订单语音声音,不启动前台播放器或通话。
-3. **Given** 同一通知到达多个回调,**When** 客户端处理,**Then** 使用相同事件标识避免重复展示/播报。
+3. **Given** 同一通知到达多个回调,**When** 客户端处理,**Then** 前端选定唯一处理入口并复用 SDK 已有消息标识(若存在);本期不提供服务端事件 ID,也不保证仅靠订单号/场景可精确去重。
 4. **Given** 用户点击通知、打开消息列表或恢复历史数据,**When** 详情导航或消息加载,**Then** 不再次播报历史事件。
 5. **Given** 系统限制发声、当前账号不匹配或 App 已转后台,**When** 前台播放器处理事件,**Then** 不强制发声、不为订单语音申请后台保活。前台媒体播放与系统通知声音设置不完全相同,具体平台行为需单独验证。
 
@@ -67,7 +69,7 @@
 
 已有商家出餐、支付成功、商家自配送接单及已存在的用户取消通知也有独立短语音,不能全部套用接单音频。
 
-**Independent Test**: 按下表各事件和四语言抽检;核对语音语言、文字通知、订单归属及通知对象。
+**Independent Test**: 按下表各事件和当前后端语言抽检,核对元数据、文字、订单归属及通知对象;音频文案与资源由前端按文档实现。
 
 | 事件语义 | 简体中文语音文案 | 现有触发范围 |
 | --- | --- | --- |
@@ -81,8 +83,8 @@
 
 **Acceptance Scenarios**:
 
-1. **Given** 用户语言为简中、繁中、英文或越南语,**When** 受支持的业务通知触发,**Then** 文字和音频使用同一种语言;不播订单号、联系方式或地址。
-2. **Given** 用户语言未知或没有该语种音频,**When** 通知触发,**Then** 使用明确的默认语种策略;不得展示原始 i18n key 或引用不存在的资源。
+1. **Given** 用户有 App 语言设置,**When** 前台收到受支持场景,**Then** 前端按自身设置选择本地短音频;后端文字仍沿用当前 Locale。本期没有语言字段,不承诺两者语言绝对一致,也不改变后端语言设置。
+2. **Given** App 语言未知或音频缺失,**When** 前台处理通知,**Then** 由前端采用自身默认语种或仅保留文字;不得引用不存在的资源。
 3. **Given** 商家或骑手是通知接收人,**When** 同一业务动作发生,**Then** 其原有铃声和 payload 行为不因用户语音改造而改变。
 
 ### User Story 4 - 不影响消息中心及业务操作 (Priority: P1)
@@ -101,12 +103,12 @@
 
 - 自配送允许跳过开始配送直接确认送达:只通知实际成功的送达事件,不补造“开始配送”。
 - 平台/旧入口使用的数字状态不同于当前外卖 deliveryStatus 和闪送字符串状态:语音事件从实际动作产生,不按一个通用 state 映射。
-- 短时间连续多个事件或多笔订单:前台避免重叠播放;不同订单事件不得被一个全局布尔标记误去重;已被更新状态替代的待播事件不倒序播报。
+- 短时间连续通知或多笔订单:前端采用单一入口和串行播放器;订单号加场景不是可靠事件 ID,同场景再次发生时不能盲目永久去重。没有事件时间/版本,不能仅凭 payload 完整判别迟到、乱序或历史。
 - App 原生接收回调和 uniPush 回调可能同时到达:必须有明确的通知展示/声音责任方。
 - App 转后台时停止前台播放并清理待播队列;恢复前台不补播后台收到的消息或被清理的事件。
 - 前台音频须在实际 App 包内且播放器可访问;本期不要求打包为系统通知声音,也不创建场景/语言通知渠道。
 - 老版本或缺少音频时保持普通文字通知,不导致接收逻辑失败;发布前须真机验证,不能仅凭 payload 构造测试判定兼容。
-- 未登录/已换账号时不播另一账号的前台缓存事件;设备通知仍遵守现有单设备 token 与会话机制。
+- 未登录/已换账号时前端停止和清理队列;保留现有单设备 token 与会话机制。本期无接收用户字段,payload 自身不能判断迟到通知属于哪个账号。
 
 ## Requirements
 
@@ -115,51 +117,57 @@
 - **FR-001**: MUST 沿用现有用户业务通知,每个成功事件只构造同一条含文字和语音信息的通知,不另发语音通知。
 - **FR-002**: MUST 明确标识上表七种事件语义;只覆盖实际存在的用户通知,不新增触发时机或收件人。
 - **FR-003**: MUST 保留历史 ddId/state/type/pushType 契约;新增场景与语音语义不得占用旧 type=0,也不得通过匹配中文正文判断。
-- **FR-004**: MUST 为一次业务事件生成并复用事件唯一标识,同一通知的入库、通道发送和所有客户端回调携带一致标识;转发不得重新生成。
-- **FR-005**: MUST 为受支持场景提供简中、繁中、英文、越南语简短音频,随用户 App 打包,不在每次通知时在线合成/下载,不播敏感或冗长订单信息。
-- **FR-006**: MUST 以接收用户语言决定文字和语音;已有其他语言无音频时明确回落,避免缺失资源或非法文件名。
-- **FR-007**: MUST 仅在 App 前台由统一接收逻辑播放一次;后台/锁屏不启动语音,保持原有通知行为,转后台停止播放并清理队列,恢复时不补播。
-- **FR-008**: MUST 在默认 uniPush、已有 FCM/APNs payload 中保留场景、语言及事件标识供前台处理;当前通道选择、声音配置、失败不回退、端别凭据隔离保持不变。
+- **FR-004**: MUST 只新增 eventScene 一个字段;取消 eventId、voiceLanguage、recipientUserId、eventTime,不增加替代字段、声音 URL 或语音接口。入库和发送仍复用同一业务 body,但不生成事件 ID。
+- **FR-005**: MUST 提供 eventScene 与短语音文案映射接入文档;语种选择、音频制作与打包由前端负责,建议本地预制、不播敏感或冗长订单信息。本任务不交付音频。
+- **FR-006**: MUST 保持后端文字的当前用户 Locale 机制,不新增语音语言字段;接入文档明确前端按自身语言选资源,并注明不能保证与通知文字语种完全一致。
+- **FR-007**: MUST 在文档明确前端仅在 App 前台统一播放一次;后台/锁屏不启动语音,保持原有通知行为,转后台停止播放并清理队列,恢复时不补播。本任务通过后端元数据支持该规则,不实现客户端播放器。
+- **FR-008**: MUST 在默认 uniPush 和既有 FCM/APNs 的完整 payload 中保留 eventScene;通道选择、声音配置、失败不回退、端别凭据隔离保持不变。
 - **FR-009**: MUST 不修改或部署云函数,不传递新的自定义通知 sound、不新建通知声音渠道;前台从受控的场景/语言映射选择本地音频,不接受任意文件路径或声音 URL。若现有云端不能保留 payload,报告限制并请求方向。
 - **FR-010**: MUST 保留消息中心独立入库;本范围内有有效用户但无设备地址不能跳过记录生成。
 - **FR-011**: MUST 在实际业务状态成功且事务提交后生成成功事件,失败/回滚不发;自配送及出餐等现有非统一提交后路径需按本范围收口。
 - **FR-012**: MUST 隔离推送与播放失败,不改变支付、配送、取消等原业务结果,不把入库成功等同于外部到达或播放成功。
-- **FR-013**: MUST 对重复回调去重,并正确区分订单/事件;通知点击、消息列表、历史恢复不触发补播。
+- **FR-013**: MUST 文档明确前端采用统一接收入口、不在点击/消息列表/恢复时补播;可利用 SDK 已有消息标识,但本期不提供服务端事件 ID、不承诺跨回调精确去重。
 - **FR-014**: MUST 将普通业务推送与通话消息分流,不调用 CallKit/PushKit 或复用 voIPToken 来实现订单语音。
-- **FR-015**: MUST 保持原有系统通知权限和声音设置,不通过强制音量、通话或后台保活绕过限制;前台播放器的权限、静音和音频中断行为在 Android/iOS 分别验证,不把媒体播放与通知声音开关视为相同能力。
-- **FR-016**: MUST 保持旧 App 的文字通知、已知跳转字段及普通声音回退;对支持新功能的 App,点击按 pushType 路由外卖/闪送详情,不把闪送订单号当外卖查询。
-- **FR-017**: MUST 仅修改本功能必需代码,补充关键场景、前台生命周期、去重、事务及兼容注释,不改云函数、后台/锁屏声音、废弃支付入口、商家/骑手新单语音或 PC 页面。
+- **FR-015**: MUST 不修改服务端系统通知声音和渠道配置,文档提醒前端不得强制音量、通话或后台保活绕过限制,须分别核验 Android/iOS 权限、静音和音频中断;不能把媒体播放与通知声音开关视为相同能力。
+- **FR-016**: MUST 保持旧 App 的文字通知和已知跳转字段;文档规定新 App 点击按 pushType 区分外卖/闪送,由前端对接其实际详情页,不把闪送订单号当外卖查询。实际页面或路由不在本任务实现范围。
+- **FR-017**: MUST 仅修改必需后端代码并补充场景、事务及兼容注释,不改 App、原生插件、云函数、后台/锁屏声音、废弃支付入口、商家/骑手新单语音或 PC 页面。
 - **FR-018**: MUST 按 specify → plan → tasks → implement 记录进度,原分支开发,不创建或切换分支;实际测试、构建、音频资源及真机验证分别记录,不将模拟检查报告为完整功能验收。
 
 ### Key Entities
 
-- **业务通知事件**:关联原订单、接收用户、成功动作、事件唯一标识、语言与短语音场景;不是一份新的订单状态。
+- **通知场景**:既有用户通知的实际成功动作,由 eventScene 描述;不是订单状态,不包含新增事件唯一标识、时间、用户或语种。
 - **消息中心记录**:已有 push_message;新增场景信息放在 JSON body,列表读取不会自动播放。
-- **本地语音资源**:事件与语言确定的短音频;Android/iOS 前台播放器可访问,文件格式、时长、内容及打包结果分别验证,不作为本期系统通知声音。
-- **播放/通知去重状态**:客户端区分订单、事件与账号;不是 isRead,不改变消息已读状态。
+- **本地语音资源(前端负责)**:事件与语言确定的短音频;前端验证播放器可访问、文案、时长及打包结果,本任务仅描述接入要求。
+- **播放控制(前端负责)**:依赖前台状态、统一接收入口和客户端/SDK 已有信息;不改变 isRead,本期不额外提供去重、账号或排序元数据。
 
 ## Success Criteria
 
 ### Measurable Outcomes
 
 - **SC-001**: 上表七种实际用户通知在受测成功动作中均有正确事件分类;失败动作/事务回滚对应成功事件数量为 0。
-- **SC-002**: 每种受支持语言的通知文字与语音内容匹配;所有发布音频均已实际生成、可解码、语句正确且在最终打包资源中可访问。
-- **SC-003**: 在权限和声音允许、消息已到设备的条件下,前台闲置播放器在收到有效事件后 2 秒内开始一次播报;后台/锁屏保持原有通知且不启动新增语音,恢复 App 不补播。
-- **SC-004**: 同一事件重复进入原生/uniPush 回调、点击通知、恢复 App、打开历史消息的测试中,前台不重复播报;一次正常事件没有两条展示通知或叠加声音。
+- **SC-002**: 新增场景契约的字段集合只有 eventScene,七种允许值完整;后端原文字本地化保持不变,前端文档无不存在的音频 URL 或被取消字段依赖。
+- **SC-003**: 前端接入文档明确有效前台事件在闲置播放器收到后 2 秒内开始播报的目标,以及后台/锁屏不新增播报、恢复不补播规则;实际耗时及声音效果交由前端真机验收。
+- **SC-004**: 同一条后端通知的入库/外发 body 内容一致;文档说明单一前台入口和历史不补播要求,同时明确没有事件 ID/时间/用户/语种字段的限制,不宣称实现精准去重、乱序判断或跨账号校验。
 - **SC-005**: 缺失设备地址或外部通道异常时,范围内受测事件仍产生一条用户消息记录,业务结果不受影响,不出现双通道补发。
-- **SC-006**: 商家/骑手通知、音视频通话与四个历史 payload 字段的定向回归无行为改变;旧 App 普通通知回退和新 App 外卖/闪送点击路径完成真机核验。
+- **SC-006**: 商家/骑手服务端通知、通道配置与四个历史 payload 字段的定向回归无行为改变;App、原生插件、PC、云函数源码零改动,客户端声音与跳转真机核验单独交接。
 
 ## Assumptions / Dependencies
 
-- 本规格以仓库源码为依据;实际服务器环境变量、现有云端 payload 透传、签名 App 和最终打包音频尚未验证。云函数与后台/锁屏语音已由用户明确排除,不作为本期交付项。
-- 本轮不切换 App 的 master 分支。若该 checkout 并非用户维护的目标 App 源码,需在跨端实施前由用户指定路径/分支,不能自行切换。
-- 音频必须是可交付的实际资源,不能以空文件、占位文件或待提供说明代替完成;若本机无法制作/核听所需语种,应记录资源依赖并请求用户提供,不宣称闭环完成。
-- 音频合成如需外部付费服务或上传数据,须先说明并取得相应授权;预制资源不包含订单号、姓名、手机号、地址。
+- 2026-10-09 用户已确认书面规格,并随后明确 App 由前端人员实现,本任务只交付后端与接入文档;本地 App checkout 缺少闪送页不再阻断后端工作,不新增页面、不修改或切换 App 仓库。
+- 后端文字沿用 LocaleUtils 的 zh_CN/zh_TW/en_US/th_TH 和繁中默认,不恢复已移除的 vi。本期不传音频语种,前端用自身语言设置;客户端资源与翻译由前端负责。
+- 本规格以仓库源码为依据;实际服务器环境变量、现有云端 payload 透传、签名 App 和最终音频尚未验证。云函数及后台/锁屏语音已排除;App 音频、实现、打包和真机由前端负责。
+- 音频文件或客户端验收没有完成,不妨碍本任务完成后端代码及文档交付,但必须明确“后端已验证”不等于“App 语音已上线”。
 - 不做远端部署、数据库操作或生产推送试发;这些操作需要用户明确要求。代码与本地验证完成后仍单独列出真机/部署未验证项。
-- 新规格补充 028 的用户通知与 033 的通道路由;不将其旧版“仅服务端/App 不在范围”的边界套用到本次已批准的 App 配套,也不改变旧任务的历史验收状态。
+- 新规格补充 028 的用户通知与 033 的通道路由;不改变旧任务历史验收状态,本期 App 责任以用户最新要求为准。
 
 ## 平台能力参考
 
 - [DCloud 服务端推送 API](https://doc.dcloud.net.cn/uniCloud/uni-cloud-push/api.html):本期沿用 payload 传递,不启用自定义 sound 或离线语音提醒。
 - [Apple UNNotificationSound](https://developer.apple.com/documentation/usernotifications/unnotificationsound) 与 [Android 通知渠道](https://developer.android.com/develop/ui/compose/notifications/channels):系统通知声音是独立能力,本期维持现状,不增加资源或渠道适配。
 - [Firebase Android 消息接收](https://firebase.google.com/docs/cloud-messaging/android/receive-messages):notification + data 在后台由系统展示,不能假定 JS/前台 onMessageReceived 会立即执行。
+
+## 范围变更记录
+
+2026-10-09 用户确认 eventScene 的播放场景用途,并明确其他新增字段不要。已精简规格、计划、任务和接入文档:只保留一个新增场景字段,撤销对服务端事件 ID、语种、接收用户和时间的依赖。产品代码尚未开始。
+
+2026-10-09 用户确认继续后在原分支实施:用户专用 JSON 工厂仅增加 eventScene;骑手、自配送、出餐、LINE/OMG 支付和现有外卖取消接入七场景。无地址仍走消息入库,提交后通知与通知准备失败隔离;商家/骑手 body 保持原样。公共控制器测试集中在 UserOrderNotificationSceneTest,覆盖实际参数、回滚、失败、直接送达及旧状态兼容。未新增 SQL、云函数、App 或 PC 修改。

+ 48 - 0
specs/035-user-notification-voice/tasks.md

@@ -0,0 +1,48 @@
+# Tasks: 用户通知 eventScene 与前端交接
+
+**Input:** [spec.md](spec.md)、[plan.md](plan.md)、[frontend-integration.md](frontend-integration.md)。
+
+**进度:** 实现中(后端接入及分批测试已完成,最终验证收口中)。本期只新增 eventScene;不修改 App、云函数、音频或数据库。任务按现有用户故事追踪,步骤/签名/断言以 plan 为准;控制器公共夹具集中于 UserOrderNotificationSceneTest,其余旧控制器测试继续回归。
+
+## Phase 1: 契约(Plan Task 1)
+
+- [x] T001 [US2] 新建 ruoyi-admin/src/test/java/com/ruoyi/app/order/dto/OrderPushBodyDtoTest.java,证明只有新增场景、旧工厂不输出场景的预期失败。
+- [x] T002 [US2] 新建 ruoyi-admin/src/main/java/com/ruoyi/app/push/UserNotificationScene.java,修改 ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPushBodyDto.java 的可选 eventScene 和 getUserJson 工厂;不建立额外事件封装类。
+- [x] T003 [US2] 运行 Task 1 测试及差异/暂存检查,独立提交契约,不推送。
+
+## Phase 2: 配送与入库(Plan Task 2)
+
+- [x] T004 [US1] 扩展 ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java、PosOrderShOprateControllerTest.java、PosOrderAdminStatusControllerTest.java 和 flashdelivery/service/FlashDeliveryNotificationServiceTest.java、FlashDeliveryMultiPickupTest.java,运行成功场景、无地址、回滚/部分取件的失败测试。
+- [x] T005 [US1] 修改 ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java 与 PosOrderController.java 的有效用户通知,明确三配送动作场景;历史 state 原值不变。
+- [x] T006 [US1] 修改 ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java,区分商家接单/出餐和自配送开始/完成;直接送达不补派送。
+- [x] T007 [US1] 修改 ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java,仅寄件用户成功接单/全部取件/送达带 eventScene,骑手路径不变。
+- [x] T008 [US4] 上述有效用户路径解除地址对入库的阻断,沿现有提交后通知边界;完成 Task 2 测试及范围检查后独立提交,不推送。
+
+## Phase 3: 现有其他通知(Plan Task 3)
+
+- [x] T009 [US3] 扩展 ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOrderNotificationServiceTest.java、omgpay/OmgPaymentNotifyServiceTest.java、order/DeliveryOrderNotificationServiceTest.java,运行支付/取消场景与非用户隔离的失败测试。
+- [x] T010 [US3] 修改 ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java、omgpay/OmgPaymentNotifyService.java、order/DeliveryOrderNotificationService.java 的已有用户通知;支付/取消仅用独立用户 body,不污染商家/骑手。
+- [x] T011 [US4] 验证 type=0 不变、核销幂等、无地址仍入库,Task 3 测试通过后独立提交,不推送。
+
+## Phase 4: 验证及交接(Plan Task 4)
+
+- [x] T012 [US2] 扩展 ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java、FcmPushSenderTest.java、ApnsPushSenderTest.java 和 utils/event/PushEventListenerTest.java,确认仅场景透传、旧通知/声音不变。
+- [x] T013 [US4] 只有测试确证 uni 用户参数编码破坏场景时,才修 ruoyi-admin/src/main/java/com/ruoyi/app/utils/PayPush.java 的该路径;不调整云函数。
+- [x] T014 [US2] 收口 specs/035-user-notification-voice/frontend-integration.md:七场景、原四字段+eventScene、JSON、前台要求、客户端选语言与无新增元数据的能力限制。
+- [x] T015 [US4] 执行 plan 的 JDK 21 定向测试/模块构建及 diff 检查,记录实际结果、提交 SHA、未验证项,确认所有改动只在后端与本 feature 文档后提交,不自动 push。
+
+## 2026-10-09 实施与验证记录
+
+- 契约提交 `deb899b`:只新增 eventScene,旧工厂键集合与旧字段值兼容;失败测试后 8/8 通过。
+- 配送提交 `a67107d`:现代/旧骑手入口、自配送和商家出餐场景;70/70 通过。控制器共用事务/异步夹具集中在 UserOrderNotificationSceneTest,其他既有控制器测试继续回归。
+- 支付/取消提交 `1f3ba28`:LINE/OMG 支付及现有外卖用户取消场景;22/22 通过,重复支付回调不再通知,商家/骑手 body 不增加场景。
+- 通道与入库回归 49/49 通过;旧 payload、default sound、qsRing、pushsound.caf 保持。uni 仍转发完整 JSON,无证据需要修改 PayPush 参数编码,T013 按条件无需改代码。
+- JDK 21 最终统一定向测试 149/149 通过(包含 UserOrderNotificationSceneTest),模块 package 为 BUILD SUCCESS。无 SQL、App、云函数、PC 或通道生产配置变更。
+- 本地提交不自动 push;未部署、未生产试发,真实云端 payload 透传、App 音频/前台效果与真机尚未验证。
+- 额外全模块回归与一次只读整体审查将在收口时补记,不把定向测试当作完整 App 功能验收。
+
+## Dependencies / 前端责任
+
+T001–T003 → T004–T008 → T009–T011 → T012–T015;按批次做失败测试、实现、通过验证和提交,不按字段拆小批次。
+
+前端负责音频、语言选择、统一入口、播放队列、生命周期、点击路由及真机验收。本期无新增事件标识、接收用户或时间,不能宣称已实现精准去重、跨账号校验或完整乱序判断。真实云端是否保留 eventScene 需联调;失败时反馈,不擅自修改云函数。