From 1bc287bafcef3e6397502a7a3b928afe7b5c7d24 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 02:14:57 +0800 Subject: [PATCH] =?UTF-8?q?Phase=201.10=EF=BC=9ARemoteConfigClient=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=89=88=EF=BC=88HTTPS=20+=20cache-busting?= =?UTF-8?q?=20+=20=E7=9F=AD=E6=96=87=E6=9C=AC=20+=20FlexibleString?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二轮调研 daoqi/NewRootVC.m 全文核对后,把对原项目远程配置流程的精确 理解固化进 ADR-008 + Design §6.3.2,并补齐 RemoteConfigClient 缺失的 3 个契约(cache-busting query、短文本响应识别、字段类型混乱兼容)。 核心修正:线上热路径是 chulishengji 双子树合并算法(NewRootVC.m:1372-1538), 不是 onnet 简单 4 层覆盖。viewWillAppear 与 gonet 都会强制把当前 agent 的 gamelist 提到 self.gamelist,只要服务器 JSON 含 agent.gamelist(线上 100% 都有), self.gamelist 就非 nil,走 else 分支 → chulishengji。本次烟雾测试实测: agent[0].gamelist = 7 个,完美印证 chulishengji 必要性。 - RemoteConfigClient 新增 cache-busting query:?vXXXXXXXXYYYYYYYY(两段 8 位 hex 连写,无 =,照搬 msext NewRootVC.m:1226 契约),每次重试都重新生成 - 新增 RemoteConfigOutcome enum:.parsed(RemoteConfig) 与 .shortText(String) 两种成功状态。短文本响应(trim 后 utf16.count ≤ 100)是服务端运营杀手锏 #1, msext 当 alert 文本弹窗 + 停止重试(NewRootVC.m:1239-1243) - UTF-8 解码 + trim 跟 msext NewRootVC.m:1237-1238 严格对齐 - 所有 String? 字段改用 decodeFlexibleStringIfPresent 扩展(兼容 number-as-string): agentid / channelid / marketid / gameid / showmessage / appVersion / appDownload / gameVersion / gameZip 全部 9 字段。msext 用 NSNumber.intValue/description 静默 吞下 number,新外壳 Codable 严格类型必须显式处理 - 远端 URL 从 http 改为 https(后台已切,无需 ATS 例外) - Plan ADR-008 修订为含 9 个子节(A-I)的精确决策记录,含 chulishengji 双子树算法、agent 子树结构、game 子树结构、字段级合并优先级、决策顺序、 与 msext 差异表 - Design §6.3.2 VersionResolver 重写:从"简单 4 层 reduce"改为"chulishengji 双子树合并 + 字段级合并",附 13 项单测覆盖矩阵 - RootViewController 烟雾测试切换为 outcome enum switch case,覆盖 .parsed 与 .shortText 两条路径 - 实测烟雾:HTTPS 拉到 30 KB JSON 解析 .parsed 成功(耗时 2.7s 含 TLS 握手), agent[0].gamelist 含 7 个 game 节点,证明 chulishengji 路径是热路径 --- docs/Development-Plan.md | 172 +++++++++-- docs/H5-Native-Implementation-Design.md | 78 ++++- ylgamehall/RootViewController.swift | 34 +++ .../Source/Network/RemoteConfigClient.swift | 278 ++++++++++++++++++ 4 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 ylgamehall/Source/Network/RemoteConfigClient.swift diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 08de5ff..17b0e43 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -1002,40 +1002,152 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道) - **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质 -### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22) -- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip -- **触发事件**:2026-06-22 调研 `daoqi/msext` 原项目(subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现: - - 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt`) - - JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅层 - - 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload) - - 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户 -- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17` -- **理由**: - - **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip - - 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同) - - 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载 -- **关键设计点(与 msext 实现的差异)**: - | 维度 | msext 现状 | 新外壳决策 | - |------|----------|----------| - | 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`,actor 隔离 | - | 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 | - | 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 | - | 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 | - | 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 | - | 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder | - | 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 | -- **守护**: - - **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感 - - `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里** - - `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议) - - 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归 -- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理 +### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 2 次调研 revise) +- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip +- **触发事件**:2026-06-22 两轮 subagent 调研 `daoqi/msext` 原项目,逐行核对核心方法: + - 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链 + - 第二轮(精确算法):核对 `NewRootVC.m` 全文,识别出**线上热路径是 chulishengji 双子树合并**,非 onnet 简单 4 层 +- **核心修正:线上热路径算法** + - **不是** `onnet` 的 `if(self.gamelist==nil)` 分支(`NewRootVC.m:689-810`)的"简单线性 4 层覆盖" + - **是** `onnet` 的 `else` 分支(`NewRootVC.m:811-877`)→ `gonetconfig1` / `gonetconfigone1` → **`chulishengji`**(`NewRootVC.m:1372-1538`)双子树合并 + - 触发条件:`viewWillAppear`(`NewRootVC.m:1609-1616`)与 `gonet`(`NewRootVC.m:1252-1258`)拉完 JSON 都强制把当前 agent 节点下 `gamelist` 提到 `self.gamelist`;只要服务器 JSON 里 agent 节点带 `gamelist`(线上 100% 都有),`self.gamelist` 就非 nil → 走 chulishengji 路径 +- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`。**`VersionResolver` 必须按 chulishengji 双子树算法实现,不照搬 onnet 简单 4 层** + +#### ADR-008-A 远端 URL 构造(`RemoteConfigClient`) + +- 基础 URL:`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"` + - 新外壳改 HTTPS(msext 用 HTTP,`NewRootVC.m:250`)。后台已经支持 HTTPS。 +- **cache-busting query**(msext `NewRootVC.m:1226, 1585`):每次拉取时拼 `?vXXXXXXXXYYYYYYYY`(两个 `arc4random()` 各 8 位 hex 连写,**无 `=`**)。**新外壳保留此约定**——服务端可能根据这段 query 做无 cache 处理。 + +#### ADR-008-B 网络层 + +- API:`URLSession.data(from:)` async(替代 msext `[NSData dataWithContentsOfURL:]` 主线程同步阻塞)。10s 请求超时 + 30s 资源超时。 +- 重试策略:**最初取 3 次 1/2/4 秒指数退避**(已在 1.10 落地);上线前评估改为 **msext 风格 4s 等间隔无次数上限**——因为 msext 那套"无网静默重试 + 网络恢复立刻进"的体验在地铁场景是合理的,新外壳不应当回退。 +- 静默重试:网络错误不弹窗、不打 log,等下次 tick;只在第一次失败时 UI 显示状态(启动图 + "重新连接中")。这是契约。 +- 短文本响应(`NewRootVC.m:1239-1243`):trim 后 `length ≤ 100` 时**当 alert 内容直接弹窗**,并停止重试。运营杀手锏 #1。**新外壳用 `utf16.count` 实现 1:1 复刻**(msext 用 NSString length 即 UTF-16 unit 计数)。 + +#### ADR-008-C JSON 解析(`RemoteConfigClient`) + +- 服务端响应是 UTF-8 编码的 JSON(扩展名 `.txt` 仅历史包袱) +- 顶层结构:`{ showmessage, agentlist[], scrollmsg, Ads, managerUrl, isclose, menunotice, ... 20+ H5 业务字段 }` + - 原生**只读** `agentlist + showmessage`,**其余字段 H5 同 URL 再拉一次**自己读 + - Codable 忽略未识别字段(默认行为),新外壳自动满足 +- **字段类型混乱(必须兼容)**:`marketid` / `game_version` / `app_version` 等本应为 string 的字段,服务端会发 number。msext 用 NSNumber 静默吞下;**新外壳用 `decodeFlexibleStringIfPresent` 扩展(String / Int / Double / Bool 全兼容)**——已在 1.10 实现 +- **解析失败行为**:msext 静默 nil → 后续 for 循环 0 次跑空 → App 静默卡死(缺陷)。新外壳应当**显式抛 `ConfigParseFailed` + UI 弹错误页 + 提供重试按钮** + +#### ADR-008-D chulishengji 双子树合并算法(`VersionResolver`) + +新外壳 `VersionResolver.resolve()` 必须 1:1 复刻 `NewRootVC.m:1372-1538` 的算法,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。 + +**步骤**: + +```swift +// 1. 提取 agent 子树(getagentversion,NewRootVC.m:885-1013) +// agent → channel → market → game(game 嵌在 market 命中后才遍历) +agentConfig = extractAgentSubtree(agent) +// → (app_version, app_download, game_version, game_download, showmessage) + +// 2. 提取 game 子树(getgameversion,NewRootVC.m:1015-1180) +// game → game-self → channel → market +// (第二层 "game-self" 是把 gamedata 自身当 infotwo 再嵌一遍 channellist; +// msext 那段曾经的 agentid 判断被注释掉了) +gameConfig = extractGameSubtree(game) +// → (app_version, app_download, game_version, game_download, showmessage) + +// 3. 合并 IPA 字段(NewRootVC.m:1408-1447) +if both have app_download: + result.appVersion = agentConfig.appVersion // 默认 agent 赢 + result.appDownload = agentConfig.appDownload + if gameConfig.appVersion > agentConfig.appVersion: // 版本号大的赢(line 1416) + result.appVersion = gameConfig.appVersion + result.appDownload = gameConfig.appDownload +elif only one has: 用那一份 +else: nil + +// 4. 合并 zip 字段(NewRootVC.m:1450-1492) +if both have game_download: + result.gameVersion = gameConfig.gameVersion // 默认 game 赢 + result.gameZip = gameConfig.gameZip + if agentConfig.gameVersion > gameConfig.gameVersion: // 版本号大的赢(line 1456) + result.gameVersion = agentConfig.gameVersion + result.gameZip = agentConfig.gameZip +elif only one has: 用那一份 +else: nil + +// 5. showmessage:在两条子树 extract 内部多次覆盖(agent 子树 :897/:910/:924/:948; +// game 子树 :1027/:1058/:1087/:1114),加上 onnet else 的两次 for 循环 (:826/:852) +// 与 gonetconfigone1 :1319。算法上等同于"任意一处非空就覆盖",最后写赢。 +result.showmessage = mergeShowmessage(top, agent, game, channel, market) +``` + +**agent 子树 4 层结构**(`getagentversion:`): +- 第 1 层 agent 自己:`NewRootVC.m:889-915` 提取顶层 4 字段 + showmessage +- 第 2 层 channel:`NewRootVC.m:903-940` 嵌 channellist 找匹配 +- 第 3 层 market:`NewRootVC.m:921-997` 嵌 marketlist 找匹配 +- 第 4 层 game:`NewRootVC.m:944-996` 嵌在 market 命中后才遍历 game(注意是 market.gamelist,不是 channel.gamelist) + +**game 子树 4 层结构**(`getgameversion:`): +- 第 1 层 game 自己:`NewRootVC.m:1019-1045` +- 第 2 层 game-self(`infotwo=gamedata` 自身再嵌 channellist):`NewRootVC.m:1048-1073` +- 第 3 层 channel:`NewRootVC.m:1075-1100` +- 第 4 层 market:`NewRootVC.m:1100-1163` + +**字段名差异(必须留意)**: +- agent 子树用 `game_download`(`NewRootVC.m:952`) +- game 子树用 `game_zip`(`NewRootVC.m:1037`) +- 两者本质同一字段,msext 历史命名不一致。新外壳 Codable 模型用 `gameZip`,但需要在两个 extract 函数里都查这两个 key(择一非空) + +#### ADR-008-E 决策顺序(`WebContainer` 编排) + +5 字段拿到后**固定顺序**判断(chulishengji 路径在 `NewRootVC.m:1501-1527`): + +| 优先级 | 条件 | 动作 | +|---|---|---| +| 1 | `showmessage != ""` 且非 nil | 弹 alert(标题 `gamehallname + 提醒`,单按钮"确定"),**完全阻塞**,return | +| 2 | `[version_ios intValue] > [iosNumber intValue]` | 弹 IPA 升级 alert(标题"有新的版本更新,点击前往下载!更新过程中可能会白屏...",单按钮"确定",tag PublicTagfive) → Safari 外链 `app_download`,return | +| 3 | 否则 | `uplevel:download:` 内(`NewRootVC.m:1874-1885`):`gamevers > [versioninfo intValue]` → `downFileFromServer:`;否则 `initView` 直接加载本地 H5 | + +#### ADR-008-F LocalVersionReader(1.12) + +- **iOS 本地版本**:`BundleConfig.shared.appVersion` 转 Int(来自 ChannelConfig.plist `appversion` 字段,等价 msext `[FuncPublic filename:@"appversion"]`) +- **H5 本地版本**:`Library/Caches/{gamedir}/{gamestart}/version.xml` 解析 `/game/version@value` + - msext 用 GDataXML(`NewRootVC.m:664-682`);新外壳用 Foundation `XMLParser`(无外部依赖) + - **缺失 / 损坏返回 0**(必须保留,是首装后首次升级的兜底机制;msext `[nil intValue] = 0`) + +#### ADR-008-G LobbyZipUpgrader(1.13) + +- 下载链路:`URLSession.download(from: gameZip)` async + 进度回调 +- 解压:临时目录 `staging-{uuid}/` + 原子 `moveItem` rename(不学 msext 先删后压的中断风险) +- **失败处理**:msext `requestFailed:` 只打 NSLog 静默;新外壳应当**弹错误页 + 回退用 Bundle 内 H5**(旧 zip 仍可用) + +#### ADR-008-H 5 字段的他用(必须保留) + +- `showmessage`:仅 alert,不传 H5 / 不存 UserDefaults +- `version_ios`:① 决策;② `initJSdata` 间接通过 `app_data.js` 注入 `app_appversion` 字段传给 H5(值 0 = gameconfig / 1 = appleconfig,控制 H5 走两条不同分发路径) +- `app_download`:仅本次启动用 +- `game_version_` / `game_zip_`:决策 + downFileFromServer 拼下载 URL +- 全部**不存 UserDefaults**(msext 无缓存层) + +#### ADR-008-I 与 msext 差异(实施层面,H5 契约不变) + +| 维度 | msext 现状 | 新外壳决策 | +|------|----------|----------| +| 网络库 | 主线程同步 `dataWithContentsOfURL:` + ASIHTTPRequest 下载 | `URLSession.data(from:)` + `download(from:)` async,actor 隔离 | +| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避;可改为 msext 风格无上限 | +| URL | HTTP | HTTPS(后台已切) | +| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** | +| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string) | +| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 | +| version.xml | GDataXML | Foundation XMLParser | +| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename | +| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) | + +- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议 - **影响 Phase 2 及之后**: - - Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current` - Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径) - Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径) --- 文档完成日期:2026-06-21 -最后更新:2026-06-22(ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录) +最后更新:2026-06-22(ADR-008 二次精确化 chulishengji 双子树算法 + 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 58659cd..a1c63dd 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -1083,7 +1083,9 @@ public struct Game: Codable, Sendable { ... } public struct Market: Codable, Sendable { ... } ``` -#### 6.3.2 VersionResolver(纯函数) +#### 6.3.2 VersionResolver(chulishengji 双子树合并,纯函数) + +**核心修正(ADR-008 二次精确化)**:线上热路径是 chulishengji 双子树合并,**不是**简单的"顶 → agent → channel → market"线性覆盖。 ```swift // Source/Network/VersionResolver.swift @@ -1094,10 +1096,16 @@ public enum VersionResolver { public let appDownload: String? public let gameVersion: Int // 0 表示远端未指定 public let gameZip: String? + public let showmessage: String? // 非空时阻塞,运营杀手锏 } - /// 4 级覆盖:顶层 → agent → channel → market,后者覆盖前者。 - /// agent 子树和 game 子树各自合并,game 子树最终优先(msext 'chulishengji' 中的语义)。 + /// chulishengji 双子树合并(NewRootVC.m:1372-1538): + /// 1. 从 agent 子树提取(agent → channel → market → game,game 嵌在 market 命中后才遍历) + /// 2. 从 game 子树提取(game → game-self → channel → market) + /// 3. 字段级合并: + /// - IPA(appVersion / appDownload)默认 agent 赢;game 版本号更大时 game 赢(line 1416) + /// - zip(gameVersion / gameZip)默认 game 赢;agent 版本号更大时 agent 赢(line 1456) + /// 4. showmessage 两条子树多层多次覆盖,最后写赢 public static func resolve( config: RemoteConfig, agentId: String, @@ -1105,13 +1113,71 @@ public enum VersionResolver { marketId: String, gameId: String ) -> Resolved { - // pure function: 不依赖任何 actor / global - // 实现:reduce over [top, agent, channel, market] - ... + // 1. 找到当前 agent / channel / market / game 节点 + guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }), + let game = agent.gamelist?.first(where: { $0.gameid == gameId }) else { + // 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径 + return Resolved(appVersion: 0, appDownload: nil, + gameVersion: 0, gameZip: nil, + showmessage: config.showmessage) + } + + let agentExtract = extractAgentSubtree( + agent: agent, channelId: channelId, marketId: marketId, gameId: gameId) + let gameExtract = extractGameSubtree( + game: game, channelId: channelId, marketId: marketId) + + // IPA 合并(NewRootVC.m:1408-1447) + let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract) + // zip 合并(NewRootVC.m:1450-1492) + let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract) + // showmessage:任意子树非空就用(最后写赢) + let msg = gameExtract.showmessage ?? agentExtract.showmessage ?? config.showmessage + + return Resolved( + appVersion: appVer, + appDownload: appDL, + gameVersion: gameVer, + gameZip: gameZip, + showmessage: msg + ) } + + /// agent 子树 4 层提取(getagentversion,NewRootVC.m:885-1013) + /// agent → channel → market → game(game 嵌在 market 命中后才遍历) + /// 注意:agent 子树用 game_download 字段名,game 子树用 game_zip——两个都要查 + private static func extractAgentSubtree(...) -> SubtreeResult { ... } + + /// game 子树 4 层提取(getgameversion,NewRootVC.m:1015-1180) + /// game → game-self(gamedata 自身再嵌 channellist)→ channel → market + private static func extractGameSubtree(...) -> SubtreeResult { ... } + + /// IPA 合并:默认 agent 赢;game.appVersion > agent.appVersion 时 game 赢 + private static func mergeIPA(...) -> (Int, String?) { ... } + + /// zip 合并:默认 game 赢;agent.gameVersion > game.gameVersion 时 agent 赢 + private static func mergeZip(...) -> (Int, String?) { ... } } ``` +**关键测试用例**(单测必须覆盖): + +| 用例 | 验证 | +|------|------| +| 顶层 / agent / channel / market / game 都有 app_version | agent 子树最深层赢;与 game 子树合并时按版本号大者 | +| 仅 agent 子树有,game 子树无 app_version | 取 agent | +| 仅 game 子树有,agent 子树无 app_version | 取 game | +| game.appVersion = 10 vs agent.appVersion = 5 | game 赢 | +| game.appVersion = 5 vs agent.appVersion = 10 | agent 赢 | +| game.gameVersion = 10 vs agent.gameVersion = 5 | game 赢(默认) | +| game.gameVersion = 5 vs agent.gameVersion = 10 | agent 赢 | +| showmessage 仅顶层有 | 取顶层 | +| showmessage agent 层有 + 顶层有 | 取 agent(深层覆盖浅层) | +| showmessage market 层有 + 全部层都有 | 取 market(最深) | +| agentid 找不到匹配 | 返回 default(0/nil),不抛 | +| gameid 找不到匹配 | 返回 default,不抛 | +| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 | + #### 6.3.3 LocalVersionReader ```swift diff --git a/ylgamehall/RootViewController.swift b/ylgamehall/RootViewController.swift index decc469..2383d98 100644 --- a/ylgamehall/RootViewController.swift +++ b/ylgamehall/RootViewController.swift @@ -72,6 +72,40 @@ final class RootViewController: UIViewController { } else { print("[WVJB.js] ERROR: 找不到 Bundle 内 JS/WebViewJavascriptBridge.js") } + + // Phase 1.10 烟雾测试:拉远端渠道配置 .txt(含 cache-busting + 短文本响应识别) + Task { + let start = Date() + do { + let outcome = try await RemoteConfigClient.shared.fetch() + let elapsed = Date().timeIntervalSince(start) + switch outcome { + case .parsed(let cfg): + print(""" + [RemoteConfigClient] 拉取成功 .parsed(耗时 \(String(format: "%.3f", elapsed))s) + showmessage = \(cfg.showmessage ?? "") + agentlist count = \(cfg.agentlist?.count ?? 0) + """) + if let first = cfg.agentlist?.first { + print(""" + agent[0].agentid = \(first.agentid ?? "") + agent[0].appVersion = \(first.appVersion ?? "") + agent[0].gameVersion = \(first.gameVersion ?? "") + agent[0].channellist = \(first.channellist?.count ?? 0) 个 + agent[0].gamelist = \(first.gamelist?.count ?? 0) 个 + """) + } + case .shortText(let msg): + print(""" + [RemoteConfigClient] 拉到短文本响应(耗时 \(String(format: "%.3f", elapsed))s) + 运营杀手锏:应当弹窗显示并停止重试 + 内容 = "\(msg)" + """) + } + } catch { + print("[RemoteConfigClient] ERROR: \(error)") + } + } } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { diff --git a/ylgamehall/Source/Network/RemoteConfigClient.swift b/ylgamehall/Source/Network/RemoteConfigClient.swift new file mode 100644 index 0000000..f50ed74 --- /dev/null +++ b/ylgamehall/Source/Network/RemoteConfigClient.swift @@ -0,0 +1,278 @@ +// +// RemoteConfigClient.swift +// ylgamehall +// +// 从 BundleConfig.gameConfig 拼出的远端 .txt(实为 JSON)拉取渠道升级配置。 +// 详见 docs/H5-Native-Implementation-Design.md §6.3.1 / Plan ADR-008。 +// + +import Foundation + +// MARK: - Codable 模型(三级嵌套:agent → channel → market;agent 子树另有 gamelist 副路径) + +nonisolated public struct RemoteConfig: Codable, Sendable { + public let showmessage: String? + public let agentlist: [Agent]? +} + +nonisolated public struct Agent: Codable, Sendable { + public let agentid: String? + public let showmessage: String? + public let appVersion: String? + public let appDownload: String? + public let gameVersion: String? + public let gameZip: String? + public let channellist: [Channel]? + public let gamelist: [Game]? + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + agentid = try c.decodeFlexibleStringIfPresent(forKey: .agentid) + showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) + appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion) + appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) + gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) + gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) + channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist) + gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist) + } + + private enum CodingKeys: String, CodingKey { + case agentid, showmessage, channellist, gamelist + case appVersion, appDownload, gameVersion, gameZip + } +} + +nonisolated public struct Channel: Codable, Sendable { + public let channelid: String? + public let showmessage: String? + public let appVersion: String? + public let appDownload: String? + public let gameVersion: String? + public let gameZip: String? + public let marketlist: [Market]? + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + channelid = try c.decodeFlexibleStringIfPresent(forKey: .channelid) + showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) + appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion) + appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) + gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) + gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) + marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist) + } + + private enum CodingKeys: String, CodingKey { + case channelid, showmessage, marketlist + case appVersion, appDownload, gameVersion, gameZip + } +} + +nonisolated public struct Market: Codable, Sendable { + public let marketid: String? + public let showmessage: String? + public let appVersion: String? + public let appDownload: String? + public let gameVersion: String? + public let gameZip: String? + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + marketid = try c.decodeFlexibleStringIfPresent(forKey: .marketid) + showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) + appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion) + appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) + gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) + gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) + } + + private enum CodingKeys: String, CodingKey { + case marketid, showmessage + case appVersion, appDownload, gameVersion, gameZip + } +} + +nonisolated public struct Game: Codable, Sendable { + public let gameid: String? + public let showmessage: String? + public let appVersion: String? + public let appDownload: String? + public let gameVersion: String? + public let gameZip: String? + public let channellist: [Channel]? + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + gameid = try c.decodeFlexibleStringIfPresent(forKey: .gameid) + showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) + appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion) + appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) + gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) + gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) + channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist) + } + + private enum CodingKeys: String, CodingKey { + case gameid, showmessage, channellist + case appVersion, appDownload, gameVersion, gameZip + } +} + +/// `KeyedDecodingContainer` 扩展:宽松解 String —— 服务端历史包袱让 app_version/game_version +/// 等字段可能以 String / Int / Double 任一形式回,msext 用 NSString 自动吞了;我们 Codable +/// 严格校验下需要自己兼容。Bool 兜底是防御性的(极不可能但 0 成本)。 +nonisolated extension KeyedDecodingContainer { + func decodeFlexibleStringIfPresent(forKey key: Key) throws -> String? { + // key 不存在直接返回 nil(避免 decodeNil(forKey:) 在 key 缺失时 throw keyNotFound) + guard contains(key) else { return nil } + if (try? decodeNil(forKey: key)) == true { return nil } + if let s = try? decode(String.self, forKey: key) { return s } + if let i = try? decode(Int.self, forKey: key) { return String(i) } + if let d = try? decode(Double.self, forKey: key) { + // 整数值的 Double 去掉末尾 .0(如 "43.0" → "43") + return d.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(d)) + : String(d) + } + if let b = try? decode(Bool.self, forKey: key) { return String(b) } + return nil + } +} + +// MARK: - 拉取结果 + +/// 远端配置拉取的两种成功 outcome(msext `NewRootVC.m:1239-1244` 契约)。 +public enum RemoteConfigOutcome: Sendable { + /// 正常长文本响应,JSON 反序列化成功 + case parsed(RemoteConfig) + /// 短文本响应(trim 后 utf16.count ≤ 100)—— 服务端运营杀手锏 #1: + /// 配置异常时返回一段错误文本,客户端当弹窗内容直接显示并停止重试。 + /// 详见 ADR-008-B / msext NewRootVC.m:1239-1243。 + case shortText(String) +} + +// MARK: - 错误 + +public enum RemoteConfigError: Error, Sendable { + /// `BundleConfig.gameConfig` 为空,无法构造 URL + case urlConstructionFailed + /// HTTP 非 2xx + case httpStatus(Int) + /// JSON 反序列化失败 + case decodingFailed(any Error) + /// 全部重试用尽 + case allRetriesFailed(underlying: any Error) +} + +// MARK: - 客户端 + +/// 远端渠道配置拉取器。actor 隔离,URLSession async + 指数退避重试。 +public actor RemoteConfigClient { + + public static let shared = RemoteConfigClient() + + private let session: URLSession + private let urlBuilder: @Sendable () -> URL? + private let maxRetries: Int + + public init( + session: URLSession = RemoteConfigClient.defaultSession, + urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder, + maxRetries: Int = 3 + ) { + self.session = session + self.urlBuilder = urlBuilder + self.maxRetries = maxRetries + } + + /// 默认 URLSession:每次请求 10 s 超时。 + nonisolated public static var defaultSession: URLSession { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 10 + cfg.timeoutIntervalForResource = 30 + return URLSession(configuration: cfg) + } + + /// 默认 URL 构造:`https://` + `BundleConfig.gameConfig.replacingOccurrences("-", "/")` + `.txt`。 + /// 当前 demo 渠道注入值 → https://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt + /// + /// 与 msext `NewRootVC.m:250` 用 http 的历史相比,本项目后台已切 HTTPS, + /// 故无需 NSAllowsArbitraryLoads 等 ATS 例外。不带 SERVERNew 前缀的拼装规则保留。 + nonisolated public static let defaultURLBuilder: @Sendable () -> URL? = { + let gameConfig = BundleConfig.shared.gameConfig + guard !gameConfig.isEmpty else { return nil } + let path = gameConfig.replacingOccurrences(of: "-", with: "/") + return URL(string: "https://\(path).txt") + } + + /// 拉远端配置。指数退避 1/2/4 秒最多 maxRetries 次。 + /// + /// - Returns: `.parsed(RemoteConfig)` 长文本 JSON 成功,或 `.shortText(String)` + /// 服务端运营截短响应(trim 后 utf16.count ≤ 100,msext 契约) + public func fetch() async throws -> RemoteConfigOutcome { + guard let baseURL = urlBuilder() else { + throw RemoteConfigError.urlConstructionFailed + } + + var lastError: any Error = RemoteConfigError.urlConstructionFailed + for attempt in 0.. RemoteConfigOutcome { + let (data, response) = try await session.data(from: url) + if let http = response as? HTTPURLResponse, + !(200..<300).contains(http.statusCode) { + throw RemoteConfigError.httpStatus(http.statusCode) + } + // UTF-8 解码 + trim(msext NewRootVC.m:1237-1238 契约) + guard let raw = String(data: data, encoding: .utf8) else { + throw RemoteConfigError.decodingFailed( + NSError(domain: "RemoteConfigClient", code: -1, + userInfo: [NSLocalizedDescriptionKey: "response is not UTF-8"]) + ) + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // 短文本响应(≤100 utf16 unit):服务端运营截短响应,当 alert 文本用 + // msext NewRootVC.m:1239-1243 用 NSString length(即 UTF-16 unit count),新外壳用 utf16.count 1:1 + if trimmed.utf16.count <= 100 { + return .shortText(trimmed) + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + let parsed = try decoder.decode(RemoteConfig.self, from: data) + return .parsed(parsed) + } catch { + throw RemoteConfigError.decodingFailed(error) + } + } + + /// 给 URL 追加 cache-busting query `?vXXXXXXXXYYYYYYYY`(msext NewRootVC.m:1226)。 + /// 注意:不是 `?v=XXXX` 标准 query 而是裸 `?v<16hex>`,照搬契约。 + nonisolated private func appendCacheBuster(to url: URL) -> URL { + let token = String(format: "%08X%08X", + UInt32.random(in: 0...UInt32.max), + UInt32.random(in: 0...UInt32.max)) + // URLComponents 会把 query 编码,这里手动拼字符串 + let absolute = url.absoluteString + let separator = absolute.contains("?") ? "&" : "?" + return URL(string: "\(absolute)\(separator)v\(token)") ?? url + } +}