diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index ad5cba5..4f62f3b 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -233,8 +233,8 @@ Contract Design Plan(本文档) - [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) - [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` - `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]` - - 公开 11 个只读属性:`qiniuDomain` / `gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig` - - 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串 + - 公开 10 个只读属性:`gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig`(`qiniuDomain` 已于 2026-06-27 移除,七牛 CDN 改 RemoteConfig.audio_domain 远端注入,详见 ADR-009) + - 单测 fixture:建立 mock bundle 含 fixture plist,验证 10 个 key 全部能读出且缺失 key 返回空串 - [x] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift` - 常量:`caches` / `documents` / `bundle` - `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html` @@ -1018,7 +1018,8 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - **修订决策**:废止"仓库根 `Resources/`"的固定结构假设,**项目内资源目录由各 Phase 实施时按需落地**: - 静态 Bundle 资源 → `ylgamehall/Resources/`(synchronized group 自动收集,含 `gamehall.zip` / `ChannelConfig.plist` 等) - 原生 Asset Catalog → `ylgamehall/Assets.xcassets/` - - 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`(11 个 string key,母包默认值;ADR-007) + - 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`(10 个 string key,母包默认值;ADR-007、ADR-009) + - 应用级凭证 → `ylgamehall/Resources/AppSecrets.plist`(3 个 string key:wxAppSecret / qiniuAccessKey / qiniuSecretKey;ADR-009) - 闭源 SDK 二进制 → `Vendor//.xcframework` - 私人原始素材池 → `docs/res/`(项目不感知,需要时拷贝到上述工程内目录) - **理由**: @@ -1079,12 +1080,12 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin 1. 默认开启的 User Script Sandboxing 拒绝读 `Scripts/copy_channel_injection.sh` 与 `ChannelInjection/` 2. 即使关掉 sandbox,Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时,被 Xcode 视为"无依赖故无需运行",clean build 也不跑 - 综合工程成本估算:为保留"目录名编码"机制,需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表,**全是 Xcode 行为兼容性维护,与项目目标无关** -- **决策**:渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 11 个 string key,与 msext 11 个目录一一对应 +- **决策**:渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 10 个 string key(2026-06-22 初版 11 个,2026-06-27 移除 `qiniudomain`,详见 ADR-009) - 存储介质:plist(iOS 原生) - Bundle 加载:synchronized group 自动收集,零配置 - 运行时读取:`BundleConfig.init(bundle:)` 用 `PropertyListSerialization` 反序列化 - **理由**: - - **契约 100% 等价**:H5 端通过 `app_data.js` 看到的 11 个 JS 全局变量行为完全不变(契约边界在 `BundleConfig.shared.xxx`,与底层存储无关) + - **契约 100% 等价**:H5 端通过 `app_data.js` 看到的 JS 全局变量行为完全不变(契约边界在 `BundleConfig.shared.xxx`,与底层存储无关) - **CLAUDE.md 原则 B 落地**:原生内部自由重构,不要照搬旧项目;msext 那套是 iOS 9 / Xcode 14 时代的 hack,Xcode 26 + Swift 6 应当用更现代的存储 - **IPA 后处理多渠道分发完全等价**: ```bash @@ -1099,9 +1100,11 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - Contract §0.3 描述的 msext 实现仅作为历史参考,新项目不照搬 - Design §7.0 / §7.2 重写为 plist 方案 - Plan Phase 1.1 简化为"建 plist"单步任务(取代原 1.1.a~d 四步 inject_channel.sh 方案) - - `BundleConfig.swift` 接口(11 个公开属性)保持不变,仅 init 实现切换 + - `BundleConfig.swift` 接口(公开属性,当前 10 个)保持稳定,仅 init 实现切换 - **守护**: - - **`ChannelConfig.plist` 必须保持 11 个 key 完整且类型为 string**;新增 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露) + - **`ChannelConfig.plist` 必须保持 10 个 key 完整且类型为 string**(gameid/channel/gamedir/gamestart/gameconfig/market/agent/appversion/other/appleconfig);新增 / 移除 key 视同契约边界变更,需更新 Design / Plan / Contract(如该值被 H5 通过 app_data.js 暴露) + - 跨渠道相同的应用凭证(微信 AppSecret、七牛 AccessKey/SecretKey)放 `AppSecrets.plist`,不进 ChannelConfig(ADR-009) + - 七牛 CDN 域名、bucket 名走 RemoteConfig 顶层 `audio_domain` / `audio_bucket` 远端注入,不在任何本地 plist(ADR-009) - 后处理工具必须**修改 plist 后立即重签**,否则 iOS 拒绝安装 - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 @@ -1243,5 +1246,34 @@ ResolvedVersion( --- +### ADR-009:凭证 / 七牛运行参数三分制(2026-06-27) + +- **背景**:本次修订前,微信 AppID/Secret + 七牛 AccessKey/SecretKey/bucket/CDN 域名分散在 3 处: + - Swift 源码硬编码:`WeChatSDK.swift`(AppID) / `WeChatAuth.swift`(AppSecret) / `QiniuConfig.swift`(AccessKey/SecretKey/bucket/CDN) + - `Info.plist` 的 CFBundleURLTypes:微信 AppID 又出现一次(iOS 系统级 URL Scheme,必须在 Info.plist) + - `ChannelConfig.plist` 的 `qiniudomain`:CDN 域名又一份,但 Swift 代码完全没读取(双源僵尸字段) +- **问题**:无单一真相源 → 多渠道分发 / 后台改后台时容易遗漏某处;CDN 域名出现"plist 改了但代码用硬编码"的潜在 bug +- **决策(三分制)**: + | 数据维度 | 唯一权威源 | 理由 | + |---|---|---| + | 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 | + | 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 | + | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 `audio_domain` / `audio_bucket` | 后台运维管理,无客户端直接出 ad-hoc 改值的诉求 | +- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)即调 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失 audio_domain / audio_bucket 抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停(理由:缺这俩 → 录音上传必失败,业务不可用,启动期即报远比运行期某次上传时报更友好) +- **类型设计**: + - `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`,3 个不可变 String 属性 + - `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async + - `QiniuTokenSigner.uploadToken()` 改 async(因 bucketName 来自 actor) + - `QiniuUploader.upload()` 预取 cdnDomain 一次(避免 SDK 同步 callback 内再 await) +- **删除清单**: + - `WeChatSDK.swift` `static let appID = "..."` 硬编码 → 改读 Info.plist + - `WeChatAuth.swift` `static let appSecret = "..."` → 改读 `AppSecrets.shared.wxAppSecret` + - `QiniuConfig.swift` `static let accessKey/secretKey/bucketName/cdnDomain = "..."` 四处硬编码 + - `ChannelConfig.plist` `qiniudomain` 字段 + - `BundleConfig.swift` `qiniuDomain` 属性(10 key 守护规则同步) +- **回滚条件**:若未来 `Info.plist` 不再允许动态读取 CFBundleURLTypes(极不可能),或 RemoteConfig 接口被替换为按渠道差异化下发(需要进 4 层 fallback),重新评估 + +--- + 文档完成日期:2026-06-21 -最后更新:2026-06-27(ADR-008 第三轮修订:VersionResolver 改单链 4 层 fallback + 5 字段共用同一查找接口;前置历史 2026-06-22 ADR-008 二次精确化 / ADR-007 渠道注入改 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级 / Resources 目录记录) +最后更新:2026-06-27(ADR-009 凭证集中化 + AppSecrets.plist + 七牛运行参数远端注入 + ChannelConfig 11→10 key;同日 ADR-008 第三轮修订:VersionResolver 单链 4 层 fallback;前置历史 2026-06-22 ADR-008 二次精确化 / ADR-007 渠道注入改 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级 / Resources 目录记录) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index f062360..1445915 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -508,10 +508,10 @@ public enum OverlayBridge { | getter | 返回类型 | 业务语义 | 数据源 | |---|---|---|---| -| `getchannelName()` | string | 渠道 ID(11 项渠道注入之一)| `BundleConfig.shared.channel` | -| `getmarketname()` | string | 市场 ID(11 项渠道注入之一)| `BundleConfig.shared.market` | +| `getchannelName()` | string | 渠道 ID(10 项渠道注入之一)| `BundleConfig.shared.channel` | +| `getmarketname()` | string | 市场 ID(10 项渠道注入之一)| `BundleConfig.shared.market` | | `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` | -| `getothername(name)` | string | 按 H5 传入 key 动态读 11 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` | +| `getothername(name)` | string | 按 H5 传入 key 动态读 10 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` | | `getcompareCode()` | int | 业务校验码(msext `RootVC.m` 沿用 zip 版本号或固定值) | 待原 msext 取值确认(Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值,并对齐) | | `getbattery()` | double | 当前电池电量 0.0–1.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) | | `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 path(loadFileURL 前 snapshot) | @@ -521,7 +521,7 @@ public enum OverlayBridge { **为什么不走 BridgeBus 异步 callback**:H5 端代码形式是 `var ch = window.settings.getchannelName()`、`var b = window.settings.getbattery()` 等**同步表达式**(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**,本地查询零延迟。 **数据快照时机**: -- 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可 +- 静态字段(渠道 / market / other / appVersion 等 10 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可 - 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话) - 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新 @@ -552,7 +552,7 @@ public enum SettingsBridgePolyfill { window.__nativeSnapshot = \(json); window.settings = window.settings || {}; - // ── 11 项渠道注入(静态,启动期一次性快照)──────────── + // ── 10 项渠道注入(静态,启动期一次性快照)──────────── window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; }; window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; }; window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; }; @@ -583,7 +583,7 @@ public enum SettingsBridgePolyfill { } public struct Snapshot: Sendable { - public let channelConfig: [String: String] // 11 项渠道注入完整字典 + public let channelConfig: [String: String] // 10 项渠道注入完整字典 public let channel: String public let market: String public let other: String @@ -1816,13 +1816,14 @@ Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownlo #### 7.0.3 渠道注入:ChannelConfig.plist 母包模式 -**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 11 个 JS 全局变量)完全不变,实现内部更简洁。 +**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 JS 全局变量)完全不变,实现内部更简洁。 -**存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值: +> **2026-06-27 修订(ADR-009)**:原 11 个 key 之一的 `qiniudomain` 已移除 —— 七牛 CDN 域名改由 RemoteConfig 顶层 `audio_domain` 远端动态下发,跨渠道无差异化诉求。**当前 ChannelConfig.plist = 10 个 string key**。 + +**存储**:`ylgamehall/Resources/ChannelConfig.plist`,10 个 string key 一一对应渠道值: ```xml - qiniudomain iosaudio.daoqi88.cn gameid G2hw0u... channel FtJf07... gamedir FtJf07... @@ -1836,6 +1837,18 @@ Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownlo ``` +**伴生配置**:`ylgamehall/Resources/AppSecrets.plist`(ADR-009 引入,跨渠道相同的应用级凭证 3 项): + +```xml + + wxAppSecret b27927... + qiniuAccessKey dQbQLU... + qiniuSecretKey RCZpwL... + +``` + +> 微信 AppID 不在 AppSecrets.plist —— 它的唯一权威源是 `Info.plist` 的 CFBundleURLTypes(URLName=weixin 首个 scheme),iOS 系统级 URL Scheme 必须在 Info.plist 静态声明,运行期无法注入。 + **运行时读取路径**:`Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist")`,由 `BundleConfig.swift`(§7.2)用 `PropertyListSerialization` 反序列化。 **多渠道分发**(IPA 后处理,不重新 Xcode build): @@ -1892,12 +1905,11 @@ public enum SandboxPaths { ```swift // ResourceKit/BundleConfig.swift // -// 从 Bundle 内的 ChannelConfig.plist 读取 11 个渠道注入值。 -// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007。 +// 从 Bundle 内的 ChannelConfig.plist 读取 10 个渠道注入值。 +// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007 / ADR-009。 public final class BundleConfig: @unchecked Sendable { public static let shared = BundleConfig() - public let qiniuDomain: String public let gameId: String public let channel: String public let gameDir: String @@ -1911,7 +1923,6 @@ public final class BundleConfig: @unchecked Sendable { public init(bundle: Bundle = .main) { let dict = Self.loadPlist(bundle: bundle) - qiniuDomain = dict["qiniudomain"] ?? "" gameId = dict["gameid"] ?? "" channel = dict["channel"] ?? "" gameDir = dict["gamedir"] ?? "" @@ -1935,7 +1946,7 @@ public final class BundleConfig: @unchecked Sendable { } ``` -> 七牛 CDN 域名(用于录音上传后的公网 URL 组装)在新项目中统一从 `BundleConfig.shared.qiniuDomain` 读取,不暴露任何全局变量。所有 AudioKit / RecordUploader 等模块通过依赖注入获取 BundleConfig。 +> 七牛 CDN 域名(录音上传后的公网 URL 组装)从远端 RemoteConfig 顶层 `audio_domain` 注入(ADR-009),启动期由 `WebContainerViewController` 调 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 写入 `QiniuConfig` actor。所有 AudioKit / RecordUploader 等模块从 `QiniuConfig.shared` 异步读取。 > > **测试性**:`init(bundle:)` 接受任意 Bundle,单测可注入 mock bundle 验证不同 plist fixture。 @@ -1989,9 +2000,9 @@ inject "gameid" "$3" inject "market" "$4" inject "agent" "$5" inject "appversion" "$6" -inject "qiniudomain" "$7" -inject "gamestart" "$8" -inject "gameconfig" "$9" +inject "gamestart" "$7" +inject "gameconfig" "$8" +# qiniudomain 已于 2026-06-27 移除(ADR-009),七牛 CDN 域名走 RemoteConfig.audio_domain echo "Channel injected: channel=$1 gamedir=$2 market=$4" ``` diff --git a/docs/SDK-Integration-Guide.md b/docs/SDK-Integration-Guide.md index 5703ee4..6bfa1ba 100644 --- a/docs/SDK-Integration-Guide.md +++ b/docs/SDK-Integration-Guide.md @@ -199,15 +199,20 @@ bridge.register("prepareaudio") { _, callback in |---|---|---| | **Bundle ID** | `com.skyapp.ylgamehall` | `daoqi/msext.xcodeproj` Release | | **App 显示名** | `进贤聚友棋牌` | `SGDefineInfo.h:103 gamehallname` | -| 微信 AppID | `wx586a9b321e56efb7` | `SGDefineInfo.h:105 kAuthOpenID` | -| 微信 AppSecret | `b2792724b9565be23e8f5ba548f117cf` | `SGDefineInfo.h:107 Appsecret` | +| 微信 AppID | `wx586a9b321e56efb7` | **新外壳**:`Info.plist` CFBundleURLTypes (URLName=weixin) 唯一权威源 / msext:`SGDefineInfo.h:105 kAuthOpenID` | +| 微信 AppSecret | `b2792724b9565be23e8f5ba548f117cf` | **新外壳**:`AppSecrets.plist` 的 `wxAppSecret` / msext:`SGDefineInfo.h:107 Appsecret` | | 微信 AuthScope | `snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact` | `SGDefineInfo.h:104` | | 微信 AuthState | `wechat_sdk` | `SGDefineInfo.h:106 kAuthState` | | 高德定位 APIKey | `b0d4a8e3fcbbcc0dd96283b7df6a4494` | `daoqi/msext/Class/Common/APIKey.h:14` | -| 七牛 AccessKey | `dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm` | `QiniuConfig.m:12` | -| 七牛 SecretKey | `RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq` | `QiniuConfig.m:13` | -| 七牛 BucketName | `iosaudio` | `QiniuConfig.m:16` | -| 七牛 CDN Domain | `iosaudio.daoqi88.cn` | `daoqi/msext/msext/qiniudomain/` 目录名 | +| 七牛 AccessKey | `dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm` | **新外壳**:`AppSecrets.plist` 的 `qiniuAccessKey` / msext:`QiniuConfig.m:12` | +| 七牛 SecretKey | `RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq` | **新外壳**:`AppSecrets.plist` 的 `qiniuSecretKey` / msext:`QiniuConfig.m:13` | +| 七牛 BucketName | `iosaudio` | **新外壳**:RemoteConfig 顶层 `audio_bucket` 远端注入 / msext:`QiniuConfig.m:16` | +| 七牛 CDN Domain | `iosaudio.daoqi88.cn` | **新外壳**:RemoteConfig 顶层 `audio_domain` 远端注入 / msext:`qiniudomain/` 目录名 | + +> **凭证位置三分制(新外壳 ADR-009)**: +> - **微信 AppID** 唯一源 = `Info.plist` 的 `CFBundleURLTypes`(iOS 系统级 URL Scheme,运行期不可注入) +> - **应用级凭证**(微信 AppSecret、七牛 AccessKey、七牛 SecretKey)唯一源 = `Resources/AppSecrets.plist` +> - **七牛运行参数**(CDN 域名、bucket 名)唯一源 = RemoteConfig 顶层 `audio_domain` / `audio_bucket`(启动期由 `WebContainerViewController` 注入 `QiniuConfig` actor;缺失即抛 `BootError.audioConfigMissing` 启动期致命) ### 0.1 安全权衡(与父项目 CLAUDE.md 同步) @@ -350,4 +355,4 @@ bridge.register("prepareaudio") { _, callback in - 高德 9 字段:`daoqi/msext/Class/RootVC/gameController.m:2528 / 2557`(`getlocationinfo` 字段顺序与类型) - AMR 转码:`daoqi/msext/Class/Common/VoiceConvert/`(`VoiceConverter.h/m`、`amrFileCodec.h/m`) - 七牛 token 自签算法:`daoqi/msext/Class/Utils/QiniuManager.m:200-230`(putPolicy + HMAC-SHA1 + Base64URL) -- 七牛域名读取:`daoqi/msext/AppDelegate.m:142` 用 `FuncPublic filename:@"qiniudomain"` 读 `Resources/qiniudomain/` 下第一个子目录名作为实际 Domain +- 七牛域名读取(msext 历史路径):`daoqi/msext/AppDelegate.m:142` 用 `FuncPublic filename:@"qiniudomain"` 读 `Resources/qiniudomain/` 下第一个子目录名作为实际 Domain。**新外壳不再沿用此路径**,CDN 域名 + bucket 名走 RemoteConfig 顶层 `audio_domain` / `audio_bucket` 远端注入到 `QiniuConfig` actor(ADR-009) diff --git a/docs/Verification-Checklist.md b/docs/Verification-Checklist.md index 2ae73af..1c65d8f 100644 --- a/docs/Verification-Checklist.md +++ b/docs/Verification-Checklist.md @@ -66,7 +66,7 @@ xcrun simctl get_app_container booted com.skyapp.ylgamehall data 预期 Xcode console 顺序: ``` -[BundleConfig] 渠道注入读取结果(11 项) +[BundleConfig] 渠道注入读取结果(10 项) [SandboxPaths] 路径计算结果 [ResourceUnzipper] ensureReady 完成 [RemoteConfigClient] 拉取成功 .parsed diff --git a/ylgamehall/Resources/AppSecrets.plist b/ylgamehall/Resources/AppSecrets.plist new file mode 100644 index 0000000..cc95e38 --- /dev/null +++ b/ylgamehall/Resources/AppSecrets.plist @@ -0,0 +1,12 @@ + + + + + wxAppSecret + b2792724b9565be23e8f5ba548f117cf + qiniuAccessKey + dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm + qiniuSecretKey + RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq + + diff --git a/ylgamehall/Resources/ChannelConfig.plist b/ylgamehall/Resources/ChannelConfig.plist index 7e895c1..fc78ad5 100644 --- a/ylgamehall/Resources/ChannelConfig.plist +++ b/ylgamehall/Resources/ChannelConfig.plist @@ -2,8 +2,6 @@ - qiniudomain - iosaudio.daoqi88.cn gameid G2hw0ubng0zcoI0r4mx3H2yr4GejidwO channel diff --git a/ylgamehall/Source/Login/WeChatAuth.swift b/ylgamehall/Source/Login/WeChatAuth.swift index de79127..26fbab0 100644 --- a/ylgamehall/Source/Login/WeChatAuth.swift +++ b/ylgamehall/Source/Login/WeChatAuth.swift @@ -5,10 +5,13 @@ // 微信 OAuth2 客户端直拼实现(与 msext 4 处 ViewController 同款路径)。 // Code → access_token → userinfo → 7 字段 user。 // -// ⚠️ AppSecret 客户端硬编码 = 父项目 CLAUDE.md 已识别但接受的安全风险。 +// ⚠️ AppSecret 客户端持有 = 父项目 CLAUDE.md 已识别但接受的安全风险。 // msext IPA 已分发数年,Appsecret 等价泄露;沿用同一 secret 不引入新攻击面, // 也无需后台 /wechat/login 中转。详见 docs/SDK-Integration-Guide.md §0.1。 // +// 存储位置:AppSecrets.plist 的 wxAppSecret 字段(唯一来源),由 +// AppSecrets.shared 启动期一次性加载。 +// // 字段名严格 1:1(与 msext NewRootVC.m:2428 sharelogin payload 等价): // openid / headimgurl / nickname / sex / city / Province(大写 P)/ unionid // @@ -40,8 +43,8 @@ public enum WeChatAuthError: Error, Sendable { @MainActor public enum WeChatAuth { - /// 微信 AppSecret(沿用 msext SGDefineInfo.h:107)— 客户端直拼 sns/oauth2 用 - static let appSecret = "b2792724b9565be23e8f5ba548f117cf" + /// 微信 AppSecret — 来源 `AppSecrets.plist` 的 `wxAppSecret`(启动期加载) + static var appSecret: String { AppSecrets.shared.wxAppSecret } /// 完整 OAuth2 流程:拉起授权 → 拿 code → 换 access_token → 拿 userinfo → 返回 7 字段 user public static func authorize() async throws -> WeChatUser { diff --git a/ylgamehall/Source/Network/QiniuConfig.swift b/ylgamehall/Source/Network/QiniuConfig.swift index 27ff673..76b69c3 100644 --- a/ylgamehall/Source/Network/QiniuConfig.swift +++ b/ylgamehall/Source/Network/QiniuConfig.swift @@ -2,36 +2,59 @@ // QiniuConfig.swift // ylgamehall // -// 七牛云配置常量(沿用 msext QiniuConfig.m)。 +// 七牛云配置访问层。 // -// ⚠️ SecretKey 客户端硬编码 = 父项目 CLAUDE.md 已识别但接受的安全风险, -// 同款 AppSecret 路径。msext 已分发多年等价泄露,沿用不引入新攻击面。 +// 数据来源(单一真相): +// - accessKey / secretKey ← AppSecrets.plist(应用级凭证,跨渠道相同) +// - bucketName / cdnDomain ← RemoteConfig 顶层 audio_bucket / audio_domain +// (远端动态下发,缺失启动期致命,由 WebContainerViewController 校验后 +// 调用 update(domain:bucket:) 注入) +// +// ⚠️ secretKey 客户端持有 = 父项目 CLAUDE.md「已识别但接受的安全风险」, +// 同款 wxAppSecret 路径。msext 已分发多年等价泄露,沿用不引入新攻击面。 // import Foundation -/// 七牛云配置常量。整体 nonisolated,使得 QiniuUploader(actor)和其它非 MainActor -/// 上下文可直接读取这些 immutable static 常量,无需 await MainActor.run。 -public enum QiniuConfig { - /// 七牛 AccessKey(沿用 msext QiniuConfig.m:12) - public nonisolated static let accessKey = "dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm" +/// 七牛云配置访问层。actor 隔离让远端注入的 cdnDomain / bucketName 在并发上传场景下 +/// 写读安全。accessKey / secretKey 是 nonisolated immutable,无需 actor 屏障。 +public actor QiniuConfig { - /// 七牛 SecretKey(沿用 msext QiniuConfig.m:13)— HMAC-SHA1 签 putPolicy 用 - public nonisolated static let secretKey = "RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq" + public static let shared = QiniuConfig() - /// 存储空间名(沿用 msext QiniuConfig.m:16)— putPolicy scope 用 - public nonisolated static let bucketName = "iosaudio" + // MARK: - 来自 AppSecrets.plist(应用级,启动期一次性加载,运行期不变) - /// CDN 默认域名(沿用 msext qiniudomain/ 渠道注入目录名,已生产生效)。 - /// msext fallback 是 `iosaudio.daoqi8888.cn`,但实际渠道注入覆盖为 `daoqi88`; - /// 新外壳直接沿用渠道注入值。 - public nonisolated static let cdnDomain = "iosaudio.daoqi88.cn" + /// 七牛 AccessKey — 来源 `AppSecrets.plist.qiniuAccessKey` + public nonisolated static var accessKey: String { AppSecrets.shared.qiniuAccessKey } + + /// 七牛 SecretKey — 来源 `AppSecrets.plist.qiniuSecretKey`。HMAC-SHA1 签 putPolicy 用 + public nonisolated static var secretKey: String { AppSecrets.shared.qiniuSecretKey } /// 录音 key 模板前缀(msext QiniuConfig.m:22 为空字符串) public nonisolated static let recordingDirectory = "" + // MARK: - 来自 RemoteConfig(远端,启动期动态注入) + + private var _cdnDomain: String = "" + private var _bucketName: String = "" + + /// 启动期由 WebContainerViewController 在远端配置拉到且校验非空后调用, + /// 之后 QiniuUploader / QiniuTokenSigner 即可 await 读取。 + public func update(cdnDomain: String, bucketName: String) { + _cdnDomain = cdnDomain + _bucketName = bucketName + } + + /// CDN 域名(不带 http:// 协议头)。访问前必须先 `update(...)`,否则返回空串 + /// 导致后续上传/拼 URL 失败 —— 但启动流水线已保证 update 一定先于任何上传调用 + public var cdnDomain: String { _cdnDomain } + + /// 七牛 bucket 名(putPolicy.scope)。访问前必须先 `update(...)`。 + public var bucketName: String { _bucketName } + /// 拼访问 URL:`http://{cdnDomain}/{key}`(msext QiniuManager.m:197 等价) - public nonisolated static func publicURL(forKey key: String) -> String { - "http://\(cdnDomain)/\(key)" + /// 协议头固定 http://(与 msext 一致;七牛 CDN 同时支持 https,按需评估)。 + public func publicURL(forKey key: String) -> String { + "http://\(_cdnDomain)/\(key)" } } diff --git a/ylgamehall/Source/Network/QiniuTokenSigner.swift b/ylgamehall/Source/Network/QiniuTokenSigner.swift index cc373e4..90b43fb 100644 --- a/ylgamehall/Source/Network/QiniuTokenSigner.swift +++ b/ylgamehall/Source/Network/QiniuTokenSigner.swift @@ -17,7 +17,8 @@ import Foundation import CryptoKit -/// 七牛上传 token 自签。整体 nonisolated,actor 内可直接调(不需要 await MainActor.run)。 +/// 七牛上传 token 自签。bucketName 来自 actor `QiniuConfig.shared`,需 await; +/// accessKey / secretKey 是 AppSecrets 同步取,不需要 await。 public enum QiniuTokenSigner { /// 生成上传 token。 @@ -27,11 +28,12 @@ public enum QiniuTokenSigner { /// 允许任意 key),与新外壳上传 key 是分离的两个维度。带 `:key` 后 /// 缀(限定 key 精确匹配)会让七牛后端校验更严,且与 daoqi 行为不一致 /// - expiresIn: token 有效时长(默认 3600 秒) - public nonisolated static func uploadToken(key: String? = nil, expiresIn: TimeInterval = 3600) -> String { + public static func uploadToken(key: String? = nil, expiresIn: TimeInterval = 3600) async -> String { _ = key // 参数保留以避免破坏调用方,但不再使用 // 1. scope 仅含 bucket(对齐 daoqi QiniuManager.m:207) - let scope = QiniuConfig.bucketName + // bucketName 由远端 audio_bucket 动态注入,启动期已校验非空 + let scope = await QiniuConfig.shared.bucketName let deadline = Int(Date().timeIntervalSince1970 + expiresIn) // 2. putPolicy JSON(daoqi 用默认 options:0,不强制排序) diff --git a/ylgamehall/Source/Network/QiniuUploader.swift b/ylgamehall/Source/Network/QiniuUploader.swift index 8e950e2..71783c2 100644 --- a/ylgamehall/Source/Network/QiniuUploader.swift +++ b/ylgamehall/Source/Network/QiniuUploader.swift @@ -56,7 +56,9 @@ public actor QiniuUploader { let key = QiniuConfig.recordingDirectory + localFile.lastPathComponent // 客户端自签 token(参 QiniuTokenSigner / msext QiniuManager 同款算法) - let token = QiniuTokenSigner.uploadToken(key: key) + let token = await QiniuTokenSigner.uploadToken(key: key) + // 预取 CDN 域名(远端注入到 QiniuConfig actor),闭包里同步拼 URL 避免再 await + let cdnDomain = await QiniuConfig.shared.cdnDomain // 七牛 SDK 8.x:`QNUploadManager.init()` 被标 `kQNDeprecated`,必须走 // `initWithConfiguration:`。用 defaultConfigurationV2(v2 已替代旧 defaultConfiguration)。 @@ -75,7 +77,7 @@ public actor QiniuUploader { token: token, complete: { info, savedKey, _ in if let info, info.isOK, let savedKey { - let url = QiniuConfig.publicURL(forKey: savedKey) + let url = "http://\(cdnDomain)/\(savedKey)" cont.resume(returning: UploadedFile( fileUrl: url, fileName: fileName, diff --git a/ylgamehall/Source/Network/RemoteConfigClient.swift b/ylgamehall/Source/Network/RemoteConfigClient.swift index 952df25..b6cdf25 100644 --- a/ylgamehall/Source/Network/RemoteConfigClient.swift +++ b/ylgamehall/Source/Network/RemoteConfigClient.swift @@ -12,7 +12,27 @@ import Foundation nonisolated public struct RemoteConfig: Codable, Sendable { public let showmessage: String? + /// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。 + /// 跨渠道全局相同,放顶层不进 4 层 fallback。缺失视为后台配置错误, + /// 启动期由 WebContainerViewController 抛 BootError.audioConfigMissing。 + public let audioDomain: String? + /// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain。 + public let audioBucket: String? public let agentlist: [Agent]? + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) + audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain) + audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket) + agentlist = try c.decodeIfPresent([Agent].self, forKey: .agentlist) + } + + private enum CodingKeys: String, CodingKey { + // 驼峰 case 名(不带 rawValue),依赖 JSONDecoder.convertFromSnakeCase 自动把 + // JSON 的 audio_domain / audio_bucket 归一化到 audioDomain / audioBucket + case showmessage, agentlist, audioDomain, audioBucket + } } nonisolated public struct Agent: Codable, Sendable { diff --git a/ylgamehall/Source/Resource/AppSecrets.swift b/ylgamehall/Source/Resource/AppSecrets.swift new file mode 100644 index 0000000..c15291f --- /dev/null +++ b/ylgamehall/Source/Resource/AppSecrets.swift @@ -0,0 +1,49 @@ +// +// AppSecrets.swift +// ylgamehall +// +// 应用级凭证(跨渠道相同):从 Bundle 内的 AppSecrets.plist 读 3 个 string 值。 +// +// ⚠️ 客户端硬编码 = 父项目 CLAUDE.md「已识别但接受的安全风险」, +// 集中放进 plist 仅做"单一真相源"管理,不缓解安全面 —— msext IPA 已分发数年, +// 这些凭证等价泄露;沿用同一份不引入新攻击面,也无需后台中转。 +// +// 与 ChannelConfig.plist 的语义边界: +// - ChannelConfig.plist:渠道差异化字段(每个渠道签名包不同值) +// - AppSecrets.plist:跨渠道相同凭证(一份硬编码,多渠道共用) +// +// 字段对应: +// - wxAppSecret : 微信 OAuth2 access_token 换取(msext SGDefineInfo.h:107) +// - qiniuAccessKey : 七牛 putPolicy 签名 + 上传 token 前缀 +// - qiniuSecretKey : 七牛 HMAC-SHA1 签名 putPolicy +// +// 注:微信 AppID 不在此 plist,唯一来源是 Info.plist 的 CFBundleURLTypes +// (URLName=weixin 的 first scheme)。详见 WeChatSDK.swift。 +// + +import Foundation + +nonisolated public final class AppSecrets: Sendable { + + public static let shared = AppSecrets() + + public let wxAppSecret: String + public let qiniuAccessKey: String + public let qiniuSecretKey: String + + public init(bundle: Bundle = .main) { + let dict = Self.loadPlist(bundle: bundle) + wxAppSecret = dict["wxAppSecret"] ?? "" + qiniuAccessKey = dict["qiniuAccessKey"] ?? "" + qiniuSecretKey = dict["qiniuSecretKey"] ?? "" + } + + private static func loadPlist(bundle: Bundle) -> [String: String] { + guard let url = bundle.url(forResource: "AppSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList( + from: data, format: nil) as? [String: String] + else { return [:] } + return plist + } +} diff --git a/ylgamehall/Source/Resource/BundleConfig.swift b/ylgamehall/Source/Resource/BundleConfig.swift index c14ec87..46c4690 100644 --- a/ylgamehall/Source/Resource/BundleConfig.swift +++ b/ylgamehall/Source/Resource/BundleConfig.swift @@ -2,13 +2,16 @@ // BundleConfig.swift // ylgamehall // -// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 11 个 string 值。 +// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 10 个 string 值。 // 设计模式与契约见 docs/H5-Native-Implementation-Design.md §7 / ADR-007。 // +// 注:原有 qiniudomain 字段已于 2026-06-27 移除,七牛 CDN 域名改由 +// RemoteConfig 顶层 audio_domain 单一权威源动态下发。 +// import Foundation -/// 渠道注入配置(11 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。 +/// 渠道注入配置(10 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。 /// /// 多渠道分发:构建后用 `plutil` 修改 `.app/ChannelConfig.plist` + 重签, /// 不需要重新 Xcode build。详见 ADR-007。 @@ -17,7 +20,6 @@ nonisolated public final class BundleConfig: Sendable { /// App 启动期默认读 main bundle 的 `ChannelConfig.plist`,业务代码统一通过此单例访问。 public static let shared = BundleConfig() - public let qiniuDomain: String public let gameId: String public let channel: String public let gameDir: String @@ -32,7 +34,6 @@ nonisolated public final class BundleConfig: Sendable { /// 单测可注入任意 bundle 验证不同 plist fixture。 public init(bundle: Bundle = .main) { let dict = Self.loadPlist(bundle: bundle) - qiniuDomain = dict["qiniudomain"] ?? "" gameId = dict["gameid"] ?? "" channel = dict["channel"] ?? "" gameDir = dict["gamedir"] ?? "" diff --git a/ylgamehall/Source/SDK/WeChat/WeChatSDK.swift b/ylgamehall/Source/SDK/WeChat/WeChatSDK.swift index 6923704..c792464 100644 --- a/ylgamehall/Source/SDK/WeChat/WeChatSDK.swift +++ b/ylgamehall/Source/SDK/WeChat/WeChatSDK.swift @@ -15,8 +15,28 @@ import UIKit @MainActor public enum WeChatSDK { - /// 微信 AppID(沿用 msext SGDefineInfo.h:105 kAuthOpenID) - public static let appID = "wx586a9b321e56efb7" + /// 微信 AppID — **唯一来源** 是 Info.plist 的 CFBundleURLTypes 中 + /// URLName=`weixin` 节点下 CFBundleURLSchemes 的首个 scheme(这是 iOS + /// 系统级 URL Scheme 注册,运行期无法注入,必须在 Info.plist 静态声明)。 + /// + /// 多渠道分发若需更换 AppID,由 plutil 改 Info.plist 同步即可(同样需要 + /// 在微信开放平台后台改后台配置),不需要再改 Swift 源码。 + /// + /// 配置缺失视为构建配置错误,启动期 preconditionFailure(fail-fast)。 + public static let appID: String = { + guard let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]], + let weixinType = urlTypes.first(where: { + ($0["CFBundleURLName"] as? String) == "weixin" + }), + let schemes = weixinType["CFBundleURLSchemes"] as? [String], + let appID = schemes.first, !appID.isEmpty + else { + preconditionFailure( + "Info.plist 缺少 weixin URL Scheme(CFBundleURLTypes 中 CFBundleURLName=weixin 的 CFBundleURLSchemes 首项即为微信 AppID)" + ) + } + return appID + }() /// 启动注册:在 AppDelegate.didFinishLaunchingWithOptions 调用一次。 /// 1.x 单参数 registerApp,不需要 Universal Link。 diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index ec24bf2..c6c760a 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -41,6 +41,8 @@ public final class WebContainerViewController: UIViewController { case operationalMessage(String) /// IPA 需要升级,弹窗 + Safari 外链 case ipaUpgradeRequired(downloadURL: String) + /// 远端 RemoteConfig 缺 audio_domain / audio_bucket:后台配置错误,启动期致命 + case audioConfigMissing } // MARK: - Lifecycle @@ -183,6 +185,10 @@ public final class WebContainerViewController: UIViewController { } catch BootError.ipaUpgradeRequired(let dl) { // IPA 升级 modal alert(业务流程信号) showIPAUpgradeAlert(downloadURL: dl) + } catch BootError.audioConfigMissing { + // 后台配置错误:远端 RemoteConfig 缺 audio_domain / audio_bucket。 + // 用户/运营无法自助处理,与运营公告同款 modal 永停。 + showBlockingAlert(message: "音频服务暂不可用(配置缺失),请联系客服。") } catch { // 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。 // 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。 @@ -339,6 +345,15 @@ public final class WebContainerViewController: UIViewController { throw BootError.operationalMessage(msg) case .parsed(let cfg): + // 七牛 audio_domain / audio_bucket:远端配置的唯一权威源。 + // 缺失视为后台配置错误,启动期致命 —— 不允许使用空值上传录音。 + guard let audioDomain = cfg.audioDomain, !audioDomain.isEmpty, + let audioBucket = cfg.audioBucket, !audioBucket.isEmpty + else { + throw BootError.audioConfigMissing + } + await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket) + let bc = BundleConfig.shared let r = VersionResolver.resolve( config: cfg,