Files
youle_app_ios_v2/docs/Development-Plan.md
T

883 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 进贤聚友棋牌 iOS 新外壳开发计划
> 配套文档:
> - `docs/H5-Native-Implementation-Design.md` — 架构蓝图(**WHAT to build / HOW to design**
> - `docs/H5-Native-Contract.md` — H5↔原生契约(**外部可观察行为,验收以此为准**)
>
> 本文档:执行图(**WHEN / IN WHAT ORDER / ACCEPTANCE CRITERIA**),随实施持续更新。
>
> 起点:M0 部分完成(项目基础配置就绪);终点:通过契约 §10 全部 26 项验收清单后灰度上线。
---
## 1. 文档三角关系
```
Contract Design Plan(本文档)
↓ ↓ ↓
契约边界 架构蓝图 执行路线
"必须长这样" "推荐怎么做" "按这个顺序做"
不可越界 可自由重构 随进度更新
```
- **Contract 与 Design 是稳定文档**,除非契约或架构本身要变更,否则不动
- **Plan 是活文档**,每完成一个 Phase 就在 §8 进度追踪里勾掉、把后续 Phase 调整为新现实
- **冲突时优先级**Contract > Design > Plan(不能为了赶进度违背契约)
---
## 2. 起点:当前项目状态快照(2026-06-21)
### 2.1 已完成
- [x] Xcode 工程骨架(File System Synchronized GroupobjectVersion 77Xcode 26.5
- [x] iOS Deployment Target 15.6
- [x] Swift 6.0 + `SWIFT_APPROACHABLE_CONCURRENCY=YES` + `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor`
- [x] iPhone + iPad 仅横屏,`UIRequiresFullScreen=true`
- [x] 删除默认 Storyboard,改用 `SceneDelegate` 代码启动 → `RootViewController`M0 占位)
- [x] Info.plist 契约前置项:`UIStatusBarHidden=NO` / `UIViewControllerBasedStatusBarAppearance=YES`
- [x] `AppDelegate` 设置 `applicationSupportsShakeToEdit=true`
- [x] `.gitignore`(标准 iOS/Swift 模板,含签名 / 敏感配置 / xcuserdata
- [x] CLAUDE.md 加入"及时提交"规则
- [x] BuildProject 验证通过
### 2.2 已就位的项目方资源
仓库根 `Resources/` 已包含(详见 CLAUDE.md「Resources/ 目录约定」与 Design §7.0):
-`Resources/gamehall.zip`(2023-12 旧版,开发期足够,上线前替换最新版)
-`Resources/Images.xcassets`AppIcon / LaunchImage / 分享平台图标)
-`Resources/Res/``sharelogo.png` / `shake_sound_male.mp3` 等散落原生资源)
→ Phase 1 不再阻塞于 H5 团队,可立即展开。
### 2.3 待项目方协调的外部资源(阻塞项)
| 资源 | 用途 | 阻塞起始 Phase | 协调对象 |
|------|------|--------------|---------|
| `gamehall.zip` **最新版**(上线前替换) | 真实 H5 业务代码 | Phase 10 灰度前 | H5/前端团队 |
| 11 个渠道注入目录的目录名值 | 渠道 / 游戏 ID / 七牛域名 / market 等 | Phase 1 | 项目方/运营 |
| 微信 OpenSDK `.framework` | 登录 / 分享 | Phase 4 | 项目方(可从 msext/Vendor 拷贝) |
| 微信 AppID`wx586a9b321e56efb7`+ Universal Links 配置 | 微信回调 | Phase 4 | 项目方 |
| QQ OpenSDK `.framework` + AppID | QQ 分享 | Phase 4 | 项目方 |
| 后台 `/wechat/login` 中转接口 | secret 不入 IPA 的前提 | Phase 4 | 后台团队 |
| 高德地图 `APIKey` + SDK Pod | 定位 | Phase 5 | 已有 key `b0d4a8e3fcbbcc0dd96283b7df6a4494`,但需新建 Bundle ID 注册 |
| 七牛上传 token 颁发接口 | 录音上传 | Phase 3 | 后台团队 |
| 极光 AppKey | 用户统计 | Phase 9 | 项目方 |
| Sentry DSN | 崩溃监控 | Phase 9 | 项目方(需开通账号) |
| Agora AppID(视频房间) | 子游戏视频 | Phase 8(默认 stub,可延后) | 项目方 |
| 闲聊 SDK | 闲聊分享 | Phase 9(默认 stub | 项目方 |
| 开发 / 企业签名证书 + 描述文件 | 真机调试 / 分发 | Phase 1 起持续 | 项目方/iOS 账号管理员 |
> **协调原则**:当某 Phase 阻塞在外部资源时,**先做不依赖该资源的 Phase**(并行机会见 §3 依赖图)。
### 2.4 已确定的项目约束
| 项 | 值 |
|---|---|
| H5 设计分辨率 | **1280 × 72016:9** |
| WebView 适配策略 | 保持 16:9 比例(设备比例不匹配时上下/左右 letterbox |
| Bundle ID | `com.skyapp.ylgamehall`(与 msext 分离) |
| Team ID | NX5W3B4QP3 |
| 设备 | iPhone + iPad(不去 iPad |
| 支持方向 | 仅横屏(LandscapeLeft / LandscapeRight |
| Swift 版本 | 6.0 |
| 最低 iOS | 15.6 |
---
## 3. 全局依赖图
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 0 工程化基线(剩余项:SwiftLint / 测试 target / CI
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 1 资源层 + 桥核心 + 第一个 handler │
│ ResourceKit / BundleConfig / BridgeCore / BridgedWebView │
│ 验收:H5 加载 + 一个简单 handler 双向通 │
└─────────────────────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Phase 2 │ │ Phase 3 │ │ Phase 5 │
│ 简单 │ │ 音频体系 │ │ 定位 │
│ handler │ │ │ │ │
│ + 反向cb │ │ │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────┴─────────────┘
│ 并行可行
┌─────────────────────────────────────────────────────────────┐
│ Phase 4 分享 + 登录(前置:项目方提供 SDK / 后台接口) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 6 子游戏容器(SwitchOverGameData / backgameData
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 7 弹层(Overlay + window.settings polyfill
│ 完成后契约 §10 A/B/C/E 节可全跑通 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 8 视频房间(默认 NoopVideoRoomAgora 启用是开关) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 9 SDK 真实化(Sentry / 极光)+ Stub 替换为真实 SDK │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 10 多渠道打包 + 真机回归 + 灰度切流 │
└─────────────────────────────────────────────────────────────┘
```
**关键并行机会**Phase 2 / 3 / 5 互不依赖,可按当下手头的资源到位情况自由调度。
---
## 4. Phase 总览
| Phase | 目标 | 关键产出 | 验收来源 | 相对成本 |
|-------|------|---------|---------|---------|
| **P0** | 工程化基线 | SwiftLint、单测 target、CI 流水线 | 本文档 §5.0 | 小 |
| **P1** | 最小垂直闭环 | `BundleConfig` / `ResourceUnzipper` / `BridgeCore` / `BridgedWebView` + `vibrator` handler | 契约 §10 中 `vibrator` 项;H5 file:// 加载成功 | 大 |
| **P2** | 大厅简单 handler + 反向 callback | `DeviceKit` / `Pasteboard` / 振动 / 摇一摇 / 网络 / 电池 / 前后台 | 契约 §10 B 节 + C 节 | 中 |
| **P3** | 音频体系 | `AudioKit`(播放 / 录音 / AMR↔WAV / 七牛上传) | 契约 §10 中 `srcIsloop` / `mediaTypeAudio` / `prepareaudio` 项 | 大 |
| **P4** | 分享 + 登录 | `LoginKit`WeChat OAuth/ `ShareKit`WeChat + Noop 闲聊) | 契约 §10 B 节 `accreditlogin` / `friendsShare...` 项 | 大 |
| **P5** | 定位 | `LocationKit`(高德 SDK 封装) | 契约 §10 B 节 `startlocation` 项 | 中 |
| **P6** | 子游戏容器 | `SubGameViewController` + `Coordinator` + `SwitchOverGameData` / `backgameData` | 契约 §10 中 `SwitchOverGameData` 项 | 中 |
| **P7** | 弹层 | `OverlayViewController` + `window.settings` polyfill + 节流 | 契约 §10 中 `OpenurlTitleData` / `settings.*` 项 | 中 |
| **P8** | 视频房间 stub | 3 个 stub handler + Native→H5 `getVideoinfo` / `phonestate` / `recordSuccess` callback | 契约 §10 D 节(NoopVideoRoom 路径) | 小 |
| **P9** | SDK 真实化 + 监控 | Sentry / 极光 / 七牛 SPM 接入;闲聊 / Bugly 决策落地 | 契约 §10 全 26 项 | 中 |
| **P10** | 多渠道 + 灰度 | 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 | 真机验收 + 灰度无回退 | 大 |
**相对成本说明**
- 小:1 个工作日内可独立完成
- 中:24 个工作日,含联调
- 大:5–10 个工作日,跨多个模块或需对外联调
---
## 5. 各 Phase 详细计划
### Phase 0: 工程化基线(剩余项)
#### 目标
建立项目级别的代码质量与回归基线,使后续 Phase 每个 commit 都可被自动验证。
#### 前置
无。当前 M0 基础配置已完成。
#### 任务清单
- [ ] **0.1** 加入 SwiftLint(SPM 插件方式,避免全局安装依赖)
- `Package.swift``SwiftLintPlugin`;规则文件 `.swiftlint.yml` 按 Design §13.4 配(行长 120、文件 ≤400、函数 ≤40)
- 验收:`xcodebuild` 时 lint 自动跑,违规 warning 出现
- [ ] **0.2** 新建 `ylgamehallTests` 单测 targetTesting framework,不引 XCTest 旧 API
- 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`,确认 `applicationSupportsShakeToEdit=true` 已生效
- 验收:`xcodebuild test` 通过
- [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target(与 unit test 分离,跑得久也无所谓)
- 暂只放空骨架,Phase 1 起逐项填入
- [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 targetXCUI
- 暂只放空骨架,Phase 10 真机回归用
- [ ] **0.5** 加 CI 配置(GitHub Actions 或本地 Jenkinsfile,按项目方实际 CI 平台)
- 跑:`xcodebuild test -scheme ylgamehall` + lint
- 验收:push 后 CI 绿
- [ ] **0.6** 准备 SPM 包目录结构(**先建空 `Package.swift` 但暂不切 target**
- 决策:先单 target 内按目录组织,等 P3 之后代码量 > 5K 行再切 SPM
- 这样早期开发不被 SPM 切分成本拖慢
#### 验收
- `xcodebuild test` 通过;CI 绿;SwiftLint 报告 0 违规
#### 风险
- 单测 / UI 测 target 的 signing 需配置(自动签 + 不同 Bundle ID 后缀)
---
### Phase 1: 最小垂直闭环(资源 + 桥 + 第一个 handler
#### 目标
打通**从启动 → 渠道注入读取 → 解压 H5 → 加载 file:// → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。
#### 前置
- ✅ Phase 0 基线
-`Resources/gamehall.zip`2023-12 旧版,足以验收 Phase 1
- ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值)
- 备选:若 zip 加载有问题,临时用一个最小 H5(`<html>` 含一个 button 调 `bridge.callHandler('vibrator')`)做工程内测排查
#### 任务清单
##### 1.A 资源层
- [ ] **1.1** 创建 `Resources/ChannelInjection/` 目录结构(11 个空容器目录占位)
- `qiniudomain/<待填>``gameid/<待填>` 等 11 项
- 文件夹下放 `.gitkeep`
- [ ] **1.2** 实现 `Source/Resource/BundleConfig.swift`
- `static func readInjected(_ key: String) -> String`:扫描 Bundle 子目录,返回首个非隐藏子项名
- 单测 fixture:建立 mock bundle,验证 11 个 key 全部能读出
- [ ] **1.3** 实现 `Source/Resource/SandboxPaths.swift`
- 常量:`caches` / `documents` / `bundle`
- `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html`
- [ ] **1.4** 加 ZIPFoundation SPM 依赖
- [ ] **1.5** 实现 `Source/Resource/ResourceUnzipper.swift`actor
- `ensureReady() async throws`:检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches
- 单测:用 fixture zip 验证解压幂等性
##### 1.B 桥核心
- [ ] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift`
- `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型
- [ ] **1.7** 实现 `Source/Bridge/BridgeBus.swift`@MainActor
- `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)`
- 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对
- [ ] **1.8** 引入 WVJB JS 端协议源码
- 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`,作为 `Resources/JS/WebViewJavascriptBridge.js`
- 注入方式:`WKUserScript``atDocumentStart`
##### 1.C WebView 容器
- [ ] **1.9** 实现 `Source/WebView/BridgedWebView.swift`
- WKWebView 配置照 Contract §4.1`javaScriptCanOpenWindowsAutomatically=NO``minimumFontSize=10``bounces=NO``scrollEnabled=NO` 等)
- `WKProcessPool` 单例 `SharedProcessPool.shared`
- [ ] **1.10** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
- 持有 `BridgedWebView` + `BridgeBus`
- 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边
- 实现 `webViewWebContentProcessDidTerminate:` 退避 reloadDesign §3.6
- [ ] **1.11** 实现 `Source/Bridge/Handlers/VibratorHandler.swift`
- `register(_ bridge)` 注册 `vibrator` handler → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` + responseCallback `"vibrator"`
##### 1.D 集成
- [ ] **1.12** `SceneDelegate``LobbyViewController`(继承 `WebContainerViewController`
- viewDidLoad: `ResourceUnzipper.ensureReady()` await 完成后 → `BridgedWebView.load(SandboxPaths.lobbyIndex())`
- [ ] **1.13** 把渠道目录里临时填入的 demo 值(如 `gamedir/demo` / `gamestart/gamehall`+ 一个 demo H5 跑通
#### 验收
- 真机启动后能看到 H5 demo 页
- H5 按钮调 `bridge.callHandler('vibrator')` 后真机震动
- H5 收到 `responseCallback("vibrator")`
- BuildProject 通过;契约测试 `VibratorContractTest` 通过
#### 风险
- WVJB JS 端协议握手时序对加载顺序敏感,必须用 `WKUserScript` 在 documentStart 注入
- iPad letterbox 实现细节:用 `aspectRatio` constraint vs 手算 frame;推荐用 Auto Layout `aspectRatio=16/9` + centerX/centerY
---
### Phase 2: 大厅简单 handler + 反向 callback
#### 目标
把不依赖外部 SDK 的 11 个 handler 全部实现,使大厅 H5 在不接微信/七牛/高德的前提下能完整跑大部分业务。
#### 前置
- Phase 1 完成
#### 任务清单
##### 2.A H5→Native handler(无外部依赖)
- [ ] **2.1** `gameCopytext` / `gamepastetext`(剪贴板)
- [ ] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`(已有,扩展)
- [ ] **2.3** `startshake` / `stopshake` / `SwitchShake`(摇一摇开关 + 音效开关)
- [ ] **2.4** `voicePlaying`(语音播放总开关)
- [ ] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`(注意小写 i
- [ ] **2.6** `browser` → 系统 Safari 打开 URL
- [ ] **2.7** `opensaoma` 空实现注册(契约 §3.1【22】要求)
##### 2.B Native→H5 反向 callback
- [ ] **2.8** `Source/Device/DeviceInfo.swift`6 字段 snapshot
- `getphoneInfo` 收到调用后 → `bridge.call("getphoneinfo", data: ...)` 反向
- [ ] **2.9** `Source/Device/BatteryMonitor.swift`:监听 `UIDeviceBatteryLevelDidChangeNotification`
- 触发 → `bridge.call("getBattery", data: .string("%.2f"))`
- [ ] **2.10** `Source/Device/NetworkMonitor.swift``NWPathMonitor` 包装
- 状态变化 → `bridge.call("getnetwork", data: .string("1"/"2"/"3"))`
- [ ] **2.11** `Source/Device/AppLifecycleObserver.swift`:监听 SceneDelegate 前后台通知
-`bridge.call("appservice", data: .string("1"=后台 / "2"=前台))`
- [ ] **2.12** `Source/WebView/ShakeDetector.swift`:在 `WebContainerViewController` 重写 `motionEnded:`
-`bridge.call("shakeEnd", data: nil)`(仅 `canshake=YES` 时触发)
##### 2.C 外部订阅生命周期
- [ ] **2.13** 实现 Design §2.4.2 的 `ExternalSubscriptions` 模式
- `viewWillAppear` resume / `viewWillDisappear` suspend
- 防双发:栈深 ≥ 2 时下层不发桥事件
#### 验收
- 契约 §10 B 节中以下项目通过:
- `getphoneInfo` 6 字段完整
- `gameCopytext` + `gamepastetext`
- `vibrator` / `repeatvibrator`
- 契约 §10 C 节全部通过:
- 前后台切换 `appservice("1"/"2")`
- 电量变化 `getBattery("0.XX")`
- 飞行模式切换 `getnetwork("1"/"2"/"3")`
- 摇一摇 `shakeEnd`
#### 风险
- `motionEnded:` 需要 `become first responder`,在 `viewDidAppear` 内调用 `becomeFirstResponder()`
- iOS 14+ `WKWebView.scrollView.bounces` 在某些版本被重置,需 viewWillAppear 内重新设置
- `NWPathMonitor` 必须保持引用,否则会被释放停止监听
---
### Phase 3: 音频体系
#### 目标
打通本地音频播放 / 远程语音播放 / 录音 → AMR 转码 → 七牛上传的完整链路。
#### 前置
- Phase 1 完成
- ⏳ 项目方提供:opencore-amr 静态库(可从 msext 拷贝)+ 七牛上传 token 颁发接口
#### 任务清单
##### 3.A 本地音频
- [ ] **3.1** `Source/Audio/AudioPlayer.swift`@MainActor
- `background` / `button` / `voice` 三类 AVAudioPlayer
- `srcIsloop` handler`isloop=0` 单次 / `isloop=1` 循环背景 / `isloop=-1` 停同名背景
- [ ] **3.2** 真机验证:放一个 `.wav` 进 ResourcesH5 调 `srcIsloop({src:"test.wav", isloop:0})` 出声
##### 3.B AMR 转码
- [ ] **3.3** 从 msext 拷贝 `libopencore-amrnb.a` / `libopencore-amrwb.a`(已 segalign 8 修复版)到 `Vendor/`
- [ ] **3.4** 拷贝 `VoiceConverter` 头文件 + 实现 → 改写为 `Source/Audio/VoiceCoder.swift` Swift wrapper
- `amrToWav(_:dest:)` / `wavToAmr(_:dest:)`
- 单测:fixture amr → 转 wav → 转回 amr,比较前后 hash
##### 3.C 录音 + 上传
- [ ] **3.5** `Source/Audio/AudioRecorder.swift`actor
- `record() async throws -> AudioFile``AVAudioRecorder` 录 WAV
- 麦克风权限:Info.plist 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"`
- [ ] **3.6** 七牛 SPM 依赖(v8++ `Source/Network/QiniuUploader.swift`
- `upload(_:token:) async throws -> UploadedFile`
- 单测:mock token,模拟上传成功 / 失败
- [ ] **3.7** `prepareaudio` handler:拉起录音 → 停止录音 → AMR 转码 → 上传 → 反向 callback `getaudiourl` + `recordSuccess`(仅子游戏触发)
##### 3.D 远程语音回放
- [ ] **3.8** `mediaTypeAudio` handler:下载 AMR → 转 WAV → `AVAudioPlayer` 播放
- 开始播放 → `bridge.call("gameui_play_voice", user)`
- 播放结束 → `bridge.call("gameui_stop_voice", user)`
-`voicePlaying` 开关控制(=1 才播)
#### 验收
- 契约 §10 B 节中:
- `srcIsloop` 背景音循环 / 停止
- `mediaTypeAudio` 远端播放 + play/stop callback
- `prepareaudio` → 录音 → 上传 → `getaudiourl({audiourl, time})`
#### 风险
- 麦克风权限被拒后的 UI 兜底(H5 alert 提示,契约 §3.1【4】)
- 七牛 token 时效性:每次录音前后端动态颁发 vs 长 token 缓存
- AVAudioSession 与背景音 / 通话音的混音规则(`.playAndRecord` mode
---
### Phase 4: 分享 + 登录
#### 目标
微信授权登录 + 微信好友/朋友圈分享 + 闲聊分享(Noop stub)。
#### 前置
- Phase 1 完成
- ⏳ 微信 OpenSDK `.framework` + AppID + Universal Links
- ⏳ QQ OpenSDK `.framework` + AppID
- ⏳ 后台 `/wechat/login` 接口(或先用客户端直拼 fallback)
#### 任务清单
##### 4.A SDK 接入
- [ ] **4.1** `Vendor/WechatSDK/` 拷贝微信 SDK
- Info.plist 加 URL Scheme`wx586a9b321e56efb7`+ `LSApplicationQueriesSchemes``weixin` / `weixinULAPI` / `weixinURLParamsAPI`
- Entitlement 加 Associated DomainsUniversal Links
- [ ] **4.2** `Vendor/QQShare/` 拷贝 QQ SDK
- Info.plist 加 QQ URL Scheme + `LSApplicationQueriesSchemes``mqq*`
- [ ] **4.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册(Design §4.2 `registerFull` + 全部 12 个 `MMAPP_SUPPORT_*` flag
- [ ] **4.4** `Source/SDK/WeChat/WeChatManager.swift`@MainActor
- 持久 delegate
- `authorize() async throws -> WXAuthCode`state UUID 配对)
- `share(_:scene:) async throws`FIFO 串行)
- 详见 Design §8.5
- [ ] **4.5** `SceneDelegate.openURLContexts` 接入 QQ → WXApi 顺序
##### 4.B 授权登录
- [ ] **4.6** `Source/Login/WeChatAuth.swift`actor
- `authorize() async throws -> WeChatUser`:内部调 `WeChatManager.authorize()` 拿 code → 转给后台 `/wechat/login`(或客户端 fallback)→ 拿到 7 字段 user
- 后台未就绪时的 fallback:客户端直拼 `api.weixin.qq.com/sns/oauth2/access_token` —— 文件头标 `// FIXME: 待后台 /wechat/login 就绪后切换为后台中转`
- [ ] **4.7** `accreditlogin` handler:触发 OAuth → 反向 callback `sharelogin`7 字段,注意 `Province` 大写 P
- **关键契约测试**`sharelogin` payload 字段名严格 1:1
##### 4.C 分享
- [ ] **4.8** `Source/Share/SharePlatform.swift` 协议 + `WeChatShare` / `NoopSharePlatform(name:"xianliao")`
- [ ] **4.9** `Source/Share/ShareCenter.swift`:策略分发(type=1/2/3 × sharetype=1/2/3,共 9 种组合)
- [ ] **4.10** `friendsSharetypeUrlToptitleDescript` handler:参数解析 → ShareCenter.dispatch → 反向 callback `sharesuccess({success:"2", type:<原 sharefriend>})`
- [ ] **4.11** 截图分享:`getImageWithFullScreenshot` Swift 等价实现(`UIGraphicsImageRenderer`
- [ ] **4.12** 远程图片分享:URLSession 下载 → 重打包
#### 验收
- 契约 §10 B 节:
- `accreditlogin` 收到 7 字段 `sharelogin`Province 大写)
- `friendsSharetypeUrlToptitleDescript` type=1/2/3 各跑一次,收到 `sharesuccess`
- 契约 §10 E 节:微信 / QQ 回调命中(QQ 必须先判)
#### 风险
- 微信审核:Universal Links 配置错误 → 授权回不来
- AppSecret 客户端泄露 = 严重安全问题;fallback 路径上线前必须切到后台
- 截图分享在 Liquid Glass / 新版 UIKit 下 `drawHierarchy` API 行为变化,需查 DocumentationSearch 验证
---
### Phase 5: 定位
#### 目标
高德定位 + 逆地理 → 反向 callback `getlocationinfo` 9 字段。
#### 前置
- Phase 1 完成
- ⏳ 高德 SDKCocoaPods 接入,APIKey 已有但 Bundle ID 需重新注册)
#### 任务清单
- [ ] **5.1** 加 Podfile + 引入 `AMapLocation` 9.x
- [ ] **5.2** Info.plist 加 `NSLocationWhenInUseUsageDescription = "{gamehallname}需要访问您的位置以提供本地化服务"`
- [ ] **5.3** `Source/SDK/AMap/AMapWrapper.swift`:启动期 `updatePrivacyShow` + `updatePrivacyAgree` + `apiKey` 设置
- [ ] **5.4** `Source/Location/LocationService.swift`actor
- `requestOnce() async throws -> LocationPayload`
- `startContinuous(onUpdate:)` / `stop()`
- [ ] **5.5** `startlocation` handler`data=1` 持续 / 其他一次性 → 反向 callback `getlocationinfo` 9 字段
- **关键契约**`latitude` / `longitude`**string**`stringWithFormat:%f` 等价产物)
- **关键契约**`province` 是小写 p(与 `sharelogin.Province` 大写不同)
- [ ] **5.6** 失败路径:`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
#### 验收
- 契约 §10 B 节 `startlocation` → 9 字段完整
- 拒绝权限后 H5 收到 errorCode 12
#### 风险
- 高德 Bundle ID 绑定:APIKey 是绑死 Bundle ID 的,必须用项目方控制台重新申请(不能复用 msext 的 key)
---
### Phase 6: 子游戏容器
#### 目标
大厅 push 子游戏,子游戏返回大厅,`getWebdata` 链路打通。
#### 前置
- Phase 1 完成(大厅可加载)
- Phase 2/3/4/5 不强制,但子游戏页同样需要 19 个共享 handler
#### 任务清单
- [ ] **6.1** `Source/Coordinator/AppCoordinator.swift`path 节流 + 栈深约束(Design §2.4.3
- [ ] **6.2** `Source/Containers/SubGameViewController.swift`:继承 WebContainer,注入 SubGameHandlers
- [ ] **6.3** `Source/Bridge/Handlers/SubGameHandlers.swift`19 共享 + `backgameData` + 3 个视频房间 stub
- [ ] **6.4** `SwitchOverGameData` handler(仅大厅注册):解参 → AppCoordinator.showSubGame → 节流 2s
- [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame
- [ ] **6.6** 大厅监听 `.subGameDidReturn``bridge.call("getWebdata", data)`
- [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`
- `Source/Resource/SubGameDownloader.swift`URLSession + ZIPFoundation
- 缓存策略:`Library/Caches/{Gamedirectory}/` 已存在则跳过
#### 验收
- 契约 §10 B 节 `SwitchOverGameData` → push 子游戏 → 子游戏调 `backgameData` → 回大厅 → 大厅收 `getWebdata`
- 栈深永远 ≤ 3Lobby + SubGame + Overlay
#### 风险
- 子游戏 zip 下载失败兜底:超时 / 网络断 / hash 校验失败 → 弹错误页且 pop 回大厅
- 大厅 + 子游戏并存时双发桥事件(已由 Phase 2 ExternalSubscriptions 解决)
---
### Phase 7: 弹层(Overlay
#### 目标
H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settings.*` 三接口操作,关闭后大厅收 `getWebdata`
#### 前置
- Phase 1 完成
#### 任务清单
- [ ] **7.1** `Source/Containers/OverlayViewController.swift`:继承 WebContainer,但 WKWebsiteDataStore 用 `.nonPersistent()` 隔离
- [ ] **7.2** `Source/WebView/OverlayBridge.swift` polyfill JS`window.settings = {backgameData, browser, finishweb}``webkit.messageHandlers.*`
- [ ] **7.3** `WKScriptMessageHandler` 注册三 handler`overlayBackgameData` / `overlayBrowser` / `overlayFinishweb`
- [ ] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(注意 `"title "` 末尾空格契约) → AppCoordinator.showOverlay → 3 秒节流
- [ ] **7.5** Overlay backgameData → 发 `.subGameDidReturn` → 上层 H5 收 `getWebdata`
- [ ] **7.6** Overlay `finishweb` → coordinator.popOverlay
- [ ] **7.7** Overlay `browser` → 系统 Safari 打开
#### 验收
- 契约 §10 B 节 `OpenurlTitleData` 完整链路 + `settings.finishweb()` 关闭 + `settings.backgameData(data)` 回传
#### 风险
- 弹层第三方外链 Cookie 不污染主业务态(`.nonPersistent()` 已隔离)
- 3 秒节流时间戳由谁持有(`OpenUrlHandler` 内部 actor 状态)
---
### Phase 8: 视频房间 stub
#### 目标
注册 3 个视频房间 handler 的 stub 实现 + 子游戏独有 3 个反向 callback,以满足契约边界 —— 即使业务暂不上视频,H5 也不会报错。
#### 前置
- Phase 6 完成(子游戏容器)
#### 任务清单
- [ ] **8.1** `Source/VideoRoom/VideoRoom.swift` 协议
- [ ] **8.2** `Source/VideoRoom/NoopVideoRoom.swift` 实现(log + 不动)
- [ ] **8.3** `SubGameHandlers` 注入 NoopVideoRoom,注册:
- `createRoom` stub → `cb("createRoom")`
- `getVideoinfo` stub → `cb("getVideoinfo")`
- `exitRoom` stub → `cb("exitRoom")`
- [ ] **8.4** `Source/Telephony/CallCenterMonitor.swift`CXCallObserver 监听通话状态
- 来电 → `bridge.call("phonestate", "2")`
- 挂断 → `bridge.call("phonestate", "0")`
- [ ] **8.5** `RecordUploader.onUploaded` 已在 Phase 3,需要在 SubGameHandlers 里**额外**触发 `recordSuccess({fileUrl, fileName, fileKey})`(契约 §3.2 表 [15]
- [ ] **8.6** `AgoraVideoRoom.swift` 留蓝图骨架(`#if AGORA_ENABLED` 包裹,默认 OFF
#### 验收
- 契约 §10 D 节中:
- `createRoom` / `exitRoom` H5 调用不报 "bridge not found"
- 子游戏接电话 → H5 收 `phonestate("2")` / `phonestate("0")`
- 子游戏录音上传 → H5 收 `getaudiourl` **AND** `recordSuccess`(双 callback
#### 风险
- `CXCallObserver` 在模拟器无效,必须真机测
- 视频房间真要启用时再走 §8.6 的 AgoraVideoRoom 实现路径
---
### Phase 9: SDK 真实化 + 监控接入
#### 目标
把所有 Noop / stub 替换成真实 SDK;接入 Sentry 崩溃监控 + 极光统计。
#### 前置
- Phase 18 完成
#### 任务清单
- [ ] **9.1** Sentry-Cocoa SPM 依赖 + `Source/Analytics/SentryCrashReporter.swift`
- 编译开关 `SENTRY_ENABLED`(默认 ON+ `NoopCrashReporter` fallback
- DSN 通过 xcconfig 注入,不入 git
- [ ] **9.2** `Source/Bridge/Handlers/H5ErrorRelay.swift`:注入 `window.onerror` / `unhandledrejection` polyfill + `reportH5Error` 桥 handlerDesign §11.4,新增 handler 不破坏契约)
- [ ] **9.3** 极光 SDKVendor+ `Source/Analytics/JAnalytics.swift` 启动期注册
- [ ] **9.4** 闲聊:保持 NoopSharePlatform(业务方未启用闲聊分享,stub 已满足契约)
- [ ] **9.5** Agora:保持 NoopVideoRoom + 编译开关 OFF
- [ ] **9.6** Bugly:不集成(Sentry 已覆盖)
#### 验收
- 强制崩溃 → Sentry 后台收到 issue
- H5 内 `throw new Error()` → Sentry 看到 H5 错误 + `container_role` 标签
- 极光后台看到设备激活
#### 风险
- Sentry 账号必须 ≥ 2 人持有访问凭证(避免重蹈 Bugly 覆辙)
- 极光 / Sentry / 自家后台埋点数据重叠:明确每个监控的"唯一职责"(Sentry=崩溃,极光=用户激活,自家=业务事件)
---
### Phase 10: 多渠道 + 真机回归 + 灰度
#### 目标
完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。
#### 前置
- Phase 19 完成
#### 任务清单
##### 10.A 多渠道打包
- [ ] **10.1** `Scripts/inject_channel.sh`:按 Design §7.4 实现
- [ ] **10.2** `Scripts/archive.sh`xcodebuild archive + exportArchive 流水线
- [ ] **10.3** `Scripts/release.sh`:循环渠道列表,逐个出包到 `dist/`
- [ ] **10.4** 渠道环境文件 `channels/*.env` 模板
##### 10.B CI
- [ ] **10.5** CI 加 archive smoke test(至少一个渠道能出 ipa)
- [ ] **10.6** 契约测试纳入 CI 必跑
##### 10.C 真机回归
- [ ] **10.7** 按契约 §10 26 项清单逐项真机过:
- A 节 启动(4 项)
- B 节 桥接(12 项)
- C 节 系统事件(4 项)
- D 节 视频房间(4 项,stub 模式下只验 phonestate / recordSuccess / createRoom 不报错)
- E 节 回归(3 项 + 截图分享)
- [ ] **10.8** 覆盖设备矩阵:iPhone(最新 + 一台老款)/ iPad / iOS 15.6 / iOS 26.x
##### 10.D 灰度
- [ ] **10.9** 与 msext 旧外壳并存(Bundle ID 已分离),内部 50 人手动覆盖装
- [ ] **10.10** 监控 Sentry crash-free rate ≥ 99.5%、用户反馈
- [ ] **10.11** 50 → 100 → 全量切流(按渠道,小渠道先切)
#### 验收
- 契约 §10 全 26 项真机通过
- 全量灰度内零回退要求
- CI 多渠道出包稳定
#### 风险
- 真机覆盖不全:单人测试时间有限,必要时项目方协调测试人力
- 灰度期间发现 P0 → 必须能 24h 内热修(设计 Sentry breadcrumb + 远程开关)
---
## 6. 横向工作流(贯穿全程)
### 6.1 SDK 集成节奏
| 集成方式 | SDK | 集成 Phase |
|---------|-----|----------|
| **SPM 优先** | ZIPFoundation / Sentry / 七牛 v8+ | Phase 1 / 9 / 3 |
| **CocoaPods** | 高德 AMap | Phase 5 |
| **Vendor `.framework`** | 微信 / QQ / 闲聊(暂不) / Agora(暂不) / JAnalytics | Phase 4 / 9 |
| **Vendor `.a`** | opencore-amr | Phase 3 |
每集成一个 SDK 立即提交一个独立 commitCLAUDE.md "及时提交" 规则)。
### 6.2 测试节奏
- **Phase 1 起每 Phase 必须有契约测试用例**纳入 `ylgamehallContractTests`
- 单元测试覆盖率目标:BridgeCore ≥ 90%、ResourceKit ≥ 85%、其余 Kit ≥ 70%Design §12.1
- UI 集成测在 Phase 10 完成 26 项验收清单的 XCUI 自动化
### 6.3 监控接入
- Sentry 默认 ON 但可关(编译开关 `SENTRY_ENABLED`
- DSN / 七牛 token 接口 URL / 微信 AppID 等敏感配置走 xcconfig + `.gitignore`
- H5 错误回流通道(`reportH5Error` 桥 handler)在 Phase 9 接入
### 6.4 文档同步
- 每个 Phase 结束更新本文档 §8 进度追踪
- 桥接 handler 任何变动必须同步更新 ContractDesign §18.6
- Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract(事实为准)
---
## 7. 真实风险与缓解(项目独有)
| 风险 | 触发条件 | 影响 | 缓解 |
|------|---------|------|------|
| 已就位的 `gamehall.zip` 是 2023-12 旧版,与现网 H5 有契约漂移 | Phase 19 联调 | 联调期发现 handler 名 / 字段被旧版掩盖、真上线时反而暴露 | 上线前阶段(Phase 10 灰度前)强制由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项;联调期发现的契约疑点立即对照现网 msext 与 H5 团队对齐 |
| 微信 SDK 审核失败 / 改 Bundle ID 引起 Universal Links 失效 | Phase 4 真机调试 | 微信回调收不到 | Bundle ID 注册微信开放平台 + 配置 Universal Links 流程独立做一次(项目方协调) |
| 后台 `/wechat/login` 接口延期 | Phase 4 上线时 | secret 仍驻 IPA | 接受临时 fallback(标 TODO),上线前必须切到后台 |
| 高德 APIKey 与 Bundle ID 绑定失败 | Phase 5 真机定位 | 定位全失败 | 项目方控制台重新申请 key(本项目独立 key) |
| Swift 6 严格并发产生大量 warning/error | Phase 2 起 | 进度延迟 | 已启用 Approachable Concurrency(渐进模式),逐 Phase 修复,不一开始就 Complete 模式 |
| WKWebView 内 `evaluateJavaScript` 在 Liquid Glass 时代 API 变化 | iOS 26+ 真机 | 桥消息派发失败 | DocumentationSearch 优先查最新 API,不假设知识截止前的写法 |
| 真机回归人力不足(单人 + AI) | Phase 10 | 漏测 → 上线翻车 | 把 26 项验收清单写成 XCUI 自动化(可重复跑),手测只覆盖体验类 |
| Sentry 账号长期无人维护变 Bugly 翻版 | Phase 9 上线后 | 崩溃监控形同虚设 | 接入时同步把账号责任写入团队 onboarding,季度回顾会上检查存活 |
| 灰度期发现 P0 但无热修通道 | Phase 10 灰度 | 用户体验受损 | 设计期就要预留远程配置开关(如:`Sentry breadcrumbs` 中查 root cause + 一个 plist 控制的 feature flag|
---
## 8. 进度追踪
> 每完成一项就把 `[ ]` 改 `[x]`,并在 commit message 里附 Phase X.Y 编号方便 git log 检索。
### Phase 0 工程化基线
- [x] 0.0 M0 基础配置(横屏 / Swift 6 / 代码启动)
- [x] 0.0.1 `.gitignore` + xcuserdata 清理
- [ ] 0.1 SwiftLint
- [ ] 0.2 单测 target
- [ ] 0.3 契约测试 target
- [ ] 0.4 UI 测试 target
- [ ] 0.5 CI 流水线
- [ ] 0.6 SPM 包目录决策(暂单 target
### Phase 1 最小垂直闭环
- [ ] 1.1 ChannelInjection 目录占位
- [ ] 1.2 BundleConfig
- [ ] 1.3 SandboxPaths
- [ ] 1.4 ZIPFoundation SPM
- [ ] 1.5 ResourceUnzipper
- [ ] 1.6 BridgeProtocol
- [ ] 1.7 BridgeBus
- [ ] 1.8 WVJB JS 协议
- [ ] 1.9 BridgedWebView
- [ ] 1.10 WebContainerViewController16:9 letterbox
- [ ] 1.11 VibratorHandler
- [ ] 1.12 SceneDelegate → LobbyViewController
- [ ] 1.13 Demo H5 联调
### Phase 2 大厅简单 handler + 反向 callback
- [ ] 2.1 剪贴板
- [ ] 2.2 振动扩展
- [ ] 2.3 摇一摇开关
- [ ] 2.4 voicePlaying
- [ ] 2.5 getphoneInfo
- [ ] 2.6 browser
- [ ] 2.7 opensaoma 空注册
- [ ] 2.8 DeviceInfo
- [ ] 2.9 BatteryMonitor
- [ ] 2.10 NetworkMonitor
- [ ] 2.11 AppLifecycleObserver
- [ ] 2.12 ShakeDetector
- [ ] 2.13 ExternalSubscriptions
### Phase 3 音频体系
- [ ] 3.1 AudioPlayer
- [ ] 3.2 本地音频真机验证
- [ ] 3.3 opencore-amr Vendor 接入
- [ ] 3.4 VoiceCoder Swift wrapper
- [ ] 3.5 AudioRecorder + 麦克风权限
- [ ] 3.6 七牛 SPM + QiniuUploader
- [ ] 3.7 prepareaudio handler
- [ ] 3.8 mediaTypeAudio handler
### Phase 4 分享 + 登录
- [ ] 4.1 微信 SDK Vendor + URL Scheme
- [ ] 4.2 QQ SDK Vendor
- [ ] 4.3 WeChatSDK registerFull
- [ ] 4.4 WeChatManagerstate map + FIFO
- [ ] 4.5 SceneDelegate openURL 顺序
- [ ] 4.6 WeChatAuth(后台中转或 fallback
- [ ] 4.7 accreditlogin → sharelogin
- [ ] 4.8 SharePlatform 协议
- [ ] 4.9 ShareCenter 9 种组合
- [ ] 4.10 friendsShare... handler
- [ ] 4.11 截图分享
- [ ] 4.12 远程图片分享
### Phase 5 定位
- [ ] 5.1 AMap Pod
- [ ] 5.2 定位权限文案
- [ ] 5.3 AMapWrapper 启动注册
- [ ] 5.4 LocationService
- [ ] 5.5 startlocation handlerlatitude/longitude stringprovince 小写)
- [ ] 5.6 失败回包 errorCode 12
### Phase 6 子游戏
- [ ] 6.1 AppCoordinator 栈深节流
- [ ] 6.2 SubGameViewController
- [ ] 6.3 SubGameHandlers
- [ ] 6.4 SwitchOverGameData
- [ ] 6.5 backgameData
- [ ] 6.6 getWebdata 通知链
- [ ] 6.7 SubGameDownloader
### Phase 7 弹层
- [ ] 7.1 OverlayViewController + 私有 dataStore
- [ ] 7.2 window.settings polyfill
- [ ] 7.3 3 个 WKScriptMessageHandler
- [ ] 7.4 OpenurlTitleData handler"title " 末尾空格)
- [ ] 7.5 Overlay backgameData
- [ ] 7.6 Overlay finishweb
- [ ] 7.7 Overlay browser
### Phase 8 视频房间 stub
- [ ] 8.1 VideoRoom 协议
- [ ] 8.2 NoopVideoRoom
- [ ] 8.3 3 个 stub handler
- [ ] 8.4 CallCenterMonitor → phonestate
- [ ] 8.5 recordSuccess 双 callback
- [ ] 8.6 AgoraVideoRoom 蓝图(#if AGORA_ENABLED OFF
### Phase 9 SDK 真实化 + 监控
- [ ] 9.1 Sentry SPM + CrashReporter
- [ ] 9.2 H5ErrorRelay
- [ ] 9.3 极光 SDK
- [ ] 9.4 闲聊保持 Noop
- [ ] 9.5 Agora 编译开关 OFF
- [ ] 9.6 不集成 Bugly
### Phase 10 多渠道 + 回归 + 灰度
- [ ] 10.1 inject_channel.sh
- [ ] 10.2 archive.sh
- [ ] 10.3 release.sh
- [ ] 10.4 渠道环境文件
- [ ] 10.5 CI archive smoke
- [ ] 10.6 契约测试入 CI
- [ ] 10.7 26 项验收清单真机过
- [ ] 10.8 设备矩阵覆盖
- [ ] 10.9 内部 50 人灰度
- [ ] 10.10 Sentry 监控
- [ ] 10.11 全量切流
---
## 9. 决策记录(ADR-lite
> 关键技术 / 工程决策的简短记录,便于回溯"为什么这样选"。新增决策追加到本节末尾。
### ADR-001:单 target 起步,暂不切 SPM2026-06-21
- **决策**Phase 02 阶段保持单 target,按目录组织(`Source/Bridge``Source/Resource` 等);待代码量 > 5K 行(约 Phase 3 完成时)再评估切 SPM
- **理由**:早期 SPM 切分成本(target 间循环依赖排查、增量编译配置、测试 target 接入)会拖慢核心垂直闭环验证;Design §2.2 也注明"目标稳态 8-10 个 target,以日常开发体验为先"
- **回滚条件**:单 target 编译时间 > 30s 时切 SPM
### ADR-002:使用已就位的 2023-12 旧版 gamehall.zip 起步(2026-06-21,原方案"H5 demo 先行"已作废)
- **背景**:仓库根 `Resources/gamehall.zip` 已存在(旧版,2023-12,11.5 MB),无需等 H5 团队最新版即可启动 Phase 1
- **决策**Phase 1 直接用该 zip 跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路;旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证
- **理由**
- 桥接口名 / 参数字段名 / JS 协议属于契约范畴,按 Contract 1:1 实现即可,不依赖 H5 业务代码新旧
- 提早用真 H5 跑通比 demo H5 更能暴露真实问题(如 WVJB 握手时序、JS 注入文件路径等)
- **风险**:联调期遇到的契约疑点可能来自旧版 H5 已废弃接口,需对照现网 msext + H5 团队双确认
- **切换条件**Phase 10 灰度前必须由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项
### ADR-004Resources/ 目录位于仓库根(非 Xcode 工程内)(2026-06-21
- **决策**:项目方提供的所有资源(`gamehall.zip` / 原生 Asset Catalog / 散落 png / mp3 / 后续渠道注入目录)统一放仓库根 `Resources/`,由打包脚本通过 Build Phase 引入;**不**直接放进 Xcode 工程的 `ylgamehall/` 源码目录
- **理由**
- 与源码物理隔离,git diff 更清晰(资源变动 vs 代码变动不混淆)
- Build Phase 引入便于按渠道动态切换内容(同一份源码 × 多个 Resources 配置)
- 符合 Design §2.2 的 SPM 包结构规划(`Resources/``Sources/` 并列)
- **现状**`Resources/` 已含 `gamehall.zip` / `Images.xcassets/` / `Res/`11 个渠道注入目录预留位 `Resources/ChannelInjection/` 待 Phase 1 由 `Scripts/inject_channel.sh` 生成
### ADR-003:微信登录走后台中转 vs 客户端 fallback2026-06-21
- **决策**:默认走后台 `/wechat/login` 中转;后台未就绪时临时客户端直拼,但代码标 `// FIXME` 且**禁止以 fallback 状态上线**
- **理由**:客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置(Contract §0.4 / Design §17);上线前必须闭环
- **复盘节点**:Phase 4 完成时检查后台接口状态
---
文档完成日期:2026-06-21
最后更新:2026-06-21(初版)