Ver Fonte

新增匿名原生推送测试接口

qmj há 10 horas atrás
pai
commit
9f71a2e06b

+ 27 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -124,6 +124,33 @@ public class ApnsPushSender {
     }
 
     public void send(RecipientApp app, String token, String title, String content, String payload) {
+        String host = properties.getApnsEnvironment() == BusinessPushProperties.ApnsEnvironment.DEVELOPMENT
+                ? DEVELOPMENT_HOST : PRODUCTION_HOST;
+        sendToHost(app, token, title, content, payload, host);
+    }
+
+    /** Test delivery always uses the production gateway and does not change the business push mode. */
+    public void sendForTestProduction(RecipientApp app, String token, String title,
+                                      String content, String payload) {
+        initializeForTest(app);
+        sendToHost(app, token, title, content, payload, PRODUCTION_HOST);
+    }
+
+    private synchronized void initializeForTest(RecipientApp app) {
+        if (clients.containsKey(app)) {
+            return;
+        }
+        BusinessPushProperties.AppProperties config = properties.getApp(app);
+        if (config == null || config.getApnsCertificatePath() == null
+                || config.getApnsCertificatePath().isBlank()) {
+            throw new IllegalStateException("APNS_CONFIG_MISSING");
+        }
+        loadApnsConfig(app, config);
+        clients.put(app, createClient(app, config));
+    }
+
+    private void sendToHost(RecipientApp app, String token, String title, String content,
+                            String payload, String host) {
         HttpClient client = clients.get(app);
         if (client == null) {
             throw new IllegalStateException("APNS_NOT_INITIALIZED");
@@ -131,8 +158,6 @@ public class ApnsPushSender {
         if (token == null || !token.matches("[0-9a-fA-F]{64}")) {
             throw new IllegalStateException("APNS_TOKEN_INVALID");
         }
-        String host = properties.getApnsEnvironment() == BusinessPushProperties.ApnsEnvironment.DEVELOPMENT
-                ? DEVELOPMENT_HOST : PRODUCTION_HOST;
         String body = buildBody(app, title, content, payload);
         if (body.getBytes(StandardCharsets.UTF_8).length > 4096) {
             throw new IllegalStateException("APNS_PAYLOAD_TOO_LARGE");

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java

@@ -104,6 +104,24 @@ public class FcmPushSender {
         }
     }
 
+    /** Test delivery initializes only the selected app, without changing the business push mode. */
+    public void sendForTest(RecipientApp app, String token, String title, String content, String payload) {
+        initializeForTest(app);
+        send(app, token, title, content, payload);
+    }
+
+    private synchronized void initializeForTest(RecipientApp app) {
+        if (messaging.containsKey(app)) {
+            return;
+        }
+        BusinessPushProperties.AppProperties config = properties.getApp(app);
+        if (config == null || config.getFcmCredentialsPath() == null
+                || config.getFcmCredentialsPath().isBlank()) {
+            throw new IllegalStateException("FCM_CONFIG_MISSING");
+        }
+        messaging.put(app, createMessaging(app, config.getFcmCredentialsPath()));
+    }
+
     public List<DeliveryResult> sendBatch(RecipientApp app, List<String> tokens,
                                           String title, String content, String payload) {
         FirebaseMessaging client = messaging.get(app);

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestController.java

@@ -0,0 +1,69 @@
+package com.ruoyi.app.push;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Locale;
+
+@RestController
+@RequestMapping("/system/push")
+public class NativePushTestController {
+    private static final Logger log = LoggerFactory.getLogger(NativePushTestController.class);
+
+    private final FcmPushSender fcm;
+    private final ApnsPushSender apns;
+
+    public NativePushTestController(FcmPushSender fcm, ApnsPushSender apns) {
+        this.fcm = fcm;
+        this.apns = apns;
+    }
+
+    /** Explicit test path: does not read an account or change the ordinary business push mode. */
+    @Anonymous
+    @PostMapping("/testNative")
+    public AjaxResult send(@RequestBody NativePushTestRequest request) {
+        if (request == null || blank(request.getAppType()) || blank(request.getPlatform())
+                || blank(request.getDeviceToken()) || blank(request.getTitle())
+                || blank(request.getContent())) {
+            return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
+        }
+        RecipientApp app;
+        try {
+            app = RecipientApp.valueOf(request.getAppType().trim().toUpperCase(Locale.ROOT));
+        } catch (IllegalArgumentException exception) {
+            return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
+        }
+        String platform = request.getPlatform().trim().toLowerCase(Locale.ROOT);
+        if (!"android".equals(platform) && !"ios".equals(platform)) {
+            return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
+        }
+        try {
+            if ("android".equals(platform)) {
+                fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload());
+            } else {
+                apns.sendForTestProduction(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload());
+            }
+            return AjaxResult.success(MessageUtils.message("native.push.test.accepted"));
+        } catch (RuntimeException exception) {
+            String category = exception.getMessage();
+            if (category == null || !category.matches("(?:FCM|APNS)_[A-Za-z0-9_]{1,64}")) {
+                category = exception.getClass().getSimpleName();
+            }
+            log.warn("Native test push failed, app={}, platform={}, category={}", app, platform, category);
+            return AjaxResult.error(MessageUtils.message("native.push.test.failed") + ": " + category);
+        }
+    }
+
+    private static boolean blank(String value) {
+        return value == null || value.isBlank();
+    }
+}

+ 59 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestRequest.java

@@ -0,0 +1,59 @@
+package com.ruoyi.app.push;
+
+/** Raw device address supplied by the caller; no user account is looked up. */
+public class NativePushTestRequest {
+    private String appType;
+    private String platform;
+    private String deviceToken;
+    private String title;
+    private String content;
+    private String payload;
+
+    public String getAppType() {
+        return appType;
+    }
+
+    public void setAppType(String appType) {
+        this.appType = appType;
+    }
+
+    public String getPlatform() {
+        return platform;
+    }
+
+    public void setPlatform(String platform) {
+        this.platform = platform;
+    }
+
+    public String getDeviceToken() {
+        return deviceToken;
+    }
+
+    public void setDeviceToken(String deviceToken) {
+        this.deviceToken = deviceToken;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
+
+    public String getPayload() {
+        return payload;
+    }
+
+    public void setPayload(String payload) {
+        this.payload = payload;
+    }
+}

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -499,3 +499,6 @@ promotion.freeDelivery.quoteChanged=报价已变化,请重新获取报价后
 promotion.freeDelivery.invalidSnapshot=订单优惠快照数据不完整,无法继续处理
 promotion.freeDelivery.configConflict=门店满额免运配置冲突,请联系管理人员
 promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
+native.push.test.invalid=原生推送测试参数不完整或不正确
+native.push.test.accepted=推送服务已接受消息,请检查手机通知
+native.push.test.failed=原生推送发送失败

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -485,3 +485,6 @@ promotion.freeDelivery.quoteChanged=The quote has changed, please get a new quot
 promotion.freeDelivery.invalidSnapshot=The order promotion snapshot is incomplete and cannot be processed
 promotion.freeDelivery.configConflict=The store's free delivery configuration conflicts, please contact support
 promotion.freeDelivery.duplicateOrder=The order has already been submitted, please do not place it again
+native.push.test.invalid=Invalid or incomplete native push test parameters
+native.push.test.accepted=The push provider accepted the message; check the phone notification
+native.push.test.failed=Native push delivery failed

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -486,3 +486,6 @@ promotion.freeDelivery.quoteChanged=ราคาที่เสนอเปล
 promotion.freeDelivery.invalidSnapshot=ข้อมูลส่วนลดของคำสั่งซื้อไม่ครบถ้วน ไม่สามารถดำเนินการต่อได้
 promotion.freeDelivery.configConflict=การตั้งค่าส่งฟรีของร้านขัดแย้งกัน กรุณาติดต่อผู้ดูแล
 promotion.freeDelivery.duplicateOrder=คำสั่งซื้อถูกส่งไปแล้ว กรุณาอย่าสั่งซื้อซ้ำ
+native.push.test.invalid=ข้อมูลทดสอบการแจ้งเตือนแบบเนทีฟไม่ครบถ้วนหรือไม่ถูกต้อง
+native.push.test.accepted=ผู้ให้บริการรับข้อความแล้ว โปรดตรวจสอบการแจ้งเตือนบนโทรศัพท์
+native.push.test.failed=ส่งการแจ้งเตือนแบบเนทีฟไม่สำเร็จ

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -487,3 +487,6 @@ promotion.freeDelivery.quoteChanged=报价已变化,请重新获取报价后
 promotion.freeDelivery.invalidSnapshot=订单优惠快照数据不完整,无法继续处理
 promotion.freeDelivery.configConflict=门店满额免运配置冲突,请联系管理人员
 promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单
+native.push.test.invalid=原生推送测试参数不完整或不正确
+native.push.test.accepted=推送服务已接受消息,请检查手机通知
+native.push.test.failed=原生推送发送失败

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -486,3 +486,6 @@ promotion.freeDelivery.quoteChanged=報價已變化,請重新取得報價後
 promotion.freeDelivery.invalidSnapshot=訂單優惠快照資料不完整,無法繼續處理
 promotion.freeDelivery.configConflict=門市滿額免運設定衝突,請聯絡管理人員
 promotion.freeDelivery.duplicateOrder=訂單已提交,請勿重複下單
+native.push.test.invalid=原生推播測試參數不完整或不正確
+native.push.test.accepted=推播服務已接受訊息,請檢查手機通知
+native.push.test.failed=原生推播傳送失敗

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

@@ -94,6 +94,22 @@ class ApnsPushSenderTest {
         assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
     }
 
+    @Test
+    void testSendUsesProductionGatewayEvenWhenBusinessModeIsUni() throws Exception {
+        BusinessPushProperties properties = nativeProperties(BusinessPushProperties.ApnsEnvironment.DEVELOPMENT);
+        properties.setMode(BusinessPushProperties.Mode.UNI);
+        StubSender sender = new StubSender(properties);
+
+        sender.initialize();
+        sender.sendForTestProduction(RecipientApp.USER, "b".repeat(64), "test", "body", "{}");
+
+        assertEquals(Map.of(RecipientApp.USER, "user.p12"), sender.loadedPaths);
+        org.mockito.ArgumentCaptor<HttpRequest> captured = org.mockito.ArgumentCaptor.forClass(HttpRequest.class);
+        verify(sender.clients.get(RecipientApp.USER)).send(captured.capture(), any());
+        assertEquals("api.push.apple.com", captured.getValue().uri().getHost());
+        sender.close();
+    }
+
     @Test
     void classpathApnsConfigsSelectEachAppsOwnTopic() throws Exception {
         BusinessPushProperties properties = classpathProperties();

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

@@ -175,6 +175,23 @@ class FcmPushSenderTest {
         sender.close();
     }
 
+    @Test
+    void testSendLoadsOnlyRequestedProjectWhileBusinessModeRemainsUni() throws Exception {
+        BusinessPushProperties properties = nativeProperties();
+        properties.setMode(BusinessPushProperties.Mode.UNI);
+        StubSender sender = new StubSender(properties);
+
+        sender.initialize();
+        assertEquals(0, sender.loadCount);
+        sender.sendForTest(RecipientApp.RIDER, "rider-token", "test", "body", "{}");
+        sender.sendForTest(RecipientApp.RIDER, "rider-token", "test", "body", "{}");
+
+        assertEquals(1, sender.loadCount);
+        assertEquals("rider.json", sender.loadedPaths.get(RecipientApp.RIDER));
+        verify(sender.messaging.get(RecipientApp.RIDER), org.mockito.Mockito.times(2)).send(any(Message.class));
+        sender.close();
+    }
+
     @Test
     void nativeModeLoadsAllThreeNamedClasspathServiceAccounts() {
         BusinessPushProperties properties = nativeProperties();

+ 85 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushTestControllerTest.java

@@ -0,0 +1,85 @@
+package com.ruoyi.app.push;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class NativePushTestControllerTest {
+    private final FcmPushSender fcm = mock(FcmPushSender.class);
+    private final ApnsPushSender apns = mock(ApnsPushSender.class);
+    private final NativePushTestController controller = new NativePushTestController(fcm, apns);
+
+    @Test
+    void androidRequestSendsDirectlyToFcmWithoutAccountLookup() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("native.push.test.accepted")).thenReturn("accepted");
+            NativePushTestRequest request = request("rider", "android", "fcm-token");
+
+            AjaxResult result = controller.send(request);
+
+            assertEquals(200, result.get("code"));
+            verify(fcm).sendForTest(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
+            verify(apns, never()).sendForTestProduction(RecipientApp.RIDER, "fcm-token", "title", "content", "{}");
+        }
+    }
+
+    @Test
+    void iosRequestUsesProductionApns() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("native.push.test.accepted")).thenReturn("accepted");
+            NativePushTestRequest request = request("user", "ios", "a".repeat(64));
+
+            AjaxResult result = controller.send(request);
+
+            assertEquals(200, result.get("code"));
+            verify(apns).sendForTestProduction(RecipientApp.USER, "a".repeat(64), "title", "content", "{}");
+        }
+    }
+
+    @Test
+    void providerFailureReturnsCategoryWithoutDeviceToken() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("native.push.test.failed")).thenReturn("failed");
+            doThrow(new IllegalStateException("FCM_UNREGISTERED"))
+                    .when(fcm).sendForTest(RecipientApp.USER, "secret-token", "title", "content", "{}");
+
+            AjaxResult result = controller.send(request("user", "android", "secret-token"));
+
+            assertEquals(500, result.get("code"));
+            assertTrue(result.get("msg").toString().contains("FCM_UNREGISTERED"));
+            assertFalse(result.toString().contains("secret-token"));
+        }
+    }
+
+    @Test
+    void unknownPlatformDoesNotSend() {
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("native.push.test.invalid")).thenReturn("invalid");
+
+            AjaxResult result = controller.send(request("user", "other", "fcm-token"));
+
+            assertEquals(500, result.get("code"));
+            verify(fcm, never()).sendForTest(RecipientApp.USER, "fcm-token", "title", "content", "{}");
+        }
+    }
+
+    private static NativePushTestRequest request(String appType, String platform, String token) {
+        NativePushTestRequest request = new NativePushTestRequest();
+        request.setAppType(appType);
+        request.setPlatform(platform);
+        request.setDeviceToken(token);
+        request.setTitle("title");
+        request.setContent("content");
+        request.setPayload("{}");
+        return request;
+    }
+}

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

