From 485f0c6679e57651138583690ab2b25c03ff4e92 Mon Sep 17 00:00:00 2001 From: joywayer Date: Sat, 27 Jun 2026 21:32:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=20resolveAudio=20=E8=BF=9B?= =?UTF-8?q?=20resolve()=EF=BC=9A=E6=89=80=E6=9C=89=20RemoteConfig=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=B5=B0=E5=8D=95=E4=B8=80=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=20+=20=E5=90=8C=E6=AC=BE=204=20=E5=B1=82=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【动机】 上个 commit 把 audio_domain/audio_bucket 改成 4 层 fallback,但用了独立的 resolveAudio() 入口 —— 与 resolve() 并存意味着调用方要记两个 API,违反 "统一接口"原则。 【方案】 - ResolvedVersion 加 audioDomain / audioBucket 两个字段(扁平结构) - resolve() 内直接 pickString(chain, \.audioDomain) ?? config.audioDomain 一次解出 - 删除 resolveAudio() 公开入口 - WebContainerViewController parsed 分支:单次 resolve() 调用 + 同一 r 取所有字段 【硬约束(Plan ADR-009 同步)】 远端 RemoteConfig 所有字段必须走 VersionResolver.resolve(...) 单一入口、 同一套 4 层 fallback;新增字段时加进协议 + 节点 struct + ResolvedVersion + 一行 pickString,不允许为某字段单独写 if 链或 helper。 BuildProject 通过。 Co-Authored-By: Claude Opus 4.7 --- docs/Development-Plan.md | 7 +++- .../Source/Network/VersionResolver.swift | 39 +++++-------------- .../WebView/WebContainerViewController.swift | 31 +++++++-------- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index ebce5bf..4599bcb 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -1259,8 +1259,11 @@ ResolvedVersion( | 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 | | 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 | | 七牛 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 算法),节点与顶层任一处声明即可 +- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolve(...)` 一次得到所有字段(含 `audioDomain` / `audioBucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停 +- **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 4 层 fallback:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 到 RemoteConfig 顶层。不允许新增字段时为它单独写 if 链或 helper —— 加进 `RemoteConfigNode` 协议 + 4 个节点 struct + `ResolvedVersion` + `resolve()` 内一行 `pickString(chain, \.xxx) ?? config.xxx` +- **2026-06-27 修订**: + - 初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等字段算法)。 + - 同日二次:曾短暂拆 `resolve()` / `resolveAudio()` 两个公开入口,违反"单一接口"原则。已合并为单一 `resolve()`,所有字段(版本 + 音频 + showmessage)一次返回 - **类型设计**: - `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/VersionResolver.swift b/ylgamehall/Source/Network/VersionResolver.swift index a5d96a9..9d55a14 100644 --- a/ylgamehall/Source/Network/VersionResolver.swift +++ b/ylgamehall/Source/Network/VersionResolver.swift @@ -19,7 +19,7 @@ import Foundation // MARK: - 决策输出 -/// 4 层 fallback 解析后的 5 个目标字段。 +/// 4 层 fallback 解析后的所有目标字段(所有字段统一走同一套 chain + pickString/Int)。 public struct ResolvedVersion: Sendable, Equatable { /// 远端期望的原生 IPA 版本(与 BundleConfig.appVersion 比较)。0 表示远端未指定。 public let appVersion: Int @@ -31,6 +31,10 @@ public struct ResolvedVersion: Sendable, Equatable { 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 个层级共用同一查找接口) @@ -82,35 +86,10 @@ public enum VersionResolver { appDownload: pickString(chain, \.appDownload), gameVersion: pickInt(chain, \.gameVersion), gameZip: pickString(chain, \.gameZip), - // showmessage:链上任一层都不写 → 回退顶层 config.showmessage - showmessage: pickString(chain, \.showmessage) ?? config.showmessage - ) - } - - // 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 + // showmessage / audioDomain / audioBucket:链上任一层都不写 → 回退顶层 config + showmessage: pickString(chain, \.showmessage) ?? config.showmessage, + audioDomain: pickString(chain, \.audioDomain) ?? config.audioDomain, + audioBucket: pickString(chain, \.audioBucket) ?? config.audioBucket ) } diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 5678b5f..b0eba0b 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -347,24 +347,9 @@ public final class WebContainerViewController: UIViewController { case .parsed(let cfg): 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) - + // 单一接口、单一 4 层 fallback:所有字段(版本 + 音频 + showmessage) + // 共用 VersionResolver.resolve(...),统一从 agent → game → channel → market + // 倒序找第一个非空,整链都未声明的字段 fallback 到 RemoteConfig 顶层。 let r = VersionResolver.resolve( config: cfg, agentId: bc.agent, @@ -372,6 +357,16 @@ public final class WebContainerViewController: UIViewController { marketId: bc.market, gameId: bc.gameId ) + print("[VersionResolver] resolved appVer=\(r.appVersion) gameVer=\(r.gameVersion) audioDomain=\(r.audioDomain ?? "") audioBucket=\(r.audioBucket ?? "")") + + // 七牛 audio_domain / audio_bucket 缺失视为后台配置错误,启动期致命 + // ——不允许使用空值上传录音。 + guard let audioDomain = r.audioDomain, !audioDomain.isEmpty, + let audioBucket = r.audioBucket, !audioBucket.isEmpty + else { + throw BootError.audioConfigMissing + } + await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket) // 运营杀手锏 #2:showmessage 非空 → 弹窗永停 if let msg = r.showmessage, !msg.isEmpty {