Эх сурвалжийг харах

修复:解决 OMG Bridge SDK 访问鉴权问题

qmj 2 долоо хоног өмнө
parent
commit
e729c1addc

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnController.java

@@ -5,9 +5,12 @@ import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -19,6 +22,8 @@ import java.nio.charset.StandardCharsets;
 @RequestMapping("/pay/omg")
 public class OmgPaymentReturnController {
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentReturnController.class);
+    private static final Resource UNI_APP_BRIDGE =
+            new ClassPathResource("static/omg/uni.webview.1.5.8.js");
     private final OmgPaymentClientReturnService service;
     private final OmgIpnAuditService ipnAuditService;
 
@@ -37,6 +42,16 @@ public class OmgPaymentReturnController {
         return html(service.paymentResult(request));
     }
 
+    @Anonymous
+    @GetMapping(value = "/bridge.js", produces = "application/javascript;charset=UTF-8")
+    public ResponseEntity<Resource> bridge() {
+        return ResponseEntity.ok()
+                .contentType(new MediaType("application", "javascript", StandardCharsets.UTF_8))
+                .header(HttpHeaders.CACHE_CONTROL, "no-store")
+                .header("X-Content-Type-Options", "nosniff")
+                .body(UNI_APP_BRIDGE);
+    }
+
     private static String sourceIp(OmgNotifyRequest request) {
         return request == null ? "unknown" : request.getSourceIp();
     }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java

