# SDK 接入指南

> 本文档面向项目方 + 开发者，描述 Phase 4.E / Phase 5 / Phase 3.B-D 三组 SDK 的真实接入步骤。
>
> **核心前提**：本项目复用 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 用户手动步骤），代码即激活真实路径。

---

## A. 用户手动 Xcode 步骤（必做 3 件事）

> **选型偏好**：官方 SPM 优先；闭源 / 无官方 SPM 的走 Vendor 二进制（社区 SPM wrapper 不计入官方，仓库一旦停更即为定时炸弹，按 daoqi `CLAUDE.md` ADR-006 否决）。
> 当前结果：
> - **七牛走官方 SPM**（`https://github.com/qiniu/objc-sdk`，活跃维护）
> - **微信走 Vendor**（Tencent 不维护官方 SPM；社区 wrapper 风险高，回 Vendor 与 AMap 统一处置）
> - **高德走 Vendor**（官方未提供 SPM）
>
> 这 3 件事都是 Xcode UI 操作，无法 AI 自动完成。完成后 `BuildProject` 即激活所有真实路径。

### A.1 添加微信 OpenSDK 1.8.2（Vendor 手动，与 daoqi msext 同款）

> **改用 1.8.2 的原因**：2.0.x 强制 Universal Link（HTTPS 域名 + AASA + 微信开放平台后台审核），运维负担大；daoqi msext 多年 1.8.2 上线稳定，沿用避免无谓改造。详见 `Vendor/WechatSDK/README.md`。
>
> SDK 已入库 `Vendor/WechatSDK/v1.8.2/`（4 个文件 + README，从 daoqi 同步），无需额外下载。

1. **删除（如有）2.0.5 xcframework**：Target → General → Frameworks → 选 `WechatOpenSDK-NoPay.xcframework` 点 `-` 删除（文件已从 git 移除）
2. **加 libWeChatSDK.a**：点 `+` → Add Other → Add Files → 选 `Vendor/WechatSDK/v1.8.2/libWeChatSDK.a` → **Do Not Embed**（静态库不能 embed）
3. **加 4 个 1.x 依赖系统库**：同位置 `+` 搜索后逐个加（均 Do Not Embed）：
   - `libz.tbd`
   - `libsqlite3.tbd`
   - `Security.framework`
   - `CFNetwork.framework`
4. **配置 Build Settings**：
   - Header Search Paths += `$(PROJECT_DIR)/Vendor/WechatSDK/v1.8.2`
   - Objective-C Bridging Header = `ylgamehall/ylgamehall-Bridging-Header.h`
5. ⌘B 编译 — **target 必须选真机**或 Generic iOS Device，fat 库不含 ios-arm64-simulator，M Mac 模拟器链接会失败（与 AMap 同款已接受现状）

### A.2 添加高德定位 framework × 2（Vendor 手动，无 SPM 替代）

> ⚠️ 高德官方至今未提供 SPM 包 / 也不提供 xcframework；只发布旧式 fat `.framework`，含 x86_64 + **device** arm64（**不含** simulator arm64）。
>
> **结果**：真机 build OK；M 芯片 Mac iOS-simulator 链接失败。**这是项目方接受的现状**——开发期跑真机，与 daoqi msext 同款实践。详细决策原因 / 不要做哪些事，见 `Vendor/AMap/README.md` 「已知限制」章节。

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**（关键，别错——静态 fat framework，Embed 会触发 `__OBJC` 段重复符号错）
5. **同一面板再 `+` 加 3 个系统 framework**（关键！漏一个真机链接器立即报 Undefined symbol）：
   - **SystemConfiguration.framework**
   - **CoreTelephony.framework**
   - **ExternalAccessory.framework**

   全部 **Do Not Embed**。详细原因见 `Vendor/AMap/README.md` 「工程接入步骤」第 5 步。
6. ⌘B 编译 — **target 必须选真机**或 Generic iOS Device，模拟器 target 会因 simulator-arm64 slice 缺失而链接失败（按设计）

### 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 编译

---

## B. 完成 A 三步后会发生什么

代码侧已经全部写好，并用 `#if canImport(...)` 守卫围着真实路径。一旦 Xcode UI 把 SDK 加进 Target，`canImport` 立刻返回 true，下面这些就会自动激活：

