合并 resolveAudio 进 resolve():所有 RemoteConfig 字段走单一入口 + 同款 4 层 fallback

【动机】
上个 commit 把 audio_domain/audio_bucket 改成 4 层 fallback,但用了独立的
resolveAudio() 入口 —— 与 resolve() 并存意味着调用方要记两个 API,违反
"统一接口"原则。

【方案】
- ResolvedVersion 加 audioDomain / audioBucket 两个字段(扁平结构)
- resolve() 内直接 pickString(chain, \.audioDomain) ?? config.audioDomain 一次解出
- 删除 resolveAudio() 公开入口
- WebContainerViewController parsed 分支:单次 resolve() 调用 + 同一 r 取所有字段

【硬约束(Plan ADR-009 同步)】
远端 RemoteConfig 所有字段必须走 VersionResolver.resolve(...) 单一入口、
同一套 4 层 fallback;新增字段时加进协议 + 节点 struct + ResolvedVersion +
一行 pickString,不允许为某字段单独写 if 链或 helper。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-27 21:32:57 +08:00
co-authored by Claude Opus 4.7
parent a54d1511be
commit 485f0c6679
3 changed files with 27 additions and 50 deletions
+5 -2
View File
@@ -1259,8 +1259,11 @@ ResolvedVersion(
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 |
- **远端注入时序**`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolveAudio(...)` 解析(与版本字段同款 4 层 fallbackagent → game → channel → market 倒序找第一个非空,整链空时 fallback 到顶层 `audio_domain` / `audio_bucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停
- **2026-06-27 修订**:初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等版本字段的 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`
- **2026-06-27 修订**
- 初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等字段算法)。
- 同日二次:曾短暂拆 `resolve()` / `resolveAudio()` 两个公开入口,违反"单一接口"原则。已合并为单一 `resolve()`,所有字段(版本 + 音频 + showmessage)一次返回
- **类型设计**
- `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`3 个不可变 String 属性
- `QiniuConfig`:从 `enum` 改为 `actor``accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
@@ -19,7 +19,7 @@ import Foundation
// MARK: -
/// 4 fallback 5
/// 4 fallback chain + pickString/Int
public struct ResolvedVersion: Sendable, Equatable {
/// IPA BundleConfig.appVersion 0
public let appVersion: Int
@@ -31,6 +31,10 @@ public struct ResolvedVersion: Sendable, Equatable {
public let gameZip: String?
/// msext #2
public let showmessage: String?
/// CDN http:// 访 URL nil
public let audioDomain: String?
/// bucket putPolicy.scopenil
public let audioBucket: String?
}
// MARK: - 4
@@ -82,35 +86,10 @@ public enum VersionResolver {
appDownload: pickString(chain, \.appDownload),
gameVersion: pickInt(chain, \.gameVersion),
gameZip: pickString(chain, \.gameZip),
// showmessage: 退 config.showmessage
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
)
}
// MARK: - 4 fallback +
/// CDN / bucket 4 fallback
/// agent game channel market `audio_domain` / `audio_bucket`
/// fallback RemoteConfig `audio_domain` / `audio_bucket`
///
/// - Returns: `(domain, bucket)` + nil
public static func resolveAudio(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> (domain: String?, bucket: String?) {
let chain = buildChain(
config: config,
agentId: agentId,
gameId: gameId,
channelId: channelId,
marketId: marketId
)
return (
domain: pickString(chain, \.audioDomain) ?? config.audioDomain,
bucket: pickString(chain, \.audioBucket) ?? config.audioBucket
// showmessage / audioDomain / audioBucket: 退 config
showmessage: pickString(chain, \.showmessage) ?? config.showmessage,
audioDomain: pickString(chain, \.audioDomain) ?? config.audioDomain,
audioBucket: pickString(chain, \.audioBucket) ?? config.audioBucket
)
}
@@ -347,24 +347,9 @@ public final class WebContainerViewController: UIViewController {
case .parsed(let cfg):
let bc = BundleConfig.shared
// audio_domain / audio_bucket 4 fallback
// agent game channel market RemoteConfig
// ,使
let audio = VersionResolver.resolveAudio(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
print("[QiniuConfig] resolved audio domain=\(audio.domain ?? "<nil>") bucket=\(audio.bucket ?? "<nil>")")
guard let audioDomain = audio.domain, !audioDomain.isEmpty,
let audioBucket = audio.bucket, !audioBucket.isEmpty
else {
throw BootError.audioConfigMissing
}
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
// 4 fallback + + showmessage
// VersionResolver.resolve(...) agent game channel market
// fallback RemoteConfig
let r = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
@@ -372,6 +357,16 @@ public final class WebContainerViewController: UIViewController {
marketId: bc.market,
gameId: bc.gameId
)
print("[VersionResolver] resolved appVer=\(r.appVersion) gameVer=\(r.gameVersion) audioDomain=\(r.audioDomain ?? "<nil>") audioBucket=\(r.audioBucket ?? "<nil>")")
// audio_domain / audio_bucket ,
// 使
guard let audioDomain = r.audioDomain, !audioDomain.isEmpty,
let audioBucket = r.audioBucket, !audioBucket.isEmpty
else {
throw BootError.audioConfigMissing
}
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
// #2showmessage
if let msg = r.showmessage, !msg.isEmpty {