@@ -9,7 +9,7 @@ import java.nio.charset.StandardCharsets;
 /** Renders a fixed, escaped page that opens the configured App route and keeps a manual fallback button. */
 @Component
 public class OmgPaymentReturnPageRenderer {
-    private static final String UNI_WEB_VIEW_BRIDGE = "/static/omg/uni.webview.1.5.8.js";
+    private static final String UNI_WEB_VIEW_BRIDGE = "/pay/omg/bridge.js";
     private final OmgPaymentProperties properties;
 
     public OmgPaymentReturnPageRenderer(OmgPaymentProperties properties) {

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnServiceTest.java

@@ -102,7 +102,7 @@ class OmgPaymentClientReturnServiceTest {
         assertEquals(200, page.statusCode());
         assertAll(
                 () -> assertTrue(page.html().contains(
-                        "<script src=\"/static/omg/uni.webview.1.5.8.js\"></script>")),
+                        "<script src=\"/pay/omg/bridge.js\"></script>")),
                 () -> assertTrue(page.html().contains(
                         "data-app-route=\"/pages/OrderList/paySuccess/paySuccess?ddId=DD-1%26next%3Devil\"")),
                 () -> assertTrue(page.html().contains("UniAppJSBridgeReady")),

+ 23 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentReturnControllerTest.java

@@ -4,10 +4,13 @@ import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
 import org.junit.jupiter.api.Test;
+import org.springframework.core.io.Resource;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 
 import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -47,4 +50,24 @@ class OmgPaymentReturnControllerTest {
     void packagesTheOfficialUniAppWebViewBridgeLocally() {
         assertNotNull(getClass().getResource("/static/omg/uni.webview.1.5.8.js"));
     }
+
+    @Test
+    void exposesThePackagedUniAppBridgeThroughAnAnonymousJavascriptEndpoint() throws Exception {
+        Method bridge = OmgPaymentReturnController.class.getDeclaredMethod("bridge");
+
+        assertArrayEquals(new String[]{"/bridge.js"}, bridge.getAnnotation(GetMapping.class).value());
+        assertNotNull(bridge.getAnnotation(Anonymous.class));
+
+        OmgPaymentClientReturnService service = mock(OmgPaymentClientReturnService.class);
+        OmgIpnAuditService audit = mock(OmgIpnAuditService.class);
+        ResponseEntity<?> response = (ResponseEntity<?>) bridge.invoke(
+                new OmgPaymentReturnController(service, audit));
+
+        assertEquals(200, response.getStatusCode().value());
+        assertEquals("application/javascript;charset=UTF-8", response.getHeaders().getContentType().toString());
+        assertEquals("no-store", response.getHeaders().getFirst("Cache-Control"));
+        Resource body = assertInstanceOf(Resource.class, response.getBody());
+        String script = new String(body.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+        assertTrue(script.startsWith("/*! DCloud uni-app web-view SDK 1.5.8"));
+    }
 }

+ 1 - 1
specs/016-omg-payment/contracts/api.md

@@ -56,7 +56,7 @@
 ### B3. 支付完成返回页 — `POST /pay/omg/result`(US2,@Anonymous)
 
 - 作为 `OrderResultURL` 接收 OMG Client POST,使用完整 DTO 严格解析并按支付尝试凭证快照验签;未知额外字段和空值全部进入 CheckMacValue。
-- 验签通过后只返回 no-store HTML:加载后端自托管 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后优先调用 `uni.webView.redirectTo({ url: '/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}' })`;Bridge 不可用、非 App 环境或未完成页面交接时,回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮保留同一 Scheme。该入口不修改订单状态,App 必须再调 `/pay/omg/query`。
+- 验签通过后只返回 no-store HTML:从匿名只读的 `GET /pay/omg/bridge.js` 加载后端自托管 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后优先调用 `uni.webView.redirectTo({ url: '/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}' })`;Bridge 不可用、非 App 环境或未完成页面交接时,回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮保留同一 Scheme。该入口不修改订单状态,App 必须再调 `/pay/omg/query`。
 
 ### B5. 订单查询/被动补单 — `POST /pay/omg/query`(US6 方案A,@Anonymous @Auth,Header token)
 

+ 2 - 1
specs/016-omg-payment/frontend-integration.md

@@ -269,7 +269,7 @@ export default {
 
 ### 4.3 支付结果页 `pages/pay/omgResult`
 
-后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。桥接页已由后端自托管 `uni.webview.1.5.8.js`:在 App 的 `<web-view>` 内优先等待 `UniAppJSBridgeReady` 并调用 `uni.webView.redirectTo`,Bridge 不可用或未完成页面交接时再使用固定 App Scheme。Android、iOS 共用这套返回页逻辑,外部浏览器仍可通过 Scheme 返回。
+后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。桥接页通过匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`:在 App 的 `<web-view>` 内优先等待 `UniAppJSBridgeReady` 并调用 `uni.webView.redirectTo`,Bridge 不可用或未完成页面交接时再使用固定 App Scheme。Android、iOS 共用这套返回页逻辑,外部浏览器仍可通过 Scheme 返回。
 
 App 前端不需要加载 Bridge SDK,也不需要在支付页增加按钮;只需确保目标页面已在 `pages.json` 注册且不是 `tabBar` 页面:
 
@@ -497,6 +497,7 @@ export default {
 | 没有 `[OMG-RETURN] page_loaded` | OMG 没有进入 `/pay/omg/result`,或 Safari 检查器选错 WebView |
 | 有 `bridge_environment` 且 `plus=true`,随后出现 `bridge_redirect_start` 并进入结果页 | App 内 Bridge 回跳成功;不会出现 `newintent`,属于正常行为 |
 | 出现 `bridge_environment` 但 `plus/nvue/uvue` 均为 `false` | 当前页面不在 uni-app App Bridge 环境,后端会进入 Scheme 兜底 |
+| 只有 `bridge_ready_timeout`,没有任何 `bridge_ready/bridge_environment` | 先直接访问 `/pay/omg/bridge.js`,必须返回 HTTP 200、`application/javascript` 和 SDK 内容;若返回 JSON/401,Bridge 不会执行 |
 | 出现 `scheme_fallback_start`,外部浏览器可打开同一 Scheme,但 App 内页面仍停留 | 当前子 WebView 未把同 App Scheme 交给 iOS;继续检查 Bridge SDK 是否返回 200 及 `bridge_environment` 日志 |
 | 中转页出现 `visibilitychange/pagehide`,但 App 没有任何新日志 | iOS 没有找到可处理该 Scheme 的安装包,重点检查 iOS `urltypes` 与重新打包结果 |
 | App 出现 `newintent/onShow_runtime` 且 `argument.present=true`,但路由没变化 | App 已收到 Scheme,路由解析或 `navigateTo/redirectTo` 逻辑有问题 |

+ 6 - 1
specs/016-omg-payment/plan.md

@@ -140,9 +140,14 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 - 真机证据确认同一 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId=...` 可从 iOS 外部浏览器打开 App,但在 App 内 OMG 子 WKWebView 中自动与手动 Scheme 均未发生页面交接;因此 Scheme 注册和固定路由有效,失败边界位于同 App 子 WKWebView 的 Scheme 交接。
 - `/pay/omg/result` 返回页自托管官方 `uni.webview.1.5.8.js`,等待 `UniAppJSBridgeReady`,通过 `uni.webView.getEnv` 确认 App 环境后优先 `redirectTo` 到现有支付结果页;不新增 App 页面,不改变 `/pay/omg/query` 最终状态判定。
 - Bridge 未就绪、非 App 环境、环境检测异常或跳转后仍未交接时使用现有固定 Scheme;Android 与外部浏览器继续保留已验证的 Scheme 兜底,手动按钮仍可重试。
-- CSP 只新增同源脚本许可,SDK 固定打包在 `/static/omg/uni.webview.1.5.8.js`,不依赖第三方运行时 CDN;订单号继续 URL 编码和 HTML 转义,跳转目标不接受请求参数覆盖。
+- CSP 只新增同源脚本许可,SDK 固定打包在 classpath 并由匿名只读地址 `/pay/omg/bridge.js` 输出,不依赖第三方运行时 CDN;订单号继续 URL 编码和 HTML 转义,跳转目标不接受请求参数覆盖。
 - `[OMG-RETURN]` 日志增加 Bridge 环境、Bridge 跳转与 Scheme 兜底事件,并将载荷 JSON 序列化,避免 HBuilderX 输出 `[object Object]`;仍不记录凭证、签名、token 或原始 Client POST。
 
+## 2026-08-14 Bridge SDK 匿名访问修复增量计划
+
+- 真机日志只有 `page_loaded` 与 `bridge_ready_timeout`,没有 `bridge_ready`、`bridge_environment` 或 JavaScript 异常;线上访问原 `/static/omg/uni.webview.1.5.8.js` 实际返回 `401` JSON,SDK 因 `nosniff` 未执行。
+- 不放开整个 `/static/**`,改由 `OmgPaymentReturnController` 的 `GET /pay/omg/bridge.js` 以 `@Anonymous`、`application/javascript;charset=UTF-8` 和 `no-store` 返回已打包 SDK;返回页只替换脚本地址,其余支付状态、路由和 Scheme 兜底逻辑不变。
+
 ## Complexity Tracking
 
 > Constitution Check 无违规,无需填表。

+ 1 - 1
specs/016-omg-payment/spec.md

@@ -163,7 +163,7 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 
 - **FR-017**:系统 MUST 自动补偿仍为 `CREATED` 的当前有效支付尝试。调度器按 `next_query_time` 取有限批次,并在真实查询前用条件更新预留该行;查询确认已付款或明确失败时必须复用主动查询的验签与同一状态机,未付款或暂时异常不得关闭支付尝试。
 
-- **FR-018**:系统 MUST 将新订单的 `OrderResultURL` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML。中转页 MUST 自托管 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后确认处于 uni-app App 环境并优先调用 `uni.webView.redirectTo` 打开 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`;Bridge 不可用、环境不是 App 或调用后未发生页面交接时,MUST 回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮也 MUST 保留同一 Scheme 兜底。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 页面打开后必须调用 `/pay/omg/query` 确认最终状态。
+- **FR-018**:系统 MUST 将新订单的 `OrderResultURL` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML。中转页 MUST 从匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后确认处于 uni-app App 环境并优先调用 `uni.webView.redirectTo` 打开 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`;Bridge 不可用、环境不是 App 或调用后未发生页面交接时,MUST 回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮也 MUST 保留同一 Scheme 兜底。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 页面打开后必须调用 `/pay/omg/query` 确认最终状态。
 - **FR-019**:支付结果中转页 MUST 为 Bridge 就绪/环境/跳转、Scheme 兜底、自动唤起、手动点击、页面可见性/焦点变化和 JavaScript 异常输出统一前缀的浏览器诊断日志,并在自动尝试后输出分阶段观察结果;日志载荷 MUST 序列化为 JSON,避免 HBuilderX 只显示 `[object Object]`。日志只能包含浏览器环境、页面生命周期、固定 App 路由、目标 Scheme 的协议/主机/路径及脱敏订单号,不得包含 token、CheckMacValue、HashKey、HashIV 或 OMG 原始支付表单。App 端必须同步记录生命周期、启动参数、`newintent`、当前路由和支付 WebView 事件;Bridge 内部路由成功不要求出现 `newintent`。本期日志仅输出到本地调试控制台,不新增远程上报接口。
 
 ### Key Entities *(include if feature involves data)*

+ 8 - 0
specs/016-omg-payment/tasks.md

@@ -257,6 +257,14 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 - [x] T100 使用 JDK 21 运行返回页定向测试、OMG 回归测试和 `ruoyi-admin` 模块构建,并校验内联脚本语法及本地 SDK 与官方 1.5.8 源码一致。
 - [ ] T101 部署后分别进行 iOS/Android 真机 OMG 信用卡支付回跳验收,确认结果页进入后继续调用 `/pay/omg/query`。
 
+---
+
+## Phase 18: Bridge SDK 匿名访问修复(Priority: P0)
+
+- [x] T102 根据真机日志和线上 HTTP 响应确认原 `/static/omg/uni.webview.1.5.8.js` 返回 401 JSON,导致 SDK 未执行及 `bridge_ready_timeout`。
+- [x] T103 先增加失败测试,再由 `OmgPaymentReturnController` 提供匿名只读 `GET /pay/omg/bridge.js`,返回正确 JavaScript Content-Type、no-store 与已打包 SDK。
+- [x] T104 返回页切换到专用 Bridge 地址并同步 spec、plan、contracts、前端接入文档;JDK 21 定向测试通过。
+
 **Checkpoint**:App 内 OMG 返回页优先走 uni-app 内部路由,Android和外部浏览器仍保留固定 Scheme;支付状态继续只由 `/pay/omg/query` 确认。
 
 ---