diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 4599bcb..9b2a71e 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -1260,10 +1260,11 @@ ResolvedVersion( | 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 | | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`(4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 | - **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolve(...)` 一次得到所有字段(含 `audioDomain` / `audioBucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停 -- **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 4 层 fallback:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 到 RemoteConfig 顶层。不允许新增字段时为它单独写 if 链或 helper —— 加进 `RemoteConfigNode` 协议 + 4 个节点 struct + `ResolvedVersion` + `resolve()` 内一行 `pickString(chain, \.xxx) ?? config.xxx` +- **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 5 层 fallback:root(顶层)→ agent → game → channel → market,倒序找第一个非空。`RemoteConfig` 自身 conform `RemoteConfigNode` 协议作为链最浅一层兜底,因此 `pickString(chain, \.xxx)` 一行即包含顶层兜底,无需 `?? config.xxx` 这类特殊写法。新增字段:加进 `RemoteConfigNode` 协议 + 5 个 struct(含 `RemoteConfig`)+ `ResolvedVersion` + `resolve()` 内一行 `pick*` - **2026-06-27 修订**: - 初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等字段算法)。 - 同日二次:曾短暂拆 `resolve()` / `resolveAudio()` 两个公开入口,违反"单一接口"原则。已合并为单一 `resolve()`,所有字段(版本 + 音频 + showmessage)一次返回 + - 同日三次:把 `RemoteConfig` 顶层也作为节点参与 fallback 链(链头 = 根),删掉 `showmessage / audioDomain / audioBucket` 三处特殊的 `?? config.xxx` 兜底;`appVersion / appDownload / gameVersion / gameZip` 也获得顶层兜底能力。所有字段真正完全统一 - **类型设计**: - `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`,3 个不可变 String 属性 - `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async diff --git a/ylgamehall/Source/Network/RemoteConfigClient.swift b/ylgamehall/Source/Network/RemoteConfigClient.swift index 69875ed..51607ff 100644 --- a/ylgamehall/Source/Network/RemoteConfigClient.swift +++ b/ylgamehall/Source/Network/RemoteConfigClient.swift @@ -10,19 +10,25 @@ import Foundation // MARK: - Codable 模型(单链 4 层嵌套:agent → game → channel → market) +/// 远端配置顶层。所有字段(appVersion / appDownload / gameVersion / gameZip / +/// showmessage / audioDomain / audioBucket)都可在顶层声明 ——作为节点链最浅一层 +/// 参与 4 层 fallback 兜底,与 Agent/Game/Channel/Market 同款语义。 nonisolated public struct RemoteConfig: Codable, Sendable { + public let appVersion: String? + public let appDownload: String? + public let gameVersion: String? + public let gameZip: String? public let showmessage: String? - /// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。 - /// 顶层值作为整链最浅层 fallback;与版本字段同款 4 层 fallback: - /// agent → game → channel → market 任一层均可声明,最深层赢;都不写则用顶层。 - /// 启动期由 VersionResolver.resolveAudio(...) 解析,缺失抛 BootError.audioConfigMissing。 public let audioDomain: String? - /// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain,同款 4 层 fallback。 public let audioBucket: String? public let agentlist: [Agent]? public init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion) + appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload) + gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion) + gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip) showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain) audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket) @@ -31,8 +37,10 @@ nonisolated public struct RemoteConfig: Codable, Sendable { private enum CodingKeys: String, CodingKey { // 驼峰 case 名(不带 rawValue),依赖 JSONDecoder.convertFromSnakeCase 自动把 - // JSON 的 audio_domain / audio_bucket 归一化到 audioDomain / audioBucket - case showmessage, agentlist, audioDomain, audioBucket + // JSON 的 app_version / audio_domain 等归一化为 appVersion / audioDomain + case showmessage, agentlist + case appVersion, appDownload, gameVersion, gameZip + case audioDomain, audioBucket } } diff --git a/ylgamehall/Source/Network/VersionResolver.swift b/ylgamehall/Source/Network/VersionResolver.swift index 9d55a14..be405db 100644 --- a/ylgamehall/Source/Network/VersionResolver.swift +++ b/ylgamehall/Source/Network/VersionResolver.swift @@ -2,17 +2,20 @@ // VersionResolver.swift // ylgamehall // -// 把远端 RemoteConfig 化简为 5 个目标字段(appVersion/appDownload/gameVersion/ -// gameZip/showmessage)的纯函数实现。 +// 把远端 RemoteConfig 化简为 7 个目标字段(appVersion / appDownload / +// gameVersion / gameZip / showmessage / audioDomain / audioBucket)的纯函数实现。 // -// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径): -// agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid] +// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径,扩展为 5 层 fallback): +// root(RemoteConfig 顶层) +// → agentlist[agentid] → gamelist[gameid] +// → channellist[channelid] → marketlist[marketid] // -// 查找语义:从最深层(market)向上回退取第一个非空值,等价于 -// msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、可复用。 +// 查找语义:从最深层(market)向上回退取第一个非空值,"根"(顶层)作为最浅一层 +// 自动包含在链中 —— 等价于 msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、 +// 可复用,且天然支持顶层声明字段(任何字段都能放顶层兜底)。 // -// 统一接口:所有 5 个字段都用同一套 `pickString` / `pickInt` 倒序查找, -// 不再有"双子树合并 / chulishengji"等历史包袱的多处实现。 +// 统一接口:所有 7 个字段都用同一套 `pickString` / `pickInt` 倒序查找, +// 无任何字段级特殊兜底逻辑(如 `?? config.xxx`),所有字段平等。 // import Foundation @@ -55,6 +58,9 @@ extension Agent: RemoteConfigNode {} extension Game: RemoteConfigNode {} extension Channel: RemoteConfigNode {} extension Market: RemoteConfigNode {} +// RemoteConfig 顶层也作为节点参与 fallback 链,处于最浅一层("根")。 +// 这样所有字段的 fallback 完全统一:节点链倒序找第一个非空 → 链上即包含根。 +extension RemoteConfig: RemoteConfigNode {} // MARK: - VersionResolver @@ -81,28 +87,30 @@ public enum VersionResolver { marketId: marketId ) + // 所有 7 字段统一走"chain 倒序找第一个非空",无任何字段级特殊兜底。 + // chain 头部就是 RemoteConfig 顶层("根"),最深层赢、根作为最浅层兜底。 return ResolvedVersion( - appVersion: pickInt(chain, \.appVersion), + appVersion: pickInt(chain, \.appVersion), appDownload: pickString(chain, \.appDownload), - gameVersion: pickInt(chain, \.gameVersion), - gameZip: pickString(chain, \.gameZip), - // showmessage / audioDomain / audioBucket:链上任一层都不写 → 回退顶层 config - showmessage: pickString(chain, \.showmessage) ?? config.showmessage, - audioDomain: pickString(chain, \.audioDomain) ?? config.audioDomain, - audioBucket: pickString(chain, \.audioBucket) ?? config.audioBucket + gameVersion: pickInt(chain, \.gameVersion), + gameZip: pickString(chain, \.gameZip), + showmessage: pickString(chain, \.showmessage), + audioDomain: pickString(chain, \.audioDomain), + audioBucket: pickString(chain, \.audioBucket) ) } // MARK: - 节点链匹配 - /// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。 + /// 沿 root → agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。 + /// 链头始终是 RemoteConfig 顶层("根"),作为最浅一层兜底。 /// - /// 返回数组从根到叶有序,长度 0..4: - /// - `[]` :agent 未匹配 - /// - `[agent]` :agent 命中、game 未匹配 - /// - `[agent, game]` :再深一层未命中 + /// 返回数组从根到叶有序,长度 1..5: + /// - `[root]` :agent 未匹配(链最短为根) + /// - `[root, agent]` :agent 命中、game 未匹配 + /// - `[root, agent, game]` :再深一层未命中 /// - ... - /// - `[agent, game, channel, market]`:全链命中 + /// - `[root, agent, game, channel, market]`:全链命中 private static func buildChain( config: RemoteConfig, agentId: String, @@ -110,7 +118,7 @@ public enum VersionResolver { channelId: String, marketId: String ) -> [RemoteConfigNode] { - var chain: [RemoteConfigNode] = [] + var chain: [RemoteConfigNode] = [config] guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) else { return chain }