fallback 链头嵌入根(RemoteConfig 顶层):所有 7 字段真正统一,删字段级特殊兜底

【动机】
上一版"统一接口"虽然合并了 resolve / resolveAudio,但 resolve 内部仍有不对称:
- showmessage / audioDomain / audioBucket: pickString(chain) ?? config.xxx
- appVersion / appDownload / gameVersion / gameZip: 只 pickInt/String(chain),
  顶层声明这 4 个字段也读不到

【方案】
- RemoteConfig 加 4 个 String? 字段(appVersion/appDownload/gameVersion/gameZip)
  配齐与节点 struct 同款 7 字段集合
- extension RemoteConfig: RemoteConfigNode(自然 conform)
- buildChain 首行 var chain = [config]:根始终作为最浅一层
- resolve() 去掉 ?? config.xxx 三处特殊兜底,全 7 字段一律 pickString/Int(chain)

【效果】
- 节点链 1..5 长(含根);所有字段一视同仁,pickString 倒序找即天然包含顶层
- 任何字段都可以在顶层 / agent / game / channel / market 任一层声明,最深层赢
- 算法对称、代码无字段级特殊分支

【硬约束(Plan ADR-009 同步)】
新增字段:加进协议 + 5 个 struct(含 RemoteConfig)+ ResolvedVersion +
resolve() 内一行 pickString/Int(chain, \.xxx);不允许 ?? config.xxx 类回退。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-27 21:38:03 +08:00
co-authored by Claude Opus 4.7
parent 485f0c6679
commit 692c849e0d
3 changed files with 47 additions and 30 deletions
+2 -1
View File
@@ -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 层 fallbackagent → game → channel → market 倒序找第一个非空,整链空时 fallback 到 RemoteConfig 顶层。不允许新增字段时为它单独写 if 链或 helper —— 加进 `RemoteConfigNode` 协议 + 4节点 struct + `ResolvedVersion` + `resolve()` 内一行 `pickString(chain, \.xxx) ?? config.xxx`
- **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 5 层 fallbackroot(顶层)→ 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
@@ -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
}
}
+30 -22
View File
@@ -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
// rootRemoteConfig
// 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 }