Files
youle_app_ios_v2/ylgamehall/Source/Network/RemoteConfigClient.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

329 lines
15 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.
//
// RemoteConfigClient.swift
// ylgamehall
//
// 从 BundleConfig.gameConfig 拼出的远端 .txt(实为 JSON)拉取渠道升级配置。
// 详见 docs/H5-Native-Implementation-Design.md §6.3.1 / Plan ADR-008。
//
import Foundation
// MARK: - Codable 模型(单链 4 层嵌套:agent → game → channel → market
nonisolated public struct RemoteConfig: Codable, Sendable {
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)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
agentlist = try c.decodeIfPresent([Agent].self, forKey: .agentlist)
}
private enum CodingKeys: String, CodingKey {
// 驼峰 case 名(不带 rawValue),依赖 JSONDecoder.convertFromSnakeCase 自动把
// JSON 的 audio_domain / audio_bucket 归一化到 audioDomain / audioBucket
case showmessage, agentlist, audioDomain, audioBucket
}
}
nonisolated public struct Agent: Codable, Sendable {
public let agentid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let audioDomain: String?
public let audioBucket: String?
public let gamelist: [Game]?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
agentid = try c.decodeFlexibleStringIfPresent(forKey: .agentid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
}
private enum CodingKeys: String, CodingKey {
case agentid, showmessage, gamelist
case appVersion, appDownload, gameVersion, gameZip
case audioDomain, audioBucket
}
}
nonisolated public struct Channel: Codable, Sendable {
public let channelid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let audioDomain: String?
public let audioBucket: String?
public let marketlist: [Market]?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
channelid = try c.decodeFlexibleStringIfPresent(forKey: .channelid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist)
}
private enum CodingKeys: String, CodingKey {
case channelid, showmessage, marketlist
case appVersion, appDownload, gameVersion, gameZip
case audioDomain, audioBucket
}
}
nonisolated public struct Market: Codable, Sendable {
public let marketid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let audioDomain: String?
public let audioBucket: String?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
marketid = try c.decodeFlexibleStringIfPresent(forKey: .marketid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
}
private enum CodingKeys: String, CodingKey {
case marketid, showmessage
case appVersion, appDownload, gameVersion, gameZip
case audioDomain, audioBucket
}
}
nonisolated public struct Game: Codable, Sendable {
public let gameid: String?
public let showmessage: String?
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let audioDomain: String?
public let audioBucket: String?
public let channellist: [Channel]?
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
gameid = try c.decodeFlexibleStringIfPresent(forKey: .gameid)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
}
private enum CodingKeys: String, CodingKey {
case gameid, showmessage, channellist
case appVersion, appDownload, gameVersion, gameZip
case audioDomain, audioBucket
}
}
/// `KeyedDecodingContainer` 扩展:宽松解 String —— 服务端历史包袱让 app_version/game_version
/// 等字段可能以 String / Int / Double 任一形式回,msext 用 NSString 自动吞了;我们 Codable
/// 严格校验下需要自己兼容。Bool 兜底是防御性的(极不可能但 0 成本)。
nonisolated extension KeyedDecodingContainer {
func decodeFlexibleStringIfPresent(forKey key: Key) throws -> String? {
// key 不存在直接返回 nil(避免 decodeNil(forKey:) 在 key 缺失时 throw keyNotFound
guard contains(key) else { return nil }
if (try? decodeNil(forKey: key)) == true { return nil }
if let s = try? decode(String.self, forKey: key) { return s }
if let i = try? decode(Int.self, forKey: key) { return String(i) }
if let d = try? decode(Double.self, forKey: key) {
// 整数值的 Double 去掉末尾 .0(如 "43.0" → "43"
return d.truncatingRemainder(dividingBy: 1) == 0
? String(Int(d))
: String(d)
}
if let b = try? decode(Bool.self, forKey: key) { return String(b) }
return nil
}
}
// MARK: - 拉取结果
/// 远端配置拉取的两种成功 outcomemsext `NewRootVC.m:1239-1244` 契约)。
public enum RemoteConfigOutcome: Sendable {
/// 正常长文本响应,JSON 反序列化成功
case parsed(RemoteConfig)
/// 短文本响应(trim 后 utf16.count ≤ 100)—— 服务端运营杀手锏 #1:
/// 配置异常时返回一段错误文本,客户端当弹窗内容直接显示并停止重试。
/// 详见 ADR-008-B / msext NewRootVC.m:1239-1243。
case shortText(String)
}
// MARK: - 错误
public enum RemoteConfigError: Error, Sendable {
/// `BundleConfig.gameConfig` 为空,无法构造 URL
case urlConstructionFailed
/// HTTP 非 2xx
case httpStatus(Int)
/// JSON 反序列化失败
case decodingFailed(any Error)
/// 全部重试用尽
case allRetriesFailed(underlying: any Error)
}
// MARK: - 客户端
/// 远端渠道配置拉取器。actor 隔离,URLSession async + 指数退避重试。
public actor RemoteConfigClient {
public static let shared = RemoteConfigClient()
private let session: URLSession
private let urlBuilder: @Sendable () -> URL?
private let maxRetries: Int
/// 最近一次 `.parsed(...)` 成功的配置。子游戏跳转链路(SubGameViewController)需要按
/// H5 传的 gameid 重新跑一次 VersionResolver 拿真实 zip URL —— 没缓存就要再发请求。
/// `.shortText` 与失败路径不更新此值,保持上一份"已知好"配置可用。
private var lastParsed: RemoteConfig?
public init(
session: URLSession = RemoteConfigClient.defaultSession,
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
maxRetries: Int = 3
) {
self.session = session
self.urlBuilder = urlBuilder
self.maxRetries = maxRetries
}
/// 启动期 fetch 成功后保留的 RemoteConfig;nil 表示从未拉到过。
public func current() -> RemoteConfig? {
lastParsed
}
/// 默认 URLSession:每次请求 10 s 超时。
nonisolated public static var defaultSession: URLSession {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 10
cfg.timeoutIntervalForResource = 30
return URLSession(configuration: cfg)
}
/// 默认 URL 构造:`https://` + `BundleConfig.gameConfig.replacingOccurrences("-", "/")` + `.txt`。
/// 当前 demo 渠道注入值 → https://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt
///
/// 与 msext `NewRootVC.m:250` 用 http 的历史相比,本项目后台已切 HTTPS,
/// 故无需 NSAllowsArbitraryLoads 等 ATS 例外。不带 SERVERNew 前缀的拼装规则保留。
nonisolated public static let defaultURLBuilder: @Sendable () -> URL? = {
let gameConfig = BundleConfig.shared.gameConfig
guard !gameConfig.isEmpty else { return nil }
let path = gameConfig.replacingOccurrences(of: "-", with: "/")
return URL(string: "https://\(path).txt")
}
/// 拉远端配置。指数退避 1/2/4 秒最多 maxRetries 次。
///
/// - Returns: `.parsed(RemoteConfig)` 长文本 JSON 成功,或 `.shortText(String)`
/// 服务端运营截短响应(trim 后 utf16.count ≤ 100msext 契约)
public func fetch() async throws -> RemoteConfigOutcome {
guard let baseURL = urlBuilder() else {
throw RemoteConfigError.urlConstructionFailed
}
var lastError: any Error = RemoteConfigError.urlConstructionFailed
for attempt in 0..<maxRetries {
do {
// 每次重试都生成新 cache-busting query,避免 CDN / Proxy 复用旧响应
// 格式:?vXXXXXXXXYYYYYYYY(两段 8 位 hex,无 `=`msext NewRootVC.m:1226 契约)
let url = appendCacheBuster(to: baseURL)
return try await fetchOnce(url: url)
} catch {
lastError = error
if attempt < maxRetries - 1 {
let delaySeconds = pow(2.0, Double(attempt))
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
}
}
}
throw RemoteConfigError.allRetriesFailed(underlying: lastError)
}
private func fetchOnce(url: URL) async throws -> RemoteConfigOutcome {
let (data, response) = try await session.data(from: url)
if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
throw RemoteConfigError.httpStatus(http.statusCode)
}
// UTF-8 解码 + trimmsext NewRootVC.m:1237-1238 契约)
guard let raw = String(data: data, encoding: .utf8) else {
throw RemoteConfigError.decodingFailed(
NSError(domain: "RemoteConfigClient", code: -1,
userInfo: [NSLocalizedDescriptionKey: "response is not UTF-8"])
)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// 短文本响应(≤100 utf16 unit):服务端运营截短响应,当 alert 文本用
// msext NewRootVC.m:1239-1243 用 NSString length(即 UTF-16 unit count),新外壳用 utf16.count 1:1
if trimmed.utf16.count <= 100 {
return .shortText(trimmed)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let parsed = try decoder.decode(RemoteConfig.self, from: data)
lastParsed = parsed
return .parsed(parsed)
} catch {
throw RemoteConfigError.decodingFailed(error)
}
}
/// 给 URL 追加 cache-busting query `?vXXXXXXXXYYYYYYYY`msext NewRootVC.m:1226)。
/// 注意:不是 `?v=XXXX` 标准 query 而是裸 `?v<16hex>`,照搬契约。
nonisolated private func appendCacheBuster(to url: URL) -> URL {
let token = String(format: "%08X%08X",
UInt32.random(in: 0...UInt32.max),
UInt32.random(in: 0...UInt32.max))
// URLComponents 会把 query 编码,这里手动拼字符串
let absolute = url.absoluteString
let separator = absolute.contains("?") ? "&" : "?"
return URL(string: "\(absolute)\(separator)v\(token)") ?? url
}
}