| 文件 | 激活后行为 |
|---|---|
| `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()` / `shareLobby()` → 剪贴板 + 引导框 + `weixin://` 跳转（不依赖 SDK 的分享 API，与 QQ / 抖音同款交互） |
| `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段（latitude/longitude string, province 小写 p） |
| `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` |

**Qiniu 单独说明**：七牛 SDK 加进 SPM 后，`canImport(QiniuSDK)` 通过；但 `RemoteAudioHandler` 仍然是 stub（prepareaudio 不真实录音上传），原因是录音→AMR 转码链路的 opencore-amr ObjC 库未接入。`QiniuUploader.upload(...)` 单独已可用，可用于其它非录音场景。完整录音上传链路见 §C。

---

## C. 仍需用户手动做的事（opencore-amr / AMR 录音上传）

> 这一块代码侧没有自动完成，因为需要从 msext 拷贝 ObjC 源码（含 MRC）+ 修改 Build Settings（File-level `-fno-objc-arc` flag）+ Bridging Header。这些 pbxproj 改动对 Xcode 26 同步项目风险较高，留用户手动。

### C.1 拷贝 opencore-amr 静态库

```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
```

### C.2 Xcode 工程配置

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`

### C.3 接入 Swift 录音 + 上传链路（代码侧）

完成 C.1-C.2 后回到代码侧补两个文件：

**`Source/Audio/VoiceCoder.swift`**（Swift wrapper）
```swift
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 }
}
```

**`Source/Audio/AudioRecorder.swift`**（actor）
```swift
import AVFoundation

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()` 注释。

---

## 0. 凭证总表（沿用 msext）

> 全部来自 daoqi/msext 原项目，**直接复用，无需重新申请**。

| 项 | 值 | 来源 |
|---|---|---|
| **Bundle ID** | `com.skyapp.ylgamehall` | `daoqi/msext.xcodeproj` Release |
| **App 显示名** | `进贤聚友棋牌` | `SGDefineInfo.h:103 gamehallname` |
| 微信 AppID | `wx586a9b321e56efb7` | **新外壳**:`Info.plist` CFBundleURLTypes (URLName=weixin) 唯一权威源 / msext:`SGDefineInfo.h:105 kAuthOpenID` |
| 微信 AppSecret | `b2792724b9565be23e8f5ba548f117cf` | **新外壳**:`AppSecrets.plist` 的 `wxAppSecret` / msext:`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` | **新外壳**:`AppSecrets.plist` 的 `qiniuAccessKey` / msext:`QiniuConfig.m:12` |
| 七牛 SecretKey | `RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq` | **新外壳**:`AppSecrets.plist` 的 `qiniuSecretKey` / msext:`QiniuConfig.m:13` |
| 七牛 BucketName | `iosaudio` | **新外壳**:RemoteConfig 顶层 `audio_bucket` 远端注入 / msext:`QiniuConfig.m:16` |
| 七牛 CDN Domain | `iosaudio.daoqi88.cn` | **新外壳**:RemoteConfig 顶层 `audio_domain` 远端注入 / msext:`qiniudomain/` 目录名 |

> **凭证位置三分制（新外壳 ADR-009）**：
> - **微信 AppID** 唯一源 = `Info.plist` 的 `CFBundleURLTypes`（iOS 系统级 URL Scheme，运行期不可注入）
> - **应用级凭证**（微信 AppSecret、七牛 AccessKey、七牛 SecretKey）唯一源 = `Resources/AppSecrets.plist`
> - **七牛运行参数**（CDN 域名、bucket 名）唯一源 = RemoteConfig 顶层 `audio_domain` / `audio_bucket`（启动期由 `WebContainerViewController` 注入 `QiniuConfig` actor；缺失即抛 `BootError.audioConfigMissing` 启动期致命）

### 0.1 安全权衡（与父项目 CLAUDE.md 同步）

父项目 CLAUDE.md 第 6 节明确指出 `AppSecret` / `SecretKey` 客户端硬编码是已识别但**接受不修**的安全风险。msext IPA 已分发数年等价泄露，新外壳沿用同一 key + 沿用同款客户端直拼路径 → **同等安全等级**，**不引入新的攻击面**，**不需要后台中转接口**。

---

