Files
youle_app_ios_v2/ylgamehall/Source/Network/VersionResolver.swift
T
joywayerandClaude Opus 4.7 692c849e0d 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>
2026-06-27 21:38:03 +08:00

168 lines
6.6 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 化简为 7 个目标字段(appVersion / appDownload /
// gameVersion / gameZip / showmessage / audioDomain / audioBucket)的纯函数实现。
//
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径,扩展为 5 层 fallback):
// rootRemoteConfig 顶层)
// → 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
}
}