Files
youle_app_ios_v2/ylgamehall/Source/Network/VersionResolver.swift
T
joywayerandClaude Opus 4.7 a54d1511be audio_domain/audio_bucket 改 4 层 fallback + 顶层兜底,修"音频服务不可用"误报
【bug】
ADR-009 初版只从 RemoteConfig 顶层读 audio_domain / audio_bucket,但渠道方
配置时习惯把这俩字段放在 agent 节点(与 app_version / game_zip 等版本字段同
款层级),导致客户端读到空值后误报 BootError.audioConfigMissing 启动期致命。

【方案】
对齐版本字段的 4 层 fallback 算法:
- Agent / Game / Channel / Market 4 个节点 struct 各加 audioDomain / audioBucket
- RemoteConfigNode 协议加这俩 getter
- VersionResolver 新增 resolveAudio(...) → (domain:String?, bucket:String?)
  逻辑:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 顶层
- WebContainerViewController parsed 分支改用 resolveAudio + print 诊断日志

【兼容性】
顶层 audio_domain / audio_bucket 仍然支持(作为整链兜底),后台不需要改配置
位置;放节点上也能读到——任意一种 layout 都工作。

【文档】
Plan ADR-009 决策表 + 注入时序段 同步更新 + 加 2026-06-27 修订说明记录此次踩坑。

BuildProject 通过,Xcode 诊断 clean。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 21:30:10 +08:00

181 lines
6.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// VersionResolver.swift
// ylgamehall
//
// 把远端 RemoteConfig 化简为 5 个目标字段(appVersion/appDownload/gameVersion/
// gameZip/showmessage)的纯函数实现。
//
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径):
// agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]
//
// 查找语义:从最深层(market)向上回退取第一个非空值,等价于
// msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、可复用。
//
// 统一接口:所有 5 个字段都用同一套 `pickString` / `pickInt` 倒序查找,
// 不再有"双子树合并 / chulishengji"等历史包袱的多处实现。
//
import Foundation
// MARK: - 决策输出
/// 4 层 fallback 解析后的 5 个目标字段。
public struct ResolvedVersion: Sendable, Equatable {
/// 远端期望的原生 IPA 版本(与 BundleConfig.appVersion 比较)。0 表示远端未指定。
public let appVersion: Int
/// IPA 升级跳转的 Safari URL。nil 表示远端未指定。
public let appDownload: String?
/// 远端期望的 H5 zip 版本(与本地 version.xml /game/version@value 比较)。
public let gameVersion: Int
/// H5 zip 下载 URL。nil 表示远端未指定。
public let gameZip: String?
/// 运营公告。非空时阻塞后续升级判断(msext 杀手锏 #2)。
public let showmessage: String?
}
// MARK: - 节点协议(4 个层级共用同一查找接口)
/// `Agent` / `Game` / `Channel` / `Market` 都暴露相同字段集合,
/// 由 VersionResolver 用同一套倒序 fallback 算法统一处理。
private protocol RemoteConfigNode {
var appVersion: String? { get }
var appDownload: String? { get }
var gameVersion: String? { get }
var gameZip: String? { get }
var showmessage: String? { get }
var audioDomain: String? { get }
var audioBucket: String? { get }
}
extension Agent: RemoteConfigNode {}
extension Game: RemoteConfigNode {}
extension Channel: RemoteConfigNode {}
extension Market: RemoteConfigNode {}
// MARK: - VersionResolver
public enum VersionResolver {
/// 4 层 fallback 解析。
///
/// - Parameters:
/// - config: 远端 RemoteConfig
/// - agentId/channelId/marketId/gameId: 当前渠道注入值(BundleConfig
/// - Returns: 5 字段决策结果。任一字段缺失返回 0 / nil。
public static func resolve(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> ResolvedVersion {
let chain = buildChain(
config: config,
agentId: agentId,
gameId: gameId,
channelId: channelId,
marketId: marketId
)
return ResolvedVersion(
appVersion: pickInt(chain, \.appVersion),
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
)
}
// MARK: - 节点链匹配
/// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
///
/// 返回数组从根到叶有序,长度 0..4:
/// - `[]` agent 未匹配
/// - `[agent]` agent 命中、game 未匹配
/// - `[agent, game]` :再深一层未命中
/// - ...
/// - `[agent, game, channel, market]`:全链命中
private static func buildChain(
config: RemoteConfig,
agentId: String,
gameId: String,
channelId: String,
marketId: String
) -> [RemoteConfigNode] {
var chain: [RemoteConfigNode] = []
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
else { return chain }
chain.append(agent)
guard let game = agent.gamelist?.first(where: { $0.gameid == gameId })
else { return chain }
chain.append(game)
guard let channel = game.channellist?.first(where: { $0.channelid == channelId })
else { return chain }
chain.append(channel)
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
else { return chain }
chain.append(market)
return chain
}
// MARK: - 字段倒序 fallback(最深层赢)
/// 从最深层(market)向根(agent)回退,取第一个非空字符串。
private static func pickString(
_ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> String? {
for node in chain.reversed() {
if let s = node[keyPath: keyPath], !s.isEmpty { return s }
}
return nil
}
/// 从最深层向根回退,取第一个能解析为非零整数的字段值。
/// 字段在 JSON 里可能是 string / int / double 之一(详见 `decodeFlexibleStringIfPresent`),
/// 解析失败 / 为 0 视同未提供继续向上找。
private static func pickInt(
_ chain: [RemoteConfigNode],
_ keyPath: KeyPath<RemoteConfigNode, String?>
) -> Int {
for node in chain.reversed() {
if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v }
}
return 0
}
}