## 1. 微信 OpenSDK（Phase 4.E）— 代码已就绪

### 1.1 凭证（已具备，沿用 msext）

参 §0 总表。

### 1.2 工程接入

参 §A.1（用户手动 Xcode 步骤）。

### 1.3 代码已落地

| 文件 | 状态 | 说明 |
|---|---|---|
| `Vendor/WechatSDK/WechatOpenSDK.xcframework` | ⏳ 等用户放置 | 项目方从腾讯开放平台下 `OpenSDK2.0.5+_NoPay.zip` 解压 → 放进 `Vendor/WechatSDK/` → 按 §A.1 Embed & Sign |
| `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport |
| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize async wrapper + WXApiDelegate（分享路径已剥离，走剪贴板见 `WechatShare`） |
| `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 |
| `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callback（Province 大写 P） |
| `Source/Share/WechatShare.swift` | ✅ 已改造 | 剪贴板 + 引导框 + `weixin://` 跳转（与 QQ / 抖音一致，不依赖微信 SDK 分享 API） |
| `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. 高德定位 SDK（Phase 5）— 代码已就绪

### 2.1 凭证（已具备）

参 §0 总表。APIKey 与 Bundle ID `com.skyapp.ylgamehall` 绑死，已复用。

### 2.2 工程接入

参 §A.2（用户手动 Xcode 步骤）。

### 2.3 代码已落地

| 文件 | 状态 |
|---|---|
| `Vendor/AMap/AMapFoundationKit.framework` + `AMapLocationKit.framework` | ✅ 已就位 |
| `Source/SDK/AMap/AMapWrapper.swift` | ✅ 已写 | 隐私合规 3 步 + apiKey 注入 |
| `Source/Location/LocationService.swift` | ✅ 已写 | actor，requestOnce 异步包装 + 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 中文文案 |

### 2.4 验收

- [ ] 首次启动 → 系统权限弹框（中文文案）
- [ ] H5 调 `startlocation` → 收 `getlocationinfo({...9 字段...})`，**latitude/longitude 是 string、province 小写 p**
- [ ] 拒绝权限 → H5 收 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
- [ ] 子游戏定位中 → 退出子游戏 → 大厅不再收悬挂定位回调

### 2.5 风险

- **APIKey 绑定 Bundle ID**：本项目已对齐 `com.skyapp.ylgamehall`，可复用。**改 Bundle ID 必须重新申请 key**
- **模拟器定位精度低**：必须真机测

---

## 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 文件
- [ ] 退出子游戏 → 进行中上传任务被 cancel（Swift Task.cancel 传播）

---

## 5. 接入完毕的清单检查

每接入完一个 SDK 跑一遍：

- [ ] `BuildProject` 成功
- [ ] `Vendor/<SDK>/README.md` 已记录版本号 / 来源 / 凭证沿用 msext 的备注 — 已就位
- [ ] Info.plist 改动只在原生侧，未触 H5 任何文件 — 已就位
- [ ] `BackGameDataHandler.swift` 对应清理钩子已加 — 微信 + 高德已加 canImport 守卫；录音待 §C
- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步
- [ ] `docs/Verification-Checklist.md` 对应 Phase 验证项已补充
- [ ] 真机跑过验收项（模拟器对微信 / 高德 / AVAudioRecorder 都不可靠）
- [ ] **Bundle ID 没改**（`com.skyapp.ylgamehall`），改了所有 key 失效

---

## 6. 与原项目 daoqi 的关系

接入过程中若行为与文档描述不一致，**优先参考原 daoqi 工程实现**（参 `CLAUDE.md`「原项目 daoqi：遇到问题时的参考来源」一节）：

- 微信 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`）
- 七牛 token 自签算法：`daoqi/msext/Class/Utils/QiniuManager.m:200-230`（putPolicy + HMAC-SHA1 + Base64URL）
- 七牛域名读取（msext 历史路径）：`daoqi/msext/AppDelegate.m:142` 用 `FuncPublic filename:@"qiniudomain"` 读 `Resources/qiniudomain/` 下第一个子目录名作为实际 Domain。**新外壳不再沿用此路径**，CDN 域名 + bucket 名走 RemoteConfig 顶层 `audio_domain` / `audio_bucket` 远端注入到 `QiniuConfig` actor（ADR-009）
