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:
co-authored by
Claude Opus 4.7
parent
8125dd4d11
commit
83d9502dee
@@ -5,10 +5,13 @@
|
||||
// 微信 OAuth2 客户端直拼实现(与 msext 4 处 ViewController 同款路径)。
|
||||
// Code → access_token → userinfo → 7 字段 user。
|
||||
//
|
||||
// ⚠️ AppSecret 客户端硬编码 = 父项目 CLAUDE.md 已识别但接受的安全风险。
|
||||
// ⚠️ AppSecret 客户端持有 = 父项目 CLAUDE.md 已识别但接受的安全风险。
|
||||
// msext IPA 已分发数年,Appsecret 等价泄露;沿用同一 secret 不引入新攻击面,
|
||||
// 也无需后台 /wechat/login 中转。详见 docs/SDK-Integration-Guide.md §0.1。
|
||||
//
|
||||
// 存储位置:AppSecrets.plist 的 wxAppSecret 字段(唯一来源),由
|
||||
// AppSecrets.shared 启动期一次性加载。
|
||||
//
|
||||
// 字段名严格 1:1(与 msext NewRootVC.m:2428 sharelogin payload 等价):
|
||||
// openid / headimgurl / nickname / sex / city / Province(大写 P)/ unionid
|
||||
//
|
||||
@@ -40,8 +43,8 @@ public enum WeChatAuthError: Error, Sendable {
|
||||
@MainActor
|
||||
public enum WeChatAuth {
|
||||
|
||||
/// 微信 AppSecret(沿用 msext SGDefineInfo.h:107)— 客户端直拼 sns/oauth2 用
|
||||
static let appSecret = "b2792724b9565be23e8f5ba548f117cf"
|
||||
/// 微信 AppSecret — 来源 `AppSecrets.plist` 的 `wxAppSecret`(启动期加载)
|
||||
static var appSecret: String { AppSecrets.shared.wxAppSecret }
|
||||
|
||||
/// 完整 OAuth2 流程:拉起授权 → 拿 code → 换 access_token → 拿 userinfo → 返回 7 字段 user
|
||||
public static func authorize() async throws -> WeChatUser {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// AppSecrets.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 应用级凭证(跨渠道相同):从 Bundle 内的 AppSecrets.plist 读 3 个 string 值。
|
||||
//
|
||||
// ⚠️ 客户端硬编码 = 父项目 CLAUDE.md「已识别但接受的安全风险」,
|
||||
// 集中放进 plist 仅做"单一真相源"管理,不缓解安全面 —— msext IPA 已分发数年,
|
||||
// 这些凭证等价泄露;沿用同一份不引入新攻击面,也无需后台中转。
|
||||
//
|
||||
// 与 ChannelConfig.plist 的语义边界:
|
||||
// - ChannelConfig.plist:渠道差异化字段(每个渠道签名包不同值)
|
||||
// - AppSecrets.plist:跨渠道相同凭证(一份硬编码,多渠道共用)
|
||||
//
|
||||
// 字段对应:
|
||||
// - wxAppSecret : 微信 OAuth2 access_token 换取(msext SGDefineInfo.h:107)
|
||||
// - qiniuAccessKey : 七牛 putPolicy 签名 + 上传 token 前缀
|
||||
// - qiniuSecretKey : 七牛 HMAC-SHA1 签名 putPolicy
|
||||
//
|
||||
// 注:微信 AppID 不在此 plist,唯一来源是 Info.plist 的 CFBundleURLTypes
|
||||
// (URLName=weixin 的 first scheme)。详见 WeChatSDK.swift。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated public final class AppSecrets: Sendable {
|
||||
|
||||
public static let shared = AppSecrets()
|
||||
|
||||
public let wxAppSecret: String
|
||||
public let qiniuAccessKey: String
|
||||
public let qiniuSecretKey: String
|
||||
|
||||
public init(bundle: Bundle = .main) {
|
||||
let dict = Self.loadPlist(bundle: bundle)
|
||||
wxAppSecret = dict["wxAppSecret"] ?? ""
|
||||
qiniuAccessKey = dict["qiniuAccessKey"] ?? ""
|
||||
qiniuSecretKey = dict["qiniuSecretKey"] ?? ""
|
||||
}
|
||||
|
||||
private static func loadPlist(bundle: Bundle) -> [String: String] {
|
||||
guard let url = bundle.url(forResource: "AppSecrets", withExtension: "plist"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, format: nil) as? [String: String]
|
||||
else { return [:] }
|
||||
return plist
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@
|
||||
// BundleConfig.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 11 个 string 值。
|
||||
// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 10 个 string 值。
|
||||
// 设计模式与契约见 docs/H5-Native-Implementation-Design.md §7 / ADR-007。
|
||||
//
|
||||
// 注:原有 qiniudomain 字段已于 2026-06-27 移除,七牛 CDN 域名改由
|
||||
// RemoteConfig 顶层 audio_domain 单一权威源动态下发。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 渠道注入配置(11 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。
|
||||
/// 渠道注入配置(10 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。
|
||||
///
|
||||
/// 多渠道分发:构建后用 `plutil` 修改 `.app/ChannelConfig.plist` + 重签,
|
||||
/// 不需要重新 Xcode build。详见 ADR-007。
|
||||
@@ -17,7 +20,6 @@ nonisolated public final class BundleConfig: Sendable {
|
||||
/// App 启动期默认读 main bundle 的 `ChannelConfig.plist`,业务代码统一通过此单例访问。
|
||||
public static let shared = BundleConfig()
|
||||
|
||||
public let qiniuDomain: String
|
||||
public let gameId: String
|
||||
public let channel: String
|
||||
public let gameDir: String
|
||||
@@ -32,7 +34,6 @@ nonisolated public final class BundleConfig: Sendable {
|
||||
/// 单测可注入任意 bundle 验证不同 plist fixture。
|
||||
public init(bundle: Bundle = .main) {
|
||||
let dict = Self.loadPlist(bundle: bundle)
|
||||
qiniuDomain = dict["qiniudomain"] ?? ""
|
||||
gameId = dict["gameid"] ?? ""
|
||||
channel = dict["channel"] ?? ""
|
||||
gameDir = dict["gamedir"] ?? ""
|
||||
|
||||
@@ -15,8 +15,28 @@ import UIKit
|
||||
@MainActor
|
||||
public enum WeChatSDK {
|
||||
|
||||
/// 微信 AppID(沿用 msext SGDefineInfo.h:105 kAuthOpenID)
|
||||
public static let appID = "wx586a9b321e56efb7"
|
||||
/// 微信 AppID — **唯一来源** 是 Info.plist 的 CFBundleURLTypes 中
|
||||
/// URLName=`weixin` 节点下 CFBundleURLSchemes 的首个 scheme(这是 iOS
|
||||
/// 系统级 URL Scheme 注册,运行期无法注入,必须在 Info.plist 静态声明)。
|
||||
///
|
||||
/// 多渠道分发若需更换 AppID,由 plutil 改 Info.plist 同步即可(同样需要
|
||||
/// 在微信开放平台后台改后台配置),不需要再改 Swift 源码。
|
||||
///
|
||||
/// 配置缺失视为构建配置错误,启动期 preconditionFailure(fail-fast)。
|
||||
public static let appID: String = {
|
||||
guard let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]],
|
||||
let weixinType = urlTypes.first(where: {
|
||||
($0["CFBundleURLName"] as? String) == "weixin"
|
||||
}),
|
||||
let schemes = weixinType["CFBundleURLSchemes"] as? [String],
|
||||
let appID = schemes.first, !appID.isEmpty
|
||||
else {
|
||||
preconditionFailure(
|
||||
"Info.plist 缺少 weixin URL Scheme(CFBundleURLTypes 中 CFBundleURLName=weixin 的 CFBundleURLSchemes 首项即为微信 AppID)"
|
||||
)
|
||||
}
|
||||
return appID
|
||||
}()
|
||||
|
||||
/// 启动注册:在 AppDelegate.didFinishLaunchingWithOptions 调用一次。
|
||||
/// 1.x 单参数 registerApp,不需要 Universal Link。
|
||||
|
||||
@@ -41,6 +41,8 @@ public final class WebContainerViewController: UIViewController {
|
||||
case operationalMessage(String)
|
||||
/// IPA 需要升级,弹窗 + Safari 外链
|
||||
case ipaUpgradeRequired(downloadURL: String)
|
||||
/// 远端 RemoteConfig 缺 audio_domain / audio_bucket:后台配置错误,启动期致命
|
||||
case audioConfigMissing
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
@@ -183,6 +185,10 @@ public final class WebContainerViewController: UIViewController {
|
||||
} catch BootError.ipaUpgradeRequired(let dl) {
|
||||
// IPA 升级 modal alert(业务流程信号)
|
||||
showIPAUpgradeAlert(downloadURL: dl)
|
||||
} catch BootError.audioConfigMissing {
|
||||
// 后台配置错误:远端 RemoteConfig 缺 audio_domain / audio_bucket。
|
||||
// 用户/运营无法自助处理,与运营公告同款 modal 永停。
|
||||
showBlockingAlert(message: "音频服务暂不可用(配置缺失),请联系客服。")
|
||||
} catch {
|
||||
// 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。
|
||||
// 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。
|
||||
@@ -339,6 +345,15 @@ 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
|
||||
else {
|
||||
throw BootError.audioConfigMissing
|
||||
}
|
||||
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
|
||||
|
||||
let bc = BundleConfig.shared
|
||||
let r = VersionResolver.resolve(
|
||||
config: cfg,
|
||||
|
||||
Reference in New Issue
Block a user