diff --git a/CLAUDE.md b/CLAUDE.md index 938e68d..c674a56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,7 @@ daoqi 仓库当前并存两条工作线: | `docs/H5-Native-Implementation-Design.md` | 新外壳实施蓝图(架构、模块、代码骨架) | 实现新外壳必读 | | `docs/Development-Plan.md` | 开发执行计划(Phase 0–10、依赖图、进度追踪) | 排期 / 进度更新必读 | | `docs/Verification-Checklist.md` | 功能验证清单(开发期累积、统一验收的操作手册) | 每完成 Phase 子项时补充 / 勾选;不要在 Phase 进行中频繁跑,等多个 Phase 累积后统一验证 | +| `docs/SDK-Integration-Guide.md` | 微信 / 高德定位 / opencore-amr / 七牛 接入操作手册(凭证 / Vendor / Info.plist / 代码触点 / BackGameData 清理 / 验收)| 项目方发 SDK 凭证 + 二进制后,按对应章节落地;接完回 §5 清单核对 | | `../daoqi/`(仓库同级目录) | 原项目 msext 工作参照实现 | 遇到 iOS 行为差异 / 渠道注入 / 桥接 / SDK 初始化等不确定问题时,**优先参考此处**而非凭推测,详见下文「原项目 daoqi:遇到问题时的参考来源」 | --- diff --git a/docs/SDK-Integration-Guide.md b/docs/SDK-Integration-Guide.md new file mode 100644 index 0000000..bb2cee8 --- /dev/null +++ b/docs/SDK-Integration-Guide.md @@ -0,0 +1,296 @@ +# SDK 接入指南 + +> 本文档面向项目方 + 开发者,描述 Phase 4.E / Phase 5 / Phase 3.B-D 三组 SDK 的真实接入步骤。 +> +> **使用方式**:每接入一个 SDK 走一遍对应章节的"凭证清单 / 工程接入 / 代码触点 / 清理钩子 / 验收"五步,全程对照 CLAUDE.md「原则 A — H5 端零修改」与 ADR-006「禁止 CocoaPods」。 + +--- + +## 0. 总览 + +| SDK | 触发的契约项 | 选型 | 阻塞 | +|---|---|---|---| +| **微信 OpenSDK** | `accreditlogin` / `friendsSharetypeUrlToptitleDescript`(微信路径)/ `sharelogin` 反向 callback / `sharesuccess` | Vendor `.xcframework` 手动 | AppID + Universal Links + 后台 `/wechat/login`(或客户端 fallback) | +| **高德定位(AMap)** | `startlocation` / `getlocationinfo` 反向 callback(9 字段)| 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 缓存策略 | + +**两条铁律(CLAUDE.md)**: +1. 任何接入步骤都不允许触碰 H5 端任何文件(包括 `app_*.js`) +2. 不引入 CocoaPods;SPM 优先,闭源走 Vendor `.xcframework` + +--- + +## 1. 微信 OpenSDK(Phase 4.E) + +### 1.1 凭证清单(项目方提供) + +- [ ] **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 + + CFBundleURLTypes + + + CFBundleURLName + weixin + CFBundleURLSchemes + YOUR_WX_APPID + + + + + LSApplicationQueriesSchemes + + weixin + weixinULAPI + weixinURLParamsAPI + + + ``` + +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_*` flag(Design §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 字段)`
**字段名严格 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 指针) | + +### 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 限频),上线初期保持后台监控 + +--- + +## 2. 高德定位 SDK(Phase 5) + +### 2.1 凭证清单 + +- [ ] **新 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) + +### 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 + NSLocationWhenInUseUsageDescription + {gamehallname}需要访问您的位置以提供本地化服务 + ``` + `{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`(一次定位)
`startContinuous(onUpdate:)` / `stop()`(持续定位)
包 `AMapLocationManager`,回调转 `async` | +| **`Source/Bridge/Handlers/StartLocationHandler.swift`** | 移除 stub,按 `data == 1` 持续 / 其它一次性
反向 callback `bridge.call("getlocationinfo", 9 字段)`
**字段名严格 1:1**:`address` / `city` / `cityCode` / `country` / `district` / `latitude`(**string**,`String(format: "%f", lat)`)/ `longitude`(**string**)/ `province`(**小写 p**!与 `sharelogin.Province` 不同)/ `street`
失败:`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})` | +| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `LocationService.shared.stop()` | + +### 2.4 清理钩子 + +`BackGameDataHandler.swift` 的 `MainActor.run` 块加: +```swift +LocationService.shared.stop() // ← Phase 5 接入时取消注释 +``` +理由:msext `gameController.m:2473-2478 cleanUpAction` 在 backgameData 里调 `[locationManager stopUpdatingLocation]` + delegate nil;防止 pop 后后台定位继续耗电 + 回调悬空。 + +### 2.5 验收 + +- [ ] 首次启动 → 系统权限弹框(文案为 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. **拷 VoiceConverter(ObjC wrapper)** + ``` + ylgamehall/Vendor/opencore-amr/VoiceConverter/ + ├── VoiceConverter.h # ObjC interface(保留 ObjC,wrapper 简单不重写 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 header,CLAUDE.md 父项目记录的修复同样适用) + - 若复用 msext MRC 的 `VoiceConverter.m`,加文件级 `-fno-objc-arc` 编译标志 + +4. **Bridging Header**(已有 `ylgamehall-Bridging-Header.h` 则追加;无则新建) + ```objc + #import "VoiceConverter.h" + ``` + +### 3.3 代码触点 + +| 文件 | 改动 | +|---|---| +| **`Source/Audio/VoiceCoder.swift`**(新建) | Swift wrapper:`static func amrToWav(_ amrPath: URL) throws -> URL` / `static func wavToAmr(_ wavPath: URL) throws -> URL`
内部调 ObjC `VoiceConverter` | +| **`Source/Audio/AudioRecorder.swift`**(新建,actor) | `record() async throws -> AudioFile`:`AVAudioRecorder` 录 WAV + 麦克风权限
录音参数与 msext `ChatVoiceRecorderVC` 等价:16kHz / 单声道 / 16-bit | +| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | 移除 stub,分别接入:
**`prepareaudio`**(录音上传)→ `AudioRecorder.record()` → WAV→AMR `VoiceCoder.wavToAmr` → `QiniuUploader.upload`(见 §4) → `bridge.call("getaudiourl", {audiourl, time})`,**子游戏页额外触发** `bridge.call("recordSuccess", {fileUrl, fileName, fileKey})`
**`mediaTypeAudio`**(远端回放)→ URLSession 下载 AMR → AMR→WAV `VoiceCoder.amrToWav` → `AVAudioPlayer` 播放
开始播放 `bridge.call("gameui_play_voice", user)` / 播放结束 `bridge.call("gameui_stop_voice", user)`
受 `VoicePlayingHandler` 的开关短路(=1 才播) | +| **`Info.plist`** | 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"` | +| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `AudioRecorder.shared.cancel()` | + +### 3.4 清理钩子 + +```swift +AudioRecorder.shared.cancel() // ← Phase 3.C 接入时取消注释 +QiniuUploader.shared.cancelInFlight() // ← Phase 3.C 接入时取消注释 +``` +理由:录音 / 上传任务跨 VC 生命周期容易遗留;msext 通过 `ChatVoiceRecorderVC` 生命周期间接清理,我们的 actor 实现不绑 VC 必须显式 cancel。 + +### 3.5 验收 + +- [ ] H5 调 `prepareaudio` → 弹麦克风权限 → 同意后开始录音 → 录完上传七牛 → 大厅收 `getaudiourl({audiourl:"https://...", time:"3"})` +- [ ] 子游戏同样路径,额外收到 `recordSuccess({fileUrl, fileName, fileKey})` +- [ ] H5 调 `mediaTypeAudio({audiourl, user})` → 听到对方语音 + 收到 `gameui_play_voice` / 结束收 `gameui_stop_voice` +- [ ] `voicePlaying` 总开关设 0 → 调 `mediaTypeAudio` 不播放 + +--- + +## 4. 七牛云上传(Phase 3.C / 3.D) + +### 4.1 凭证清单 + +- [ ] **后台 token 颁发接口**:每次录音前向后台 GET `/api/qiniu/upload-token?scene=voice` 拿到上传凭证 + 域名 +- [ ] **七牛 Bucket 名 / CDN 域名**:项目方提供(可硬编到 BundleConfig,或随 ChannelConfig 注入) + +### 4.2 工程接入(SPM,优于二进制) + +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 + +注:七牛官方 SPM 包是 ObjC,但 SPM 直接支持 ObjC + Swift 混合,不需要 bridging header(SPM 自动处理)。 + +### 4.3 代码触点 + +| 文件 | 改动 | +|---|---| +| **`Source/Network/QiniuTokenService.swift`**(新建,actor) | `fetchUploadToken() async throws -> QiniuToken`(含 token / 上传域名 / Key 模板 / 过期时间)
缓存策略:token 失效前 30 秒强制刷新;并发请求合并 | +| **`Source/Network/QiniuUploader.swift`**(新建,actor) | `upload(_ file: URL, token: QiniuToken) async throws -> UploadedFile`
内部用 `QNUploadManager` 包成 async;UploadedFile 含 `fileUrl` / `fileName` / `fileKey`
取消语义:`cancelInFlight()` 取消所有进行中任务(接清理钩子用) | +| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | `prepareaudio` 完整链路第 3 步:`QiniuUploader.upload(amrFile, token: await QiniuTokenService.shared.fetchUploadToken())` | + +### 4.4 清理钩子 + +见 §3.4(与 opencore-amr 同一处加 `QiniuUploader.shared.cancelInFlight()`)。 + +### 4.5 验收 + +- [ ] H5 调 `prepareaudio` → 录音 3 秒 → 上传 → 大厅收 `getaudiourl({audiourl:"https://your-cdn.com/voice/xxx.amr", time:"3"})` +- [ ] 录音中 / 上传中 → 退出子游戏 → 任务被 cancel,不再有悬空网络写 +- [ ] token 过期场景:模拟后台返回过期 token → uploader 自动刷新重试一次 + +### 4.6 风险 + +- **token 时效**:通常 1 小时;高频录音场景需要 caching + 失效前预刷新 +- **上传失败重试**:默认 3 次,超时建议设 30s(语音文件通常 < 100KB) +- **CDN 域名 HTTPS**:H5 业务侧拿到 `audiourl` 后会请求播放,必须保证域名 HTTPS(NSAppTransportSecurity 我们没收紧 ATS,但七牛默认走 HTTPS) + +--- + +## 5. 接入完毕的清单检查 + +每接入完一个 SDK,跑一遍: + +- [ ] `BuildProject` 成功 +- [ ] `Vendor//README.md` 已记录版本号 / 来源 / 凭证创建日期 +- [ ] Info.plist 改动只在原生侧,未触 H5 任何文件 +- [ ] `BackGameDataHandler.swift` 对应清理钩子已加(每个 SDK 都要回到此处补 1 行) +- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步(提交时附 commit 备注「Plan 进度已勾选」) +- [ ] `docs/Verification-Checklist.md` 对应 Phase 验证项已补充 +- [ ] 真机跑过该 SDK 的验收项(模拟器对 SDK 行为不可靠:高德、微信、AVAudioRecorder 都受影响) + +--- + +## 6. 与原项目 daoqi 的关系 + +接入过程中若行为与文档描述不一致,**优先参考原 daoqi 工程实现**(参 `CLAUDE.md`「原项目 daoqi:遇到问题时的参考来源」一节): + +- 微信回调字段:`daoqi/msext/Class/RootVC/NewRootVC.m:2428`(`sharelogin` 7 字段,含 `Province` 大写) +- 高德 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「典型案例」积累若干)。