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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6742308e14
commit
a54d1511be
@@ -1258,8 +1258,9 @@ ResolvedVersion(
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
|
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
|
||||||
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
|
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
|
||||||
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 `audio_domain` / `audio_bucket` | 后台运维管理,无客户端直接出 ad-hoc 改值的诉求 |
|
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`(4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 |
|
||||||
- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)即调 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失 audio_domain / audio_bucket 抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停(理由:缺这俩 → 录音上传必失败,业务不可用,启动期即报远比运行期某次上传时报更友好)
|
- **远端注入时序**:`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 属性
|
- `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`,3 个不可变 String 属性
|
||||||
- `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
|
- `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import Foundation
|
|||||||
nonisolated public struct RemoteConfig: Codable, Sendable {
|
nonisolated public struct RemoteConfig: Codable, Sendable {
|
||||||
public let showmessage: String?
|
public let showmessage: String?
|
||||||
/// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。
|
/// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。
|
||||||
/// 跨渠道全局相同,放顶层不进 4 层 fallback。缺失视为后台配置错误,
|
/// 顶层值作为整链最浅层 fallback;与版本字段同款 4 层 fallback:
|
||||||
/// 启动期由 WebContainerViewController 抛 BootError.audioConfigMissing。
|
/// agent → game → channel → market 任一层均可声明,最深层赢;都不写则用顶层。
|
||||||
|
/// 启动期由 VersionResolver.resolveAudio(...) 解析,缺失抛 BootError.audioConfigMissing。
|
||||||
public let audioDomain: String?
|
public let audioDomain: String?
|
||||||
/// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain。
|
/// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain,同款 4 层 fallback。
|
||||||
public let audioBucket: String?
|
public let audioBucket: String?
|
||||||
public let agentlist: [Agent]?
|
public let agentlist: [Agent]?
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ nonisolated public struct Agent: Codable, Sendable {
|
|||||||
public let appDownload: String?
|
public let appDownload: String?
|
||||||
public let gameVersion: String?
|
public let gameVersion: String?
|
||||||
public let gameZip: String?
|
public let gameZip: String?
|
||||||
|
public let audioDomain: String?
|
||||||
|
public let audioBucket: String?
|
||||||
public let gamelist: [Game]?
|
public let gamelist: [Game]?
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
@@ -52,12 +55,15 @@ nonisolated public struct Agent: Codable, Sendable {
|
|||||||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||||||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||||||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
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)
|
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case agentid, showmessage, gamelist
|
case agentid, showmessage, gamelist
|
||||||
case appVersion, appDownload, gameVersion, gameZip
|
case appVersion, appDownload, gameVersion, gameZip
|
||||||
|
case audioDomain, audioBucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ nonisolated public struct Channel: Codable, Sendable {
|
|||||||
public let appDownload: String?
|
public let appDownload: String?
|
||||||
public let gameVersion: String?
|
public let gameVersion: String?
|
||||||
public let gameZip: String?
|
public let gameZip: String?
|
||||||
|
public let audioDomain: String?
|
||||||
|
public let audioBucket: String?
|
||||||
public let marketlist: [Market]?
|
public let marketlist: [Market]?
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
@@ -78,12 +86,15 @@ nonisolated public struct Channel: Codable, Sendable {
|
|||||||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||||||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||||||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
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)
|
marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case channelid, showmessage, marketlist
|
case channelid, showmessage, marketlist
|
||||||
case appVersion, appDownload, gameVersion, gameZip
|
case appVersion, appDownload, gameVersion, gameZip
|
||||||
|
case audioDomain, audioBucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +105,8 @@ nonisolated public struct Market: Codable, Sendable {
|
|||||||
public let appDownload: String?
|
public let appDownload: String?
|
||||||
public let gameVersion: String?
|
public let gameVersion: String?
|
||||||
public let gameZip: String?
|
public let gameZip: String?
|
||||||
|
public let audioDomain: String?
|
||||||
|
public let audioBucket: String?
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -103,11 +116,14 @@ nonisolated public struct Market: Codable, Sendable {
|
|||||||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||||||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||||||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||||||
|
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
|
||||||
|
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case marketid, showmessage
|
case marketid, showmessage
|
||||||
case appVersion, appDownload, gameVersion, gameZip
|
case appVersion, appDownload, gameVersion, gameZip
|
||||||
|
case audioDomain, audioBucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +134,8 @@ nonisolated public struct Game: Codable, Sendable {
|
|||||||
public let appDownload: String?
|
public let appDownload: String?
|
||||||
public let gameVersion: String?
|
public let gameVersion: String?
|
||||||
public let gameZip: String?
|
public let gameZip: String?
|
||||||
|
public let audioDomain: String?
|
||||||
|
public let audioBucket: String?
|
||||||
public let channellist: [Channel]?
|
public let channellist: [Channel]?
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
@@ -128,12 +146,15 @@ nonisolated public struct Game: Codable, Sendable {
|
|||||||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||||||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||||||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
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)
|
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case gameid, showmessage, channellist
|
case gameid, showmessage, channellist
|
||||||
case appVersion, appDownload, gameVersion, gameZip
|
case appVersion, appDownload, gameVersion, gameZip
|
||||||
|
case audioDomain, audioBucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ private protocol RemoteConfigNode {
|
|||||||
var gameVersion: String? { get }
|
var gameVersion: String? { get }
|
||||||
var gameZip: String? { get }
|
var gameZip: String? { get }
|
||||||
var showmessage: String? { get }
|
var showmessage: String? { get }
|
||||||
|
var audioDomain: String? { get }
|
||||||
|
var audioBucket: String? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Agent: RemoteConfigNode {}
|
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: - 节点链匹配
|
// MARK: - 节点链匹配
|
||||||
|
|
||||||
/// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
/// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
||||||
|
|||||||
@@ -345,16 +345,26 @@ public final class WebContainerViewController: UIViewController {
|
|||||||
throw BootError.operationalMessage(msg)
|
throw BootError.operationalMessage(msg)
|
||||||
|
|
||||||
case .parsed(let cfg):
|
case .parsed(let cfg):
|
||||||
// 七牛 audio_domain / audio_bucket:远端配置的唯一权威源。
|
let bc = BundleConfig.shared
|
||||||
// 缺失视为后台配置错误,启动期致命 —— 不允许使用空值上传录音。
|
|
||||||
guard let audioDomain = cfg.audioDomain, !audioDomain.isEmpty,
|
// 七牛 audio_domain / audio_bucket:与版本字段同款 4 层 fallback:
|
||||||
let audioBucket = cfg.audioBucket, !audioBucket.isEmpty
|
// 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 ?? "<nil>") bucket=\(audio.bucket ?? "<nil>")")
|
||||||
|
guard let audioDomain = audio.domain, !audioDomain.isEmpty,
|
||||||
|
let audioBucket = audio.bucket, !audioBucket.isEmpty
|
||||||
else {
|
else {
|
||||||
throw BootError.audioConfigMissing
|
throw BootError.audioConfigMissing
|
||||||
}
|
}
|
||||||
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
|
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
|
||||||
|
|
||||||
let bc = BundleConfig.shared
|
|
||||||
let r = VersionResolver.resolve(
|
let r = VersionResolver.resolve(
|
||||||
config: cfg,
|
config: cfg,
|
||||||
agentId: bc.agent,
|
agentId: bc.agent,
|
||||||
|
|||||||
Reference in New Issue
Block a user