【数据流改造(三分制)】 - 微信 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>
132 lines
5.5 KiB
Swift
132 lines
5.5 KiB
Swift
//
|
||
// WeChatAuth.swift
|
||
// ylgamehall
|
||
//
|
||
// 微信 OAuth2 客户端直拼实现(与 msext 4 处 ViewController 同款路径)。
|
||
// Code → access_token → userinfo → 7 字段 user。
|
||
//
|
||
// ⚠️ 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
|
||
//
|
||
|
||
import Foundation
|
||
|
||
public struct WeChatUser: Sendable {
|
||
public let openid: String
|
||
public let headimgurl: String
|
||
public let nickname: String
|
||
/// 性别。微信回的是 number(1=男 2=女 0=未知);H5 收到的 sharelogin 是同款 number 字面。
|
||
public let sex: Int
|
||
/// city / province 经 msext `danbian:` 去单引号处理(防 H5 JSON 解析炸)
|
||
public let city: String
|
||
/// 注意大写 P:msext NewRootVC.m:2428 payload key 是 "Province",硬约束
|
||
public let Province: String
|
||
public let unionid: String
|
||
}
|
||
|
||
public enum WeChatAuthError: Error, Sendable {
|
||
case userCancelled
|
||
case authStepFailed(Int)
|
||
case tokenRequestFailed(any Error)
|
||
case tokenResponseInvalid(String)
|
||
case userInfoRequestFailed(any Error)
|
||
case userInfoResponseInvalid(String)
|
||
}
|
||
|
||
@MainActor
|
||
public enum WeChatAuth {
|
||
|
||
/// 微信 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 {
|
||
// Step 1: 拿 code
|
||
let payload: WXAuthCodePayload
|
||
do {
|
||
payload = try await WeChatManager.shared.authorize()
|
||
} catch let WeChatError.authFailed(errCode) {
|
||
// msext 行为:errCode == -2 视为用户取消,不弹错误
|
||
if errCode == -2 { throw WeChatAuthError.userCancelled }
|
||
throw WeChatAuthError.authStepFailed(errCode)
|
||
}
|
||
// Step 2: code → access_token + openid
|
||
let token = try await exchangeAccessToken(code: payload.code)
|
||
// Step 3: access_token + openid → userinfo(7 字段)
|
||
return try await fetchUserInfo(accessToken: token.accessToken, openid: token.openid)
|
||
}
|
||
|
||
// MARK: - Private — OAuth2 step 2/3 directly hitting api.weixin.qq.com
|
||
|
||
private struct TokenResponse {
|
||
let accessToken: String
|
||
let openid: String
|
||
}
|
||
|
||
private static func exchangeAccessToken(code: String) async throws -> TokenResponse {
|
||
let urlString = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||
+ "?appid=\(WeChatSDK.appID)"
|
||
+ "&secret=\(appSecret)"
|
||
+ "&code=\(code)"
|
||
+ "&grant_type=authorization_code"
|
||
guard let url = URL(string: urlString) else {
|
||
throw WeChatAuthError.tokenResponseInvalid("bad url")
|
||
}
|
||
let data: Data
|
||
do {
|
||
(data, _) = try await URLSession.shared.data(from: url)
|
||
} catch {
|
||
throw WeChatAuthError.tokenRequestFailed(error)
|
||
}
|
||
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||
let accessToken = obj["access_token"] as? String,
|
||
let openid = obj["openid"] as? String
|
||
else {
|
||
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
|
||
throw WeChatAuthError.tokenResponseInvalid(raw)
|
||
}
|
||
return TokenResponse(accessToken: accessToken, openid: openid)
|
||
}
|
||
|
||
private static func fetchUserInfo(accessToken: String, openid: String) async throws -> WeChatUser {
|
||
let urlString = "https://api.weixin.qq.com/sns/userinfo"
|
||
+ "?access_token=\(accessToken)"
|
||
+ "&openid=\(openid)"
|
||
guard let url = URL(string: urlString) else {
|
||
throw WeChatAuthError.userInfoResponseInvalid("bad url")
|
||
}
|
||
let data: Data
|
||
do {
|
||
(data, _) = try await URLSession.shared.data(from: url)
|
||
} catch {
|
||
throw WeChatAuthError.userInfoRequestFailed(error)
|
||
}
|
||
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
||
let raw = String(data: data, encoding: .utf8) ?? "<binary>"
|
||
throw WeChatAuthError.userInfoResponseInvalid(raw)
|
||
}
|
||
return WeChatUser(
|
||
openid: (obj["openid"] as? String) ?? "",
|
||
headimgurl: (obj["headimgurl"] as? String) ?? "",
|
||
nickname: (obj["nickname"] as? String) ?? "",
|
||
sex: (obj["sex"] as? Int) ?? 0,
|
||
// msext danbian: 去单引号(防 JSON 序列化炸)
|
||
city: danbian((obj["city"] as? String) ?? ""),
|
||
Province: danbian((obj["province"] as? String) ?? ""),
|
||
unionid: (obj["unionid"] as? String) ?? ""
|
||
)
|
||
}
|
||
|
||
/// msext FuncPublic.danbian: 等价:去掉单引号
|
||
private static func danbian(_ s: String) -> String {
|
||
s.replacingOccurrences(of: "'", with: "")
|
||
}
|
||
}
|