diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 4f62f3b..ebce5bf 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -1258,8 +1258,9 @@ ResolvedVersion( |---|---|---| | 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 | | 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 | - | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 `audio_domain` / `audio_bucket` | 后台运维管理,无客户端直接出 ad-hoc 改值的诉求 | -- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)即调 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失 audio_domain / audio_bucket 抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停(理由:缺这俩 → 录音上传必失败,业务不可用,启动期即报远比运行期某次上传时报更友好) + | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`(4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 | +- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolveAudio(...)` 解析(与版本字段同款 4 层 fallback:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 到顶层 `audio_domain` / `audio_bucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停 +- **2026-06-27 修订**:初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等版本字段的 fallback 算法),节点与顶层任一处声明即可 - **类型设计**: - `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`,3 个不可变 String 属性 - `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async diff --git a/ylgamehall/Source/Network/RemoteConfigClient.swift b/ylgamehall/Source/Network/RemoteConfigClient.swift index b6cdf25..69875ed 100644 --- a/ylgamehall/Source/Network/RemoteConfigClient.swift +++ b/ylgamehall/Source/Network/RemoteConfigClient.swift @@ -13,10 +13,11 @@ import Foundation nonisolated public struct RemoteConfig: Codable, Sendable { public let showmessage: String? /// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。 - /// 跨渠道全局相同,放顶层不进 4 层 fallback。缺失视为后台配置错误, - /// 启动期由 WebContainerViewController 抛 BootError.audioConfigMissing。 + /// 顶层值作为整链最浅层 fallback;与版本字段同款 4 层 fallback: + /// agent → game → channel → market 任一层均可声明,最深层赢;都不写则用顶层。 + /// 启动期由 VersionResolver.resolveAudio(...) 解析,缺失抛 BootError.audioConfigMissing。 public let audioDomain: String? - /// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain。 + /// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain,同款 4 层 fallback。 public let audioBucket: String? public let agentlist: [Agent]? @@ -42,6 +43,8 @@ nonisolated public struct Agent: Codable, Sendable { 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 { @@ -52,12 +55,15 @@ nonisolated public struct Agent: Codable, Sendable { 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 } } @@ -68,6 +74,8 @@ nonisolated public struct Channel: Codable, Sendable { 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 { @@ -78,12 +86,15 @@ nonisolated public struct Channel: Codable, Sendable { 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 } } @@ -94,6 +105,8 @@ nonisolated public struct Market: Codable, Sendable { 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) @@ -103,11 +116,14 @@ nonisolated public struct Market: Codable, Sendable { 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 } } @@ -118,6 +134,8 @@ nonisolated public struct Game: Codable, Sendable { 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 { @@ -128,12 +146,15 @@ nonisolated public struct Game: Codable, Sendable { 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 } } diff --git a/ylgamehall/Source/Network/VersionResolver.swift b/ylgamehall/Source/Network/VersionResolver.swift index 3cf4300..a5d96a9 100644 --- a/ylgamehall/Source/Network/VersionResolver.swift +++ b/ylgamehall/Source/Network/VersionResolver.swift @@ -43,6 +43,8 @@ private protocol RemoteConfigNode { var gameVersion: String? { get } var gameZip: String? { get } var showmessage: String? { get } + var audioDomain: String? { get } + var audioBucket: String? { get } } extension Agent: RemoteConfigNode {} @@ -85,6 +87,33 @@ public enum VersionResolver { ) } + // 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 顺序逐层匹配,遇到不匹配的层立刻截断。 diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 5d4ae68..5678b5f 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -345,16 +345,26 @@ public final class WebContainerViewController: UIViewController { throw BootError.operationalMessage(msg) case .parsed(let cfg): - // 七牛 audio_domain / audio_bucket:远端配置的唯一权威源。 - // 缺失视为后台配置错误,启动期致命 —— 不允许使用空值上传录音。 - guard let audioDomain = cfg.audioDomain, !audioDomain.isEmpty, - let audioBucket = cfg.audioBucket, !audioBucket.isEmpty + let bc = BundleConfig.shared + + // 七牛 audio_domain / audio_bucket:与版本字段同款 4 层 fallback: + // agent → game → channel → market 任一层声明都赢,整链没写则用 RemoteConfig 顶层。 + // 缺失视为后台配置错误,启动期致命——不允许使用空值上传录音。 + let audio = VersionResolver.resolveAudio( + config: cfg, + agentId: bc.agent, + channelId: bc.channel, + marketId: bc.market, + gameId: bc.gameId + ) + print("[QiniuConfig] resolved audio domain=\(audio.domain ?? "") bucket=\(audio.bucket ?? "")") + guard let audioDomain = audio.domain, !audioDomain.isEmpty, + let audioBucket = audio.bucket, !audioBucket.isEmpty else { throw BootError.audioConfigMissing } await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket) - let bc = BundleConfig.shared let r = VersionResolver.resolve( config: cfg, agentId: bc.agent,