@@ -150,3 +150,7 @@ PowerShell 中仅对当前命令临时设置 `$env:JAVA_HOME='C:\Users\99029\.jd
 先用 `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` 下仍可测试,且业务路由不改变。

+ 19 - 0
specs/033-native-push-routing/quickstart.md

@@ -39,4 +39,23 @@ APNs 配置 JSON 使用原文件的 `packetName` 作为 topic、`pwd` 解锁同
 
 自动化测试验证三端 classpath 凭据可加载、路由、请求构造和错误隔离;真实设备 token、FCM/APNs 外部投递、通知展示与回退操作仍需部署环境验证。
 
+## ApiPost 原生推送测试(业务模式无需切换)
+
+常驻接口 `POST /system/push/testNative` 允许匿名访问,`Content-Type: application/json`,无需账号 ID 或后台 token。即使 `BUSINESS_PUSH_MODE` 保持默认 `uni`,该接口仍直接调用原生通道;Android 使用所选端的 FCM 项目,iOS 固定走 APNs 正式网关。请求示例:
+
+```json
+{
+  "appType": "rider",
+  "platform": "android",
+  "deviceToken": "手机取得的原生 FCM 或 APNs Token",
+  "title": "原生推送测试",
+  "content": "请检查手机通知栏",
+  "payload": "{}"
+}
+```
+
+`appType` 可填 `user`、`merchant`、`rider`;`platform` 可填 `android`、`ios`。测试 iOS 时改为 `ios` 并传正式包取得的 64 位十六进制 APNs token;开发包的 sandbox token 不能用于这个正式网关。`payload` 可省略。响应 `code=200` 只表示 FCM/APNs 接受发送请求,**不证明手机通知栏已显示**;失败时 `code=500` 和 `msg` 返回不含 token 的错误类别。需在真机核对通知权限、前台展示与通知栏,不调用业务订单流程,也不写入 `push_message`。
+
+该接口按需求长期匿名开放,公网部署后任何知道地址的人都可以使用服务端凭据向其掌握的设备 Token 发送消息,可能造成滥用和推送配额消耗;如不接受此风险,应在网关限制可访问来源或后续改为鉴权接口。
+
 外卖和闪送新单通知附近最多 20 名骑手时,`native` 模式会按骑手当前设备类型分流:Android 骑手按相同语言文案和订单 payload 使用骑手端 FCM multicast,逐 token 处理返回结果;iOS 骑手继续逐设备走 APNs。每位骑手的消息中心事件保持独立,批量失败不回退 uniPush。默认 `uni` 模式及其逐骑手新单通知完全不变,旧的 uni 批量方法未接入此流程。

+ 2 - 0
specs/033-native-push-routing/spec.md

@@ -108,6 +108,7 @@
 - **FR-011**: 实现范围 MUST 排除音视频通话推送、已废弃订单/支付入口及 App 端代码;不新增推送业务场景或改变登录互斥规则。
 - **FR-012**: 本功能 MUST 沿用现有 `info_user` 单设备字段,不新增数据库字段或设备表。
 - **FR-013**: 外卖和闪送新单向多名骑手开放时,仅在 `native` 模式对 Android 骑手按相同标题、正文及 payload 分组,使用骑手端 FCM multicast 发送;iOS 骑手保留逐设备 APNs 请求,`uni` 模式保留原有逐骑手发送方式,不接入旧批量方法。各骑手的语言、消息中心记录和发送失败处理 MUST 保持独立,原生失败不得回退 uniPush。
+- **FR-014**: 服务端 MUST 提供常驻的匿名 HTTP 原生推送测试接口,调用者直接提供接收端 `appType`、设备 `platform`、`deviceToken`、标题和正文;接口不读取账号、不验证 token 归属、不切换全局业务推送模式。Android 直发对应端 FCM,iOS 固定走 APNs 正式网关。只按需初始化所选端凭据,成功响应只表示 FCM/APNs 接受请求,失败响应提供不含 token/密钥的错误类别。该接口不发布业务事件、不写入消息中心。公网部署时任何调用者均可使用该接口发送测试消息,属于明确接受的匿名调用风险。
 
 ### Key Entities *(include if feature involves data)*
 
@@ -125,6 +126,7 @@
 - **SC-003**: 受测通知在正常发送、token 缺失和发送异常场景下,单次均不出现双通道外发;现有会入库的路径每次最多生成一条消息记录。
 - **SC-004**: 模拟 FCM/APNs 不可用或 token 无效时,原业务操作结果与不发送推送时一致;失败可通过日志定位到接收端、平台与错误类别,日志不包含完整 token 或凭据。
 - **SC-005**: 测试配置切回 `uni` 并重启后,后续受测通知恢复原 uniPush 发送路径;音视频通话路径保持原行为。
+- **SC-006**: 保持业务模式为 `uni` 时,匿名测试接口仍能按所传端类型与平台分别向 FCM/APNs 正式网关发送,不需要修改配置或重启;错误响应不泄露设备 token,业务发送路线保持原样。
 
 ## Assumptions
 

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

@@ -40,3 +40,9 @@ T001–T002 → T003/T004 → T005–T006 → T007 → T008–T009。FCM 与 APN
 - [x] T013 先写失败测试:骑手端 FCM multicast 保留业务 payload 与逐 token 结果;原生外卖、闪送新单进入批量分支,uni 仍逐骑手发送。
 - [x] T014 Android 骑手按已本地化文案和 payload 分组批量发送,逐人发布消息中心事件;iOS 保留原有逐设备异步发送,失败不回退 uni。
 - [x] T015 更新操作文档,运行 JDK 21 定向与回归测试、模块构建,核对变更及暂存范围。
+
+## 2026-10-23 追加:无需切换业务模式的原生测试
+
+- [x] T016 先写失败测试:默认 `uni` 时只按需初始化目标端 FCM/APNs,iOS 测试固定发送到 APNs 正式网关。
+- [x] T017 新增匿名 `POST /system/push/testNative`,直接使用请求提供的设备 Token,不查询账号;返回服务商接受或脱敏失败类别,保留普通业务推送模式。
+- [x] T018 更新 ApiPost 示例与风险说明,完成差异检查、定向测试和模块构建;真实手机通知由部署后人工验证。