【动机】 上一版"统一接口"虽然合并了 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>
168 lines
6.6 KiB
Swift
168 lines
6.6 KiB
Swift
//
|
||
// VersionResolver.swift
|
||
// ylgamehall
|
||
//
|
||
// 把远端 RemoteConfig 化简为 7 个目标字段(appVersion / appDownload /
|
||
// gameVersion / gameZip / showmessage / audioDomain / audioBucket)的纯函数实现。
|
||
//
|
||
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径,扩展为 5 层 fallback):
|
||
// root(RemoteConfig 顶层)
|
||
// → agentlist[agentid] → gamelist[gameid]
|
||
// → channellist[channelid] → marketlist[marketid]
|
||
//
|
||
// 查找语义:从最深层(market)向上回退取第一个非空值,"根"(顶层)作为最浅一层
|
||
// 自动包含在链中 —— 等价于 msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、
|
||
// 可复用,且天然支持顶层声明字段(任何字段都能放顶层兜底)。
|
||
//
|
||
// 统一接口:所有 7 个字段都用同一套 `pickString` / `pickInt` 倒序查找,
|
||
// 无任何字段级特殊兜底逻辑(如 `?? config.xxx`),所有字段平等。
|
||
//
|
||
|
||
import Foundation
|
||
|
||
// MARK: - 决策输出
|
||
|
||
/// 4 层 fallback 解析后的所有目标字段(所有字段统一走同一套 chain + pickString/Int)。
|
||
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?
|
||
/// 七牛 CDN 域名(不带 http:// 前缀),录音上传后拼公开访问 URL 用。nil 表示远端未指定。
|
||
public let audioDomain: String?
|
||
/// 七牛 bucket 名(putPolicy.scope)。nil 表示远端未指定。
|
||
public let audioBucket: 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 {}
|
||
// RemoteConfig 顶层也作为节点参与 fallback 链,处于最浅一层("根")。
|
||
// 这样所有字段的 fallback 完全统一:节点链倒序找第一个非空 → 链上即包含根。
|
||
extension RemoteConfig: 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
|
||
)
|
||
|
||
// 所有 7 字段统一走"chain 倒序找第一个非空",无任何字段级特殊兜底。
|
||
// chain 头部就是 RemoteConfig 顶层("根"),最深层赢、根作为最浅层兜底。
|
||
return ResolvedVersion(
|
||
appVersion: pickInt(chain, \.appVersion),
|
||
appDownload: pickString(chain, \.appDownload),
|
||
gameVersion: pickInt(chain, \.gameVersion),
|
||
gameZip: pickString(chain, \.gameZip),
|
||
showmessage: pickString(chain, \.showmessage),
|
||
audioDomain: pickString(chain, \.audioDomain),
|
||
audioBucket: pickString(chain, \.audioBucket)
|
||
)
|
||
}
|
||
|
||
// MARK: - 节点链匹配
|
||
|
||
/// 沿 root → agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
||
/// 链头始终是 RemoteConfig 顶层("根"),作为最浅一层兜底。
|
||
///
|
||
/// 返回数组从根到叶有序,长度 1..5:
|
||
/// - `[root]` :agent 未匹配(链最短为根)
|
||
/// - `[root, agent]` :agent 命中、game 未匹配
|
||
/// - `[root, agent, game]` :再深一层未命中
|
||
/// - ...
|
||
/// - `[root, agent, game, channel, market]`:全链命中
|
||
private static func buildChain(
|
||
config: RemoteConfig,
|
||
agentId: String,
|
||
gameId: String,
|
||
channelId: String,
|
||
marketId: String
|
||
) -> [RemoteConfigNode] {
|
||
var chain: [RemoteConfigNode] = [config]
|
||
|
||
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
|
||
}
|
||
}
|