Files
youle_app_ios_v2/ylgamehall/Source/Network/RemoteConfigClient.swift
T
joywayerandClaude Opus 4.7 83d9502dee ADR-009 凭证集中化:AppSecrets.plist + 七牛运行参数走 RemoteConfig.audio_*(契约影响)
【数据流改造(三分制)】
- 微信 AppID  唯一源 = Info.plist CFBundleURLTypes (URLName=weixin first scheme)
- 应用级凭证 唯一源 = AppSecrets.plist (wxAppSecret/qiniuAccessKey/qiniuSecretKey)
- 七牛运行参数 唯一源 = RemoteConfig 顶层 audio_domain / audio_bucket(远端动态注入)

【契约影响】
- ChannelConfig.plist:11 key → 10 key,移除 qiniudomain(ADR-007 守护规则同步)
- BundleConfig:删除 qiniuDomain 属性
- RemoteConfig:顶层新增可选字段 audioDomain / audioBucket(JSON snake_case 自动归一化)
- 启动期:WebContainerViewController parsed 分支校验 audio_domain/audio_bucket 非空,
  缺失抛 BootError.audioConfigMissing 弹 modal 永停(与 showmessage 同等致命)
- 上线前置:测试 / 生产远端 .txt 配置必须先补 audio_domain / audio_bucket 两个顶层 key
- WeChatSDK.appID / WeChatAuth.appSecret / QiniuConfig.* 调用方零签名变化

【新增】
- ylgamehall/Resources/AppSecrets.plist(3 key)
- ylgamehall/Source/Resource/AppSecrets.swift(单例加载,对齐 BundleConfig 模式)
- QiniuConfig 改 actor:cdnDomain/bucketName 进 actor 状态 + update(...) async;
  accessKey/secretKey 仍 nonisolated(直接读 AppSecrets)
- QiniuTokenSigner.uploadToken() 改 async(bucketName 来自 actor)
- QiniuUploader 预取 cdnDomain 闭包外,SDK 同步 callback 内直接拼 URL

【删除】
- WeChatSDK.swift  static let appID  硬编码 → Info.plist 启动期解析
- WeChatAuth.swift static let appSecret 硬编码 → AppSecrets.shared.wxAppSecret
- QiniuConfig 中 accessKey / secretKey / bucketName / cdnDomain 四处硬编码
- ChannelConfig.plist 的 qiniudomain 字段(plist 与代码双源僵尸字段)

【文档同步】
- Plan:新增 ADR-009 + ADR-007 守护规则改 10 key + §236 BundleConfig 描述
- Design §3.4 多处 "11 项" → "10 项";§7.0.3 plist 示例 + BundleConfig 代码骨架
  + Scripts/inject_channel.sh 同步
- SDK-Integration-Guide §0 凭证位置改三分制 + §尾"七牛域名读取"加新外壳路径
- Verification-Checklist L69 "(11 项)" → "(10 项)"

参考契约章节:docs/Development-Plan.md ADR-009、docs/H5-Native-Implementation-Design.md §7.0.3
BuildProject 通过,Xcode 即时诊断 0 警告。

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

308 lines
13 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 用。
/// 跨渠道全局相同,放顶层不进 4 层 fallback。缺失视为后台配置错误,
/// 启动期由 WebContainerViewController 抛 BootError.audioConfigMissing。
public let audioDomain: String?
/// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain。
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 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)
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
}
private enum CodingKeys: String, CodingKey {
case agentid, showmessage, gamelist
case appVersion, appDownload, gameVersion, gameZip
}
}
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 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)
marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist)
}
private enum CodingKeys: String, CodingKey {
case channelid, showmessage, marketlist
case appVersion, appDownload, gameVersion, gameZip
}
}
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 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)
}
private enum CodingKeys: String, CodingKey {
case marketid, showmessage
case appVersion, appDownload, gameVersion, gameZip
}
}
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 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)
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
}
private enum CodingKeys: String, CodingKey {
case gameid, showmessage, channellist
case appVersion, appDownload, gameVersion, gameZip
}
}
/// `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
}
}