Phase 4.E + Phase 5 + Phase 3.C 七牛上传:代码侧 SDK 接入(SPM 优先 / Vendor 兜底)

选型(CLAUDE.md ADR-006「SPM 优先 + Vendor xcframework 兜底」):
- 微信 → SPM(Tencent 官方 https://github.com/Tencent/WechatOpenSDK-XCFramework);
  项目方下载的 WechatOpenSDK-NoPay.xcframework 已撤出 Vendor,docs/res 内副本作离线备份
- 高德 → Vendor 手动(官方未提供 SPM;Vendor/AMap/{AMapFoundationKit,AMapLocationKit}.framework
  随仓库分发,fat .framework 同时含 x86_64 + arm64,Do Not Embed)
- 七牛 → SPM(https://github.com/qiniu/objc-sdk v8.9.x)

代码侧(全部 #if canImport 守卫,Xcode 加 SDK 前编译为 no-op):
- Source/SDK/WeChat/WeChatSDK.swift:WXApi.registerApp + handleOpenURL(沿用 msext AppID
  wx586a9b321e56efb7,universalLink 空字符串走非 ULAPI 路径)
- Source/SDK/WeChat/WeChatManager.swift:WXApiDelegate + state UUID 配对的 authorize +
  FIFO 串行 share async/await wrapper
- Source/Login/WeChatAuth.swift:客户端直拼 sns/oauth2/access_token + sns/userinfo →
  7 字段(Province 大写 P / city 经 danbian 去单引号),沿用 msext 同款路径
- Source/SDK/AMap/AMapWrapper.swift:iOS 14+ 隐私合规 3 步 + apiKey 注入
- Source/Location/LocationService.swift:actor + AMapLocationManager 异步包装 + 9 字段
- Source/Network/QiniuConfig.swift:4 项常量沿用 msext(AccessKey/SecretKey/Bucket/Domain)
- Source/Network/QiniuTokenSigner.swift:纯 Swift CryptoKit HMAC-SHA1 + Base64URL 自签
  token(与 msext QiniuManager.m:200-230 等价,不依赖 Qiniu SDK 工作)
- Source/Network/QiniuUploader.swift:actor 包 QNUploadManager async/await

Handler 升级:
- AccreditLoginHandler:拉起授权 → 反向 callback sharelogin 7 字段
- WechatShare:真实链接分享(type=2/3 截图待 Phase 4.F)
- StartLocationHandler:真实定位 → 9 字段(latitude/longitude string, province 小写 p)

生命周期:
- AppDelegate.didFinishLaunchingWithOptions:WeChatSDK.register + AMapWrapper.bootstrap
- SceneDelegate.openURLContexts:WeChatSDK.handleOpenURL 接入回调
- BackGameDataHandler:子游戏 backgameData pop 时 WXApi.delegate=nil + LocationService.stop()

Info.plist:CFBundleURLTypes 加 wx586a9b321e56efb7;LSApplicationQueriesSchemes 追加
weixin/weixinULAPI/weixinURLParamsAPI;NSLocationWhenInUseUsageDescription +
NSMicrophoneUsageDescription(gamehallname 中文文案)。

.gitignore:排除 docs/res/{AMap_iOS_Loc_ALL,objc-sdk-8.9.2}/(235MB 项目方下载副本,
已走 SPM/Vendor 接入不需要副本)。

文档:docs/SDK-Integration-Guide.md 重写 §A 用户手动 Xcode UI 三步(SPM × 2 +
Add Files × 2);Vendor/{AMap,WechatSDK}/README.md 记录各自接入路径选型。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 22:22:23 +08:00
co-authored by Claude Opus 4.7
parent a2baf742b6
commit 1d7e258130
47 changed files with 3226 additions and 273 deletions
+265 -227
View File
@@ -2,285 +2,323 @@
> 本文档面向项目方 + 开发者,描述 Phase 4.E / Phase 5 / Phase 3.B-D 三组 SDK 的真实接入步骤。
>
> **使用方式**:每接入一个 SDK 走一遍对应章节的"凭证清单 / 工程接入 / 代码触点 / 清理钩子 / 验收"五步,全程对照 CLAUDE.md「原则 A — H5 端零修改」与 ADR-006「禁止 CocoaPods」
> **核心前提**:本项目复用 daoqi(msext) 原项目的全部 SDK 凭证。**Bundle ID 必须保持 `com.skyapp.ylgamehall`**(已确认与 msext Release 配置一致)— 改 Bundle ID 会导致所有 key 失效
>
> **代码状态(2026-06-22**:所有 Swift wrapper / handler 真实路径 / 生命周期清理钩子已落地,全部包在 `#if canImport(<ModuleName>)` 守卫里。**只需用户在 Xcode UI 完成 3 件事**(详见 §A 用户手动步骤),代码即激活真实路径。
---
## 0. 总览
## A. 用户手动 Xcode 步骤(必做 3 件事)
| SDK | 触发的契约项 | 选型 | 阻塞 |
|---|---|---|---|
| **微信 OpenSDK** | `accreditlogin` / `friendsSharetypeUrlToptitleDescript`(微信路径)/ `sharelogin` 反向 callback / `sharesuccess` | Vendor `.xcframework` 手动 | AppID + Universal Links + 后台 `/wechat/login`(或客户端 fallback |
| **高德定位(AMap** | `startlocation` / `getlocationinfo` 反向 callback9 字段)| Vendor `.xcframework` 手动 | **新 APIKey(绑死 Bundle ID,不可复用 msext** + 隐私合规调用 |
| **opencore-amr** | `prepareaudio`(录音上传)/ `mediaTypeAudio`(远端语音回放)AMR ↔ WAV 转码 | 从 msext 拷贝已修复版 `.a`fat header segalign 8 | 无外部凭证;二进制已在 msext 仓库内 |
| **七牛云上传** | `prepareaudio``getaudiourl({audiourl, time})` / `recordSuccess` 反向 callback | SPM 官方 `https://github.com/qiniu/objc-sdk` v8.9.x | 后台 token 颁发接口 / 长 token 缓存策略 |
> **选型偏好**:尽量 SPM 远程依赖;只有官方未提供 SPM 的才走 Vendor 二进制。
> 当前结果:**微信 + 七牛走 SPM**pbxproj 仅多 2 个 `XCRemoteSwiftPackageReference`,与现有 ZIPFoundation 一致);**高德走 Vendor**(官方未提供 SPM)。
> 这 3 件事都是 Xcode UI 操作,无法 AI 自动完成。完成后 `BuildProject` 即激活所有真实路径。
**两条铁律(CLAUDE.md**
1. 任何接入步骤都不允许触碰 H5 端任何文件(包括 `app_*.js`
2. 不引入 CocoaPodsSPM 优先,闭源走 Vendor `.xcframework`
### A.1 添加微信 OpenSDKSPM 远程依赖)
> 项目方下载的 `WechatOpenSDK-NoPay.xcframework` 已撤出 `Vendor/`;改用 Tencent 官方 SPM 仓库。
1. Xcode → File → **Add Package Dependencies...**
2. 右上搜索框粘贴:`https://github.com/Tencent/WechatOpenSDK-XCFramework`
3. **Dependency Rule****Up to Next Major Version**`2.0.5`(或更新;避免 1.x 的 ITMS-90809 历史包袱)
4. Add Package → 弹窗里 Library 选 `WechatOpenSDK` → Add Package
5. 确认 ylgamehall target 已勾选
6. ⌘B 编译
### A.2 添加高德定位 framework × 2Vendor 手动,无 SPM 替代)
> ⚠️ 高德官方至今未提供 SPM 包;只发布旧式 fat `.framework`(不是 xcframework),同时含 x86_64 + arm64 — 模拟器和真机都能用,但要 **Do Not Embed**(静态库不能 Embed,否则链接器报 `__OBJC` 段重复符号)。
1. 选中 `ylgamehall` Target → General → Frameworks, Libraries, and Embedded Content
2.`+` → Add Other → Add Files
3. 一次性选两个:
- `Vendor/AMap/AMapFoundationKit.framework`
- `Vendor/AMap/AMapLocationKit.framework`
4. 两个的 **Embed** 列都选 **Do Not Embed**(关键,别错)
5. ⌘B 编译
### A.3 添加七牛 SPM 依赖(远程)
> 项目方下载的 `docs/res/objc-sdk-8.9.2/` 改 SPM 后不再使用,可保留作离线备份或直接删除(不影响工程)。
1. Xcode → File → **Add Package Dependencies...**
2. 右上搜索框粘贴:`https://github.com/qiniu/objc-sdk`
3. **Dependency Rule****Up to Next Major Version**`8.9.0`
4. Add Package → 弹窗里 Library 选 `Qiniu` → Add Package
5. ⌘B 编译
---
## 1. 微信 OpenSDKPhase 4.E
## B. 完成 A 三步后会发生什么
### 1.1 凭证清单(项目方提供)
代码侧已经全部写好,并用 `#if canImport(...)` 守卫围着真实路径。一旦 Xcode UI 把 SDK 加进 Target`canImport` 立刻返回 true,下面这些就会自动激活:
- [ ] **AppID**:微信开放平台「移动应用」面板新建一个应用(新 Bundle ID 不能复用 msext 的 `wx586a9b321e56efb7`,必须为本项目重新申请)
- [ ] **Universal Links**:项目方拥有的域名 + `apple-app-site-association` 文件(部署到 `https://your-domain.com/.well-known/apple-app-site-association`HTTPS 必须)
- [ ] **后台 `/wechat/login` 中转接口**(强烈推荐):客户端拿 `code` 调后台,后台用 `AppSecret``access_token` + `unionid` + `userinfo`,回 7 字段给客户端
- 若后台未就绪,客户端走 fallback:`api.weixin.qq.com/sns/oauth2/access_token` + `sns/userinfo`**`AppSecret` 客户端硬编码 = 安全风险**,CLAUDE.md 父项目已明确警告。新项目尽量直接走后台中转
- [ ] **微信 OpenSDK XCFramework**:从 [微信开放平台官网下载](https://developers.weixin.qq.com/doc/oplatform/Downloads/iOS_Resource.html),使用 1.9.x 或最新(避免 1.8.x 的 ITMS-90809 历史包袱)
### 1.2 工程接入
1. **放置 SDK**
```
Vendor/WechatSDK/
├── WechatOpenSDK.xcframework
└── README.md # 记录版本号 + 下载日期 + 来源
```
按 Design §14.1 Vendor 接入标准流程:Target → General → Frameworks → Embed & Sign
2. **Info.plist 改动**(仅原生侧,不动 H5)
```xml
<!-- 1. URL Scheme:让微信 App 回调能打开本 App -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>weixin</string>
<key>CFBundleURLSchemes</key>
<array><string>YOUR_WX_APPID</string></array>
</dict>
</array>
<!-- 2. 查询微信是否安装 -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
<!-- 已有的 mqq* / snssdk1128 别覆盖 -->
</array>
```
3. **Entitlement 改动**Signing & Capabilities
- Associated Domains 添加 `applinks:your-domain.com`(与 `apple-app-site-association` 的域名严格一致)
### 1.3 代码触点(接入此 SDK 必须改的文件)
| 文件 | 改动 |
| 文件 | 激活后行为 |
|---|---|
| **`Source/SDK/WeChat/WeChatSDK.swift`**(新建) | `WXApi.registerApp(_:universalLink:)` 启动注册;包含全部 12 个 `MMAPP_SUPPORT_*` flagDesign §4.2 |
| **`AppDelegate.swift`** | `application(_:didFinishLaunchingWithOptions:)` 内调 `WeChatSDK.register()` |
| **`SceneDelegate.swift`** | `openURLContexts` 内按 **QQ → WXApi** 顺序判(msext `AppDelegate.m` 顺序硬约束,QQ 必须先判) |
| **`Source/SDK/WeChat/WeChatManager.swift`**(新建) | 持久 delegate`authorize() async throws -> WXAuthCode`state UUID 配对);`share(_:scene:) async throws`FIFO 串行)。Design §8.5 |
| **`Source/Login/WeChatAuth.swift`**(新建) | `authorize()` 拿 code → 转后台 `/wechat/login`(或 fallback 拼 `sns/oauth2/access_token` + `sns/userinfo`)→ 拿到 7 字段 user。Fallback 文件头标 `// FIXME: 待后台 /wechat/login 就绪后切换为后台中转` |
| **`Source/Bridge/Handlers/AccreditLoginHandler.swift`** | 移除 stub 返回,调 `WeChatAuth.authorize()` → 反向 `bridge.call("sharelogin", 7 字段)`<br>**字段名严格 1:1(含历史包袱)**:`openid` / `headimgurl` / `nickname` / `sex` / `city` / `Province`**大写 P**!)/ `unionid` |
| **`Source/Share/WechatShare.swift`** | 移除 stub `return .success`,调 `WeChatManager.share``isInstalled` 改为 `WXApi.isWXAppInstalled()` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `WXApi.delegate = nil`(清理子游戏 push 期间的 delegate 指针) |
| `AppDelegate.swift` | `WeChatSDK.register()` → 真实调 `WXApi.registerApp("wx586a9b321e56efb7", universalLink: "")` |
| `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey |
| `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` |
| `AccreditLoginHandler` | `accreditlogin` → 真实拉起微信授权 → 反向 callback `sharelogin` 7 字段(Province 大写 P |
| `WechatShare` | `share()` → 真实调 `WXApi.send(SendMessageToWXReq)` |
| `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段(latitude/longitude string, province 小写 p |
| `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` |
### 1.4 清理钩子(CLAUDE.md 第 6.0 节回响)
`BackGameDataHandler.swift` 的 `MainActor.run` 块加一行:
```swift
WXApi.delegate = nil // ← Phase 4.E 接入时取消注释
```
理由:msext `gameController.m:699` 在 backgameData 里清 delegate,防止 pop 后微信回调指向已销毁的 SubGameVC。
### 1.5 验收(H5 端零修改前提下跑)
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅收 `sharelogin({openid,...,Province})`**`Province` 是大写 P**
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 直接调起微信朋友圈
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择 → 分享成功后大厅收 `sharesuccess({success:"2",type:"1"})`
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
### 1.6 风险
- **AppSecret 客户端泄露**fallback 路径上线前必须切到后台中转,否则 secret 一旦泄露不可重置(CLAUDE.md 父项目「已识别为安全风险,明确接受不修」的同款问题,新项目从一开始避免)
- **Universal Links 配置错误**:授权回不来。验证方式:`xcrun simctl openurl booted "https://your-domain.com/test"` 看是否拉起本 App
- **微信审核**:审核期间 OpenSDK 行为差异(部分 API 限频),上线初期保持后台监控
**Qiniu 单独说明**:七牛 SDK 加进 SPM 后,`canImport(QiniuSDK)` 通过;但 `RemoteAudioHandler` 仍然是 stubprepareaudio 不真实录音上传),原因是录音→AMR 转码链路的 opencore-amr ObjC 库未接入。`QiniuUploader.upload(...)` 单独已可用,可用于其它非录音场景。完整录音上传链路见 §C。
---
## 2. 高德定位 SDKPhase 5
## C. 仍需用户手动做的事(opencore-amr / AMR 录音上传
### 2.1 凭证清单
> 这一块代码侧没有自动完成,因为需要从 msext 拷贝 ObjC 源码(含 MRC+ 修改 Build SettingsFile-level `-fno-objc-arc` flag+ Bridging Header。这些 pbxproj 改动对 Xcode 26 同步项目风险较高,留用户手动。
- [ ] **新 APIKey**[高德控制台](https://lbs.amap.com/dev/key/app) 用项目方账号新建应用,绑定本项目的 Bundle ID**msext 的 `b0d4a8e3fcbbcc0dd96283b7df6a4494` 不能复用**APIKey 与 Bundle ID 一一绑死)
- [ ] **AMap SDK XCFramework**[官方下载页](https://lbs.amap.com/api/ios-location-sdk/download)拉取最新(截至 2026-06 推荐 v2.10.x+
- `AMapLocationKit.xcframework` — 定位主库
- `AMapFoundationKit.xcframework` — 基础库(必须配合 LocationKit
### C.1 拷贝 opencore-amr 静态库
### 2.2 工程接入
1. **放置 SDK**
```
Vendor/AMap/
├── AMapLocationKit.xcframework
├── AMapFoundationKit.xcframework
└── README.md # 记录 SDK 版本 + APIKey 创建日期 + 绑定 Bundle ID
```
Target → General → Frameworks → Embed & Sign(两个都要)
2. **Info.plist**(仅原生侧)
```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>{gamehallname}需要访问您的位置以提供本地化服务</string>
```
`{gamehallname}` 占位由 ChannelConfig.plist 注入或硬编为渠道名。
### 2.3 代码触点
| 文件 | 改动 |
|---|---|
| **`Source/SDK/AMap/AMapWrapper.swift`**(新建) | 启动期调用顺序:`updatePrivacyShow(.didShow, privacyInfo: .didContain)` → `updatePrivacyAgree(.didAgree)` → `AMapServices.shared().apiKey = "..."`**iOS 14+ 隐私合规必走的 3 步**,少一步定位拿不到结果) |
| **`AppDelegate.swift`** | `didFinishLaunchingWithOptions` 内调 `AMapWrapper.bootstrap()` |
| **`Source/Location/LocationService.swift`**(新建,actor | `requestOnce() async throws -> LocationPayload`(一次定位)<br>`startContinuous(onUpdate:)` / `stop()`(持续定位)<br>包 `AMapLocationManager`,回调转 `async` |
| **`Source/Bridge/Handlers/StartLocationHandler.swift`** | 移除 stub,按 `data == 1` 持续 / 其它一次性<br>反向 callback `bridge.call("getlocationinfo", 9 字段)`<br>**字段名严格 1:1**`address` / `city` / `cityCode` / `country` / `district` / `latitude`**string**`String(format: "%f", lat)`/ `longitude`**string**/ `province`**小写 p**!与 `sharelogin.Province` 不同)/ `street`<br>失败:`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `LocationService.shared.stop()` |
### 2.4 清理钩子
`BackGameDataHandler.swift` 的 `MainActor.run` 块加:
```swift
LocationService.shared.stop() // ← Phase 5 接入时取消注释
```bash
mkdir -p ylgamehall/Vendor/opencore-amr/
# 已修复版(fat header segalign 8)静态库
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrnb.a \
ylgamehall/Vendor/opencore-amr/
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrwb.a \
ylgamehall/Vendor/opencore-amr/
# 拷头文件
cp -r ../daoqi/msext/msext/Class/Common/VoiceConvert/include \
ylgamehall/Vendor/opencore-amr/
# 拷 VoiceConverter ObjC wrapper(含 amrFileCodec / interf_*
cp -r ../daoqi/msext/msext/Class/Common/VoiceConvert \
ylgamehall/Vendor/opencore-amr/VoiceConverter
```
理由:msext `gameController.m:2473-2478 cleanUpAction` 在 backgameData 里调 `[locationManager stopUpdatingLocation]` + delegate nil;防止 pop 后后台定位继续耗电 + 回调悬空。
### 2.5 验收
### C.2 Xcode 工程配置
- [ ] 首次启动 → 系统权限弹框(文案为 Info.plist 设置的中文)
- [ ] H5 调 `bridge.callHandler('startlocation', "1")` → 收到 `getlocationinfo({...9 字段...})``latitude` / `longitude` 是 string 类型,`province` 是小写 p
- [ ] 拒绝权限 → H5 收 `getlocationinfo({errorCode:12,errorMsg:"缺少定位权限"})`
- [ ] 子游戏定位中 → 调 `backgameData` 退出 → 大厅不再收到悬挂定位回调(验证 cleanUp 钩子)
### 2.6 风险
- **APIKey 绑定**Bundle ID 改了 / 多 Target 用不同 ID 都要重新申请 key,否则定位失败但无明显报错(高德回 errorCode 7「KEY 校验失败」)
- **隐私合规调用顺序**:iOS 14+ 必须先 `updatePrivacyShow` → `updatePrivacyAgree` 再 `apiKey`,否则定位接口直接拒绝
- **模拟器**:高德定位在模拟器走 Apple 的 CoreLocation 模拟坐标,定位精度低,必须真机测
---
## 3. opencore-amr 转码库(Phase 3.B
### 3.1 凭证清单
- 无凭证。二进制已在 msext 仓库内,**且已经过 fat header `segalign 8` 修复**(参 CLAUDE.md 父项目「已处理的定时炸弹」一节,2026-06-20 摘掉 `-ld_classic` 的同一组 `.a`
### 3.2 工程接入
1. **拷贝二进制**(已修复版)
```bash
mkdir -p ylgamehall/Vendor/opencore-amr/
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrnb.a ylgamehall/Vendor/opencore-amr/
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrwb.a ylgamehall/Vendor/opencore-amr/
# 拷头文件
cp -r ../daoqi/msext/msext/Class/Common/VoiceConvert/include ylgamehall/Vendor/opencore-amr/
```
2. **拷 VoiceConverterObjC wrapper**
```
ylgamehall/Vendor/opencore-amr/VoiceConverter/
├── VoiceConverter.h # ObjC interface(保留 ObjCwrapper 简单不重写 Swift
├── VoiceConverter.m # MRC 代码可保留,加 -fno-objc-arc 编译标志或转 ARC
├── amrFileCodec.h/m
└── interf_*.h/c
```
3. **Xcode 工程配置**
- Target → Build Phases → Link Binary With Libraries 加入两个 `.a`
- Target → Build Settings → Library Search Paths 加 `$(PROJECT_DIR)/ylgamehall/Vendor/opencore-amr`
- Target → Build Settings → Header Search Paths 加 `$(PROJECT_DIR)/ylgamehall/Vendor/opencore-amr/include`
- **不需要** `-ld_classic`(已用 `lipo -segalign 8` 修复 fat headerCLAUDE.md 父项目记录的修复同样适用)
- 若复用 msext MRC 的 `VoiceConverter.m`,加文件级 `-fno-objc-arc` 编译标志
4. **Bridging Header**(已有 `ylgamehall-Bridging-Header.h` 则追加;无则新建)
1. 选中 Target → Build Phases → Link Binary With Libraries → `+` → Add Other → Add Files → 选两个 `.a`
2. Target → Build Settings
- **Library Search Paths** 加 `$(PROJECT_DIR)/Vendor/opencore-amr`
- **Header Search Paths** 加 `$(PROJECT_DIR)/Vendor/opencore-amr/include`
3.`Vendor/opencore-amr/VoiceConverter/` 下的 `.m` 文件拖入项目(Add to Target ylgamehall
4. 每个 `.m` 文件单独加 `-fno-objc-arc` 编译标志(msext 是 MRC):
- Target → Build Phases → Compile Sources → 找到每个 `.m` → 双击 → 加 `-fno-objc-arc`
5. 创建 Bridging Header `ylgamehall-Bridging-Header.h` 内容:
```objc
#import "VoiceConverter.h"
```
6. Target → Build Settings → **Objective-C Bridging Header** 设为 `ylgamehall/ylgamehall-Bridging-Header.h`
### 3.3 代码触点
### C.3 接入 Swift 录音 + 上传链路(代码侧)
| 文件 | 改动 |
|---|---|
| **`Source/Audio/VoiceCoder.swift`**(新建) | Swift wrapper`static func amrToWav(_ amrPath: URL) throws -> URL` / `static func wavToAmr(_ wavPath: URL) throws -> URL`<br>内部调 ObjC `VoiceConverter` |
| **`Source/Audio/AudioRecorder.swift`**(新建,actor | `record() async throws -> AudioFile``AVAudioRecorder` 录 WAV + 麦克风权限<br>录音参数与 msext `ChatVoiceRecorderVC` 等价:16kHz / 单声道 / 16-bit |
| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | 移除 stub,分别接入:<br>**`prepareaudio`**(录音上传)→ `AudioRecorder.record()` → WAV→AMR `VoiceCoder.wavToAmr` → `QiniuUploader.upload`(见 §4 → `bridge.call("getaudiourl", {audiourl, time})`**子游戏页额外触发** `bridge.call("recordSuccess", {fileUrl, fileName, fileKey})`<br>**`mediaTypeAudio`**(远端回放)→ URLSession 下载 AMR → AMR→WAV `VoiceCoder.amrToWav` → `AVAudioPlayer` 播放<br>开始播放 `bridge.call("gameui_play_voice", user)` / 播放结束 `bridge.call("gameui_stop_voice", user)`<br>受 `VoicePlayingHandler` 的开关短路(=1 才播) |
| **`Info.plist`** | 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `AudioRecorder.shared.cancel()` |
### 3.4 清理钩子
完成 C.1-C.2 后回到代码侧补两个文件:
**`Source/Audio/VoiceCoder.swift`**Swift wrapper
```swift
AudioRecorder.shared.cancel() // ← Phase 3.C 接入时取消注释
QiniuUploader.shared.cancelInFlight() // ← Phase 3.C 接入时取消注释
import Foundation
public enum VoiceCoder {
public static func wavToAmr(_ wav: URL) throws -> URL {
let amr = wav.deletingPathExtension().appendingPathExtension("amr")
// VoiceConverter 是 msext ObjC 类(MRC),桥到 Swift 后直接调
let ok = VoiceConverter.convertWav(toAmr: wav.path, amrSavePath: amr.path)
guard ok else { throw VoiceCodeError.transcodeFailed }
return amr
}
public static func amrToWav(_ amr: URL) throws -> URL {
let wav = amr.deletingPathExtension().appendingPathExtension("wav")
let ok = VoiceConverter.convertAmr(toWav: amr.path, wavSavePath: wav.path)
guard ok else { throw VoiceCodeError.transcodeFailed }
return wav
}
public enum VoiceCodeError: Error { case transcodeFailed }
}
```
理由:录音 / 上传任务跨 VC 生命周期容易遗留;msext 通过 `ChatVoiceRecorderVC` 生命周期间接清理,我们的 actor 实现不绑 VC 必须显式 cancel。
### 3.5 验收
**`Source/Audio/AudioRecorder.swift`**actor
```swift
import AVFoundation
- [ ] H5 调 `prepareaudio` → 弹麦克风权限 → 同意后开始录音 → 录完上传七牛 → 大厅收 `getaudiourl({audiourl:"https://...", time:"3"})`
- [ ] 子游戏同样路径,额外收到 `recordSuccess({fileUrl, fileName, fileKey})`
- [ ] H5 调 `mediaTypeAudio({audiourl, user})` → 听到对方语音 + 收到 `gameui_play_voice` / 结束收 `gameui_stop_voice`
- [ ] `voicePlaying` 总开关设 0 → 调 `mediaTypeAudio` 不播放
public actor AudioRecorder {
public static let shared = AudioRecorder()
public func record() async throws -> (file: URL, duration: Int) {
// AVAudioRecorder 16kHz / mono / 16-bit WAV
// 等价 msext ChatVoiceRecorderVC 配置
// ... 实现略,约 60 行
}
public func cancel() { /* 停录音 */ }
}
```
**升级 `RemoteAudioHandler.swift` 的 prepareaudio 分支**
```swift
bridge.register("prepareaudio") { _, callback in
callback?(.string("Response from prepareaudio"))
Task { @MainActor in
do {
let rec = try await AudioRecorder.shared.record()
let amr = try VoiceCoder.wavToAmr(rec.file)
let uploaded = try await QiniuUploader.shared.upload(amr, timeSec: rec.duration)
bridge.call("getaudiourl", data: .object([
"audiourl": .string(uploaded.fileUrl),
"time": .string("\(uploaded.timeSec)")
]), callback: nil)
// 子游戏页额外触发(仅 SubGameViewController 注册的 handler 时才发,
// 需要在 SubGameViewController.registerBridgeHandlers 里包一层判别):
// bridge.call("recordSuccess", data: ...)
} catch {
// 失败兜底(msext 同款乐观策略:不反向通知 H5)
}
}
}
```
**升级 BackGameDataHandler 清理钩子**:取消那行 `AudioRecorder.shared.cancel()` 注释。
---
## 4. 七牛云上传(Phase 3.C / 3.D
## 0. 凭证总表(沿用 msext
### 4.1 凭证清单
> 全部来自 daoqi/msext 原项目,**直接复用,无需重新申请**。
- [ ] **后台 token 颁发接口**:每次录音前向后台 GET `/api/qiniu/upload-token?scene=voice` 拿到上传凭证 + 域名
- [ ] **七牛 Bucket 名 / CDN 域名**:项目方提供(可硬编到 BundleConfig,或随 ChannelConfig 注入)
| 项 | 值 | 来源 |
|---|---|---|
| **Bundle ID** | `com.skyapp.ylgamehall` | `daoqi/msext.xcodeproj` Release |
| **App 显示名** | `进贤聚友棋牌` | `SGDefineInfo.h:103 gamehallname` |
| 微信 AppID | `wx586a9b321e56efb7` | `SGDefineInfo.h:105 kAuthOpenID` |
| 微信 AppSecret | `b2792724b9565be23e8f5ba548f117cf` | `SGDefineInfo.h:107 Appsecret` |
| 微信 AuthScope | `snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact` | `SGDefineInfo.h:104` |
| 微信 AuthState | `wechat_sdk` | `SGDefineInfo.h:106 kAuthState` |
| 高德定位 APIKey | `b0d4a8e3fcbbcc0dd96283b7df6a4494` | `daoqi/msext/Class/Common/APIKey.h:14` |
| 七牛 AccessKey | `dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm` | `QiniuConfig.m:12` |
| 七牛 SecretKey | `RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq` | `QiniuConfig.m:13` |
| 七牛 BucketName | `iosaudio` | `QiniuConfig.m:16` |
| 七牛 CDN Domain | `iosaudio.daoqi88.cn` | `daoqi/msext/msext/qiniudomain/` 目录名 |
### 4.2 工程接入(SPM,优于二进制
### 0.1 安全权衡(与父项目 CLAUDE.md 同步
1. Xcode → File → Add Package Dependencies
2. URL`https://github.com/qiniu/objc-sdk`
3. 版本:`8.9.x` 最新(avoid 7.x 历史 API
4. 勾选 target → Add Package
父项目 CLAUDE.md 第 6 节明确指出 `AppSecret` / `SecretKey` 客户端硬编码是已识别但**接受不修**的安全风险。msext IPA 已分发数年等价泄露,新外壳沿用同一 key + 沿用同款客户端直拼路径 → **同等安全等级**,**不引入新的攻击面**,**不需要后台中转接口**。
注:七牛官方 SPM 包是 ObjC,但 SPM 直接支持 ObjC + Swift 混合,不需要 bridging headerSPM 自动处理)。
---
### 4.3 代码触点
## 1. 微信 OpenSDKPhase 4.E)— 代码已就绪
| 文件 | 改动 |
### 1.1 凭证(已具备,沿用 msext)
参 §0 总表。
### 1.2 工程接入
参 §A.1(用户手动 Xcode 步骤)。
### 1.3 代码已落地
| 文件 | 状态 | 说明 |
|---|---|---|
| SPM `WechatOpenSDK-XCFramework` | ⏳ 待用户在 Xcode Add Package Dependencies | 见 §A.1Tencent 官方仓库 |
| `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport |
| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize / shareLink async wrapper + WXApiDelegate |
| `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 |
| `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callbackProvince 大写 P |
| `Source/Share/WechatShare.swift` | ✅ 已升级 | 真实链接分享(type=2/3 截图待 Phase 4.F |
| `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `WXApi.delegate = nil` 子游戏 pop 时清理 |
| `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` |
| `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` |
| `Info.plist` | ✅ 已加 | CFBundleURLTypes + LSApplicationQueriesSchemes 微信 3 项 |
### 1.4 验收(用户 Xcode 加完 framework 后跑)
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`**Province 大写 P**
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 拉起微信朋友圈
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
### 1.5 风险
- AppSecret / SecretKey 已永久泄露(msext IPA 已分发数年)。复用同一 secret 安全等级不变,不引入新风险。
- 企业签 / TF 分发,不上 App Store,不需要 Universal Links / 审核合规。
---
## 2. 高德定位 SDKPhase 5)— 代码已就绪
### 2.1 凭证(已具备)
参 §0 总表。APIKey 与 Bundle ID `com.skyapp.ylgamehall` 绑死,已复用。
### 2.2 工程接入
参 §A.2(用户手动 Xcode 步骤)。
### 2.3 代码已落地
| 文件 | 状态 |
|---|---|
| **`Source/Network/QiniuTokenService.swift`**(新建,actor | `fetchUploadToken() async throws -> QiniuToken`(含 token / 上传域名 / Key 模板 / 过期时间)<br>缓存策略:token 失效前 30 秒强制刷新;并发请求合并 |
| **`Source/Network/QiniuUploader.swift`**(新建,actor | `upload(_ file: URL, token: QiniuToken) async throws -> UploadedFile`<br>内部用 `QNUploadManager` 包成 asyncUploadedFile 含 `fileUrl` / `fileName` / `fileKey`<br>取消语义:`cancelInFlight()` 取消所有进行中任务(接清理钩子用) |
| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | `prepareaudio` 完整链路第 3 步:`QiniuUploader.upload(amrFile, token: await QiniuTokenService.shared.fetchUploadToken())` |
| `Vendor/AMap/AMapFoundationKit.framework` + `AMapLocationKit.framework` | ✅ 已就位 |
| `Source/SDK/AMap/AMapWrapper.swift` | ✅ 已写 | 隐私合规 3 步 + apiKey 注入 |
| `Source/Location/LocationService.swift` | ✅ 已写 | actorrequestOnce 异步包装 + stop |
| `Source/Bridge/Handlers/StartLocationHandler.swift` | ✅ 已升级 | 真实定位 + getlocationinfo 9 字段(latitude/longitude string, province 小写 p/ 失败回 errorCode 12 |
| `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `LocationService.shared.stop()` |
| `AppDelegate.swift` | ✅ 已加注册 | `AMapWrapper.bootstrap()` |
| `Info.plist` | ✅ 已加 | NSLocationWhenInUseUsageDescription 中文文案 |
### 4.4 清理钩子
### 2.4 验收
见 §3.4(与 opencore-amr 同一处加 `QiniuUploader.shared.cancelInFlight()`)。
- [ ] 首次启动 → 系统权限弹框(中文文案)
- [ ] H5 调 `startlocation` → 收 `getlocationinfo({...9 字段...})`**latitude/longitude 是 string、province 小写 p**
- [ ] 拒绝权限 → H5 收 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
- [ ] 子游戏定位中 → 退出子游戏 → 大厅不再收悬挂定位回调
### 4.5 验收
### 2.5 风险
- [ ] H5 调 `prepareaudio` → 录音 3 秒 → 上传 → 大厅收 `getaudiourl({audiourl:"https://your-cdn.com/voice/xxx.amr", time:"3"})`
- [ ] 录音中 / 上传中 → 退出子游戏 → 任务被 cancel,不再有悬空网络写
- [ ] token 过期场景:模拟后台返回过期 token → uploader 自动刷新重试一次
- **APIKey 绑定 Bundle ID**:本项目已对齐 `com.skyapp.ylgamehall`,可复用。**改 Bundle ID 必须重新申请 key**
- **模拟器定位精度低**:必须真机测
### 4.6 风险
---
- **token 时效**:通常 1 小时;高频录音场景需要 caching + 失效前预刷新
- **上传失败重试**:默认 3 次,超时建议设 30s(语音文件通常 < 100KB
- **CDN 域名 HTTPS**H5 业务侧拿到 `audiourl` 后会请求播放,必须保证域名 HTTPSNSAppTransportSecurity 我们没收紧 ATS,但七牛默认走 HTTPS)
## 3. opencore-amr 转码库(Phase 3.B)— 用户手动
参 §C(用户手动步骤)。代码侧的 wrapper 文件待用户完成 C.1-C.2 后自行新增(§C.3 已给出代码骨架)。
---
## 4. 七牛云上传(Phase 3.C / 3.D)— 代码已就绪(除录音链路)
### 4.1 凭证(已具备)
参 §0 总表。客户端用 AccessKey + SecretKey 本地 HMAC-SHA1 拼 token(与 msext 等价路径),**无后台依赖**。
### 4.2 工程接入
参 §A.3(用户手动添加 SPM 依赖)。
### 4.3 代码已落地
| 文件 | 状态 |
|---|---|
| `Source/Network/QiniuConfig.swift` | ✅ 已写 | 4 项常量(AccessKey / SecretKey / BucketName / cdnDomain+ publicURL 拼接 |
| `Source/Network/QiniuTokenSigner.swift` | ✅ 已写 | 纯 Swift 用 `CryptoKit.HMAC<Insecure.SHA1>` + Base64URL 自签 token(与 msext QiniuManager.m:200-230 等价) |
| `Source/Network/QiniuUploader.swift` | ✅ 已写 | actor`upload(_ file: URL, timeSec: Int) async throws -> UploadedFile`,包 `QNUploadManager` 异步 |
### 4.4 上层调用待 §C 完成
`Source/Bridge/Handlers/RemoteAudioHandler.swift` 的 `prepareaudio` 真实链路依赖 §C 的 AMR 转码 + AudioRecorder。完成后取消 §C.3 的 TODO 注释即可激活全链路。
### 4.5 验收(依赖 §C 完成)
- [ ] H5 调 `prepareaudio` → 录音 3 秒 → 上传 → 大厅 H5 收 `getaudiourl({audiourl:"http://iosaudio.daoqi88.cn/xxx.amr", time:"3"})`
- [ ] 浏览器粘贴 `audiourl` 能直接下载 amr 文件
- [ ] 退出子游戏 → 进行中上传任务被 cancelSwift Task.cancel 传播)
---
## 5. 接入完毕的清单检查
每接入完一个 SDK跑一遍:
每接入完一个 SDK 跑一遍:
- [ ] `BuildProject` 成功
- [ ] `Vendor/<SDK>/README.md` 已记录版本号 / 来源 / 凭证创建日期
- [ ] Info.plist 改动只在原生侧,未触 H5 任何文件
- [ ] `BackGameDataHandler.swift` 对应清理钩子已加(每个 SDK 都要回到此处补 1 行)
- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步(提交时附 commit 备注「Plan 进度已勾选」)
- [ ] `Vendor/<SDK>/README.md` 已记录版本号 / 来源 / 凭证沿用 msext 的备注 — 已就位
- [ ] Info.plist 改动只在原生侧,未触 H5 任何文件 — 已就位
- [ ] `BackGameDataHandler.swift` 对应清理钩子已加 — 微信 + 高德已加 canImport 守卫;录音待 §C
- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步
- [ ] `docs/Verification-Checklist.md` 对应 Phase 验证项已补充
- [ ] 真机跑过该 SDK 的验收项(模拟器对 SDK 行为不可靠:高德、微信、AVAudioRecorder 都受影响
- [ ] 真机跑过验收项(模拟器对微信 / 高德 / AVAudioRecorder 都不可靠
- [ ] **Bundle ID 没改**`com.skyapp.ylgamehall`),改了所有 key 失效
---
@@ -288,9 +326,9 @@ QiniuUploader.shared.cancelInFlight() // ← Phase 3.C 接入时取消注释
接入过程中若行为与文档描述不一致,**优先参考原 daoqi 工程实现**(参 `CLAUDE.md`「原项目 daoqi:遇到问题时的参考来源」一节):
- 微信回调字段:`daoqi/msext/Class/RootVC/NewRootVC.m:2428``sharelogin` 7 字段,含 `Province` 大写)
- 微信 OAuth + 7 字段:`daoqi/msext/Class/RootVC/NewRootVC.m:2428``sharelogin` 7 字段,含 `Province` 大写)
- 微信 SDK 12 个 MMAPP flag`daoqi/msext/AppDelegate.m` 搜 `registerApp`
- 高德 9 字段:`daoqi/msext/Class/RootVC/gameController.m:2528 / 2557``getlocationinfo` 字段顺序与类型)
- AMR 转码:`daoqi/msext/Class/Common/VoiceConvert/``VoiceConverter.h/m`、`amrFileCodec.h/m`
- 七牛上传`daoqi/msext/Class/Utils/QiniuManager.m`(参数命名 + 回调结构
**遇到契约边界 / 历史包袱时**:照搬 msext 字面 + 字段名 + 顺序,不要凭语义猜测。原项目稳定运行多年的写法是真理,文档 / 设计稿可能有传抄错误(已在 CLAUDE.md「典型案例」积累若干)。
- 七牛 token 自签算法`daoqi/msext/Class/Utils/QiniuManager.m:200-230`putPolicy + HMAC-SHA1 + Base64URL
- 七牛域名读取:`daoqi/msext/AppDelegate.m:142` 用 `FuncPublic filename:@"qiniudomain"` 读 `Resources/qiniudomain/` 下第一个子目录名作为实际 Domain