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>
This commit is contained in:
joywayer
2026-06-27 21:07:55 +08:00
co-authored by Claude Opus 4.7
parent 8125dd4d11
commit 83d9502dee
15 changed files with 260 additions and 67 deletions
+41 -18
View File
@@ -2,36 +2,59 @@
// QiniuConfig.swift
// ylgamehall
//
// 七牛云配置常量(沿用 msext QiniuConfig.m)。
// 七牛云配置访问层。
//
// ⚠️ SecretKey 客户端硬编码 = 父项目 CLAUDE.md 已识别但接受的安全风险,
// 同款 AppSecret 路径。msext 已分发多年等价泄露,沿用不引入新攻击面。
// 数据来源(单一真相):
// - accessKey / secretKey ← AppSecrets.plist(应用级凭证,跨渠道相同)
// - bucketName / cdnDomain ← RemoteConfig 顶层 audio_bucket / audio_domain
// (远端动态下发,缺失启动期致命,由 WebContainerViewController 校验后
// 调用 update(domain:bucket:) 注入)
//
// ⚠️ secretKey 客户端持有 = 父项目 CLAUDE.md「已识别但接受的安全风险」,
// 同款 wxAppSecret 路径。msext 已分发多年等价泄露,沿用不引入新攻击面。
//
import Foundation
/// 七牛云配置常量。整体 nonisolated,使得 QiniuUploader(actor)和其它非 MainActor
/// 上下文可直接读取这些 immutable static 常量,无需 await MainActor.run。
public enum QiniuConfig {
/// 七牛 AccessKey(沿用 msext QiniuConfig.m:12)
public nonisolated static let accessKey = "dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm"
/// 七牛云配置访问层。actor 隔离让远端注入的 cdnDomain / bucketName 在并发上传场景下
/// 写读安全。accessKey / secretKey 是 nonisolated immutable,无需 actor 屏障。
public actor QiniuConfig {
/// 七牛 SecretKey(沿用 msext QiniuConfig.m:13)— HMAC-SHA1 签 putPolicy 用
public nonisolated static let secretKey = "RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq"
public static let shared = QiniuConfig()
/// 存储空间名(沿用 msext QiniuConfig.m:16)— putPolicy scope 用
public nonisolated static let bucketName = "iosaudio"
// MARK: - 来自 AppSecrets.plist(应用级,启动期一次性加载,运行期不变)
/// CDN 默认域名(沿用 msext qiniudomain/ 渠道注入目录名,已生产生效)。
/// msext fallback 是 `iosaudio.daoqi8888.cn`,但实际渠道注入覆盖为 `daoqi88`;
/// 新外壳直接沿用渠道注入值。
public nonisolated static let cdnDomain = "iosaudio.daoqi88.cn"
/// 七牛 AccessKey — 来源 `AppSecrets.plist.qiniuAccessKey`
public nonisolated static var accessKey: String { AppSecrets.shared.qiniuAccessKey }
/// 七牛 SecretKey — 来源 `AppSecrets.plist.qiniuSecretKey`。HMAC-SHA1 签 putPolicy 用
public nonisolated static var secretKey: String { AppSecrets.shared.qiniuSecretKey }
/// 录音 key 模板前缀(msext QiniuConfig.m:22 为空字符串)
public nonisolated static let recordingDirectory = ""
// MARK: - 来自 RemoteConfig(远端,启动期动态注入)
private var _cdnDomain: String = ""
private var _bucketName: String = ""
/// 启动期由 WebContainerViewController 在远端配置拉到且校验非空后调用,
/// 之后 QiniuUploader / QiniuTokenSigner 即可 await 读取。
public func update(cdnDomain: String, bucketName: String) {
_cdnDomain = cdnDomain
_bucketName = bucketName
}
/// CDN 域名(不带 http:// 协议头)。访问前必须先 `update(...)`,否则返回空串
/// 导致后续上传/拼 URL 失败 —— 但启动流水线已保证 update 一定先于任何上传调用
public var cdnDomain: String { _cdnDomain }
/// 七牛 bucket 名(putPolicy.scope)。访问前必须先 `update(...)`。
public var bucketName: String { _bucketName }
/// 拼访问 URL:`http://{cdnDomain}/{key}`(msext QiniuManager.m:197 等价)
public nonisolated static func publicURL(forKey key: String) -> String {
"http://\(cdnDomain)/\(key)"
/// 协议头固定 http://(与 msext 一致;七牛 CDN 同时支持 https,按需评估)。
public func publicURL(forKey key: String) -> String {
"http://\(_cdnDomain)/\(key)"
}
}
@@ -17,7 +17,8 @@
import Foundation
import CryptoKit
/// 七牛上传 token 自签。整体 nonisolated,actor 内可直接调(不需要 await MainActor.run)。
/// 七牛上传 token 自签。bucketName 来自 actor `QiniuConfig.shared`,需 await;
/// accessKey / secretKey 是 AppSecrets 同步取,不需要 await。
public enum QiniuTokenSigner {
/// 生成上传 token。
@@ -27,11 +28,12 @@ public enum QiniuTokenSigner {
/// 允许任意 key),与新外壳上传 key 是分离的两个维度。带 `:key` 后
/// 缀(限定 key 精确匹配)会让七牛后端校验更严,且与 daoqi 行为不一致
/// - expiresIn: token 有效时长(默认 3600 秒)
public nonisolated static func uploadToken(key: String? = nil, expiresIn: TimeInterval = 3600) -> String {
public static func uploadToken(key: String? = nil, expiresIn: TimeInterval = 3600) async -> String {
_ = key // 参数保留以避免破坏调用方,但不再使用
// 1. scope 仅含 bucket(对齐 daoqi QiniuManager.m:207)
let scope = QiniuConfig.bucketName
// bucketName 由远端 audio_bucket 动态注入,启动期已校验非空
let scope = await QiniuConfig.shared.bucketName
let deadline = Int(Date().timeIntervalSince1970 + expiresIn)
// 2. putPolicy JSON(daoqi 用默认 options:0,不强制排序)
@@ -56,7 +56,9 @@ public actor QiniuUploader {
let key = QiniuConfig.recordingDirectory + localFile.lastPathComponent
// 客户端自签 token(参 QiniuTokenSigner / msext QiniuManager 同款算法)
let token = QiniuTokenSigner.uploadToken(key: key)
let token = await QiniuTokenSigner.uploadToken(key: key)
// 预取 CDN 域名(远端注入到 QiniuConfig actor),闭包里同步拼 URL 避免再 await
let cdnDomain = await QiniuConfig.shared.cdnDomain
// 七牛 SDK 8.x:`QNUploadManager.init()` 被标 `kQNDeprecated`,必须走
// `initWithConfiguration:`。用 defaultConfigurationV2(v2 已替代旧 defaultConfiguration)。
@@ -75,7 +77,7 @@ public actor QiniuUploader {
token: token,
complete: { info, savedKey, _ in
if let info, info.isOK, let savedKey {
let url = QiniuConfig.publicURL(forKey: savedKey)
let url = "http://\(cdnDomain)/\(savedKey)"
cont.resume(returning: UploadedFile(
fileUrl: url,
fileName: fileName,
@@ -12,7 +12,27 @@ import Foundation
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 {