Files
ts-mobile-go/docs/bridge-contract.md
sansenandClaude Fable 5 8716b391f6 工程改进:跨语言契约固化、日志脱敏、状态模型收敛
依据 docs/项目工程改进方案.md 实施(第五梯队 CI/协作除外)。

安全性
- Release 剥离 Log.v/d/i/w(-assumenosideeffects),保留 Log.e
- 全量频道/成员 dump、消息正文、发送者 UID 改为 BuildConfig.DEBUG 保护
  (实测 release dex 中 "Channel: id=" / "Client: id=" / selfId= 等均为 0 命中)

构建产物体积
- .gitignore 补 *.a(保留 .opus/lib 四个 ABI 预编译库)、.opus/install、
  app/libs/*.aar、dnn/torch
- git rm --cached 除名 AAR、.gradle、.opus/{build,install}、dnn/torch
  跟踪体积 50MB → 17.4MB,工作区文件不受影响

跨语言契约(本方案核心)
- 新增 go/teamspeak/contract.go:字段名常量 + BridgeContractVersion 单一事实来源
- 新增契约测试:反射断言常量与 struct tag 一致;Go/Kotlin 共用 golden 样本
- 新增 BridgeContract.kt:启动校验 AAR 契约版本,不匹配则阻止连接
- 修正文档:channel_order 是前驱频道 ID(链表指针)而非排序权重,
  按它数值排序会打乱频道树;ChannelInfo.order 在 Go 侧不存在

状态模型
- ConnectionState 增 Idle 取代 null 编码,connectionState 不再可空
- applyClients 改为按频道差分更新,避免全量刷新导致频道树整体重组
- 频道数据 freshness 统一由 Repository 维护,修复 ViewModel 与 Repository
  两份时间戳不同步导致的重复刷新

UI
- 修复 collectAsState() 在参数位置调用导致的 isSelf 快照失效
- 消除 9 处 !! 断言(ChannelListScreen)
- 硬编码 24.5/14.5dp 与 depth*24 收敛到 UiTokens.Spacing,缩进加 4 层上限

测试
- Kotlin 测试 7 → 106;Go 契约测试新增 24 个用例
- 各覆盖 InputValidator、消息送达确认、僵尸会话过滤、频道差分、
  频道顺序语义、错误分类

其他修复
- classifyError 提取为纯函数并补测试;修复 too many clones (id=521)
  未识别导致英文原文直接暴露给用户

版本号提升至 1.0.13(versionCode 13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:58:15 +08:00

219 lines
10 KiB
Markdown
Raw Permalink 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.
# Go ↔ Kotlin Bridge 契约
> **状态:权威** | 最后核对:2026-09-10 | 契约版本:`1`
>
> 单一事实来源:`go/teamspeak/contract.go`(字段名常量 + `BridgeContractVersion`)
> Kotlin 侧守卫:`android/app/src/main/java/com/tsmobile/app/data/BridgeContract.kt`
> 契约测试:`go/teamspeak/contract_test.go`、`contract_golden_test.go`、
> `android/app/src/test/java/com/tsmobile/app/data/BridgeContractTest.kt`
---
## 为什么需要这份文档
gomobile 有三条硬限制(`CLAUDE.md` 已记录):不能导出 `[]string`、`[]*T`、Go `error`。
项目用 **JSON 字符串**绕过它们,代价是:
> **Go 侧改一个字段名,Kotlin 侧不会编译失败,只会在运行时静默拿到默认值。**
叠加 `Repository.kt` 的 `Json { ignoreUnknownKeys = true }`,字段对不上时
`ChannelInfo` 直接填 `""` / `0` / `false`:频道列表看起来是空的,
或者密码频道显示成公开频道,而日志里什么都没有。
本契约用三道机制钉住这个边界:
| 机制 | 防止的问题 |
| --- | --- |
| `contract.go` 字段名常量 | 集中声明,marshal 处不写字面量 |
| `contract_test.go` 反射断言 | 常量与 struct tag 漂移 → 测试红 |
| golden 样本(Go/Kotlin 共用) | 样本与 tag 漂移 → 两侧测试都红 |
| `BridgeContract.verifyOrDescribeError()` | AAR 与 App 版本不匹配 → 连接前明确报错 |
---
## 边界数据类型分级
不是所有跨边界数据都同样脆弱。**改代码前先确认属于哪一类**:
| 类别 | 保护强度 | 类型 | 说明 |
| --- | --- | --- | --- |
| **A 类** | 编译期检查 | `TextMsg`、`PokeEvent`、`Channel`、`Client` | gomobile 导出的 struct,字段是真实属性。Go 改名 → Kotlin 编译失败。**无需常量保护。** |
| **B 类** | 无(靠契约测试) | `channelJSON`、`clientJSON`、`serverInfoJSON`、文件下载 JSON | JSON 字符串。**本契约的主要保护对象。** |
| **C 类** | 无(无消费方) | `channelDetailedJSON`、`clientDetailedInfoJSON`、`initialSyncJSON` | 已导出但 Kotlin 未解析。字段名已声明以钉住形状;将来要消费时先跑契约测试。 |
---
## B 类契约明细
图例:**✓** = 已有 Kotlin 消费方 | **—** = 当前未消费
### 1. 频道列表 — `GetChannelsJSON()` → `Repository.fetchChannels()`
Go:`channelJSON`(`bridge.go`)| Kotlin:`ChannelInfo`(`Models.kt`)
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
| --- | --- | --- | --- | --- | --- |
| `ID` | `id` | `id` | `String` | 否 | `""` — **字符串化数字**,见下方注意 |
| `Name` | `name` | `name` | `String` | 否 | `""` |
| `ParentID` | `parentId` | `parentId` | `String` | 否 | `"0"` — 顶级频道的约定值 |
| `Description` | `description` | `description` | `String` | 否 | `""`(Go 侧实际不填充) |
| `IsPassword` | `isPassword` | `isPassword` | `Boolean` | 否 | `false` |
> ⚠️ **`id` / `parentId` 是字符串不是数字。** TeamSpeak 的频道 ID 是无符号 64 位,
> 超出 Kotlin `Int` 范围。`contract_test.go` 的 `TestChannelJSON_IDIsStringType`
> 钉住了这一点——改成数值类型会让 Kotlin 在运行时抛序列化异常。
> ⚠️ **`ChannelInfo.order`(`Int`)在 Go 侧不存在。** `channelJSON` 没有 `order` 字段,
> 所以该值恒为默认 `0`,`ChannelViewModel.buildChannelTree` 的 `.sortedBy { it.order }`
> 实际是不产生效果的 no-op(`sortedBy` 稳定,全等值等于保持原序)。
>
> **频道顺序目前由服务器返回顺序决定,且这是正确的**——TS3 的 `channellist`
> 响应按频道树顺序返回,子频道紧跟父频道。显示顺序经真机验证与响应顺序一致。
>
> 📌 **不要"按 order 排序"**:`channel_order` 不是排序权重,而是
> **前驱频道的 ID**(链表指针,`0` = 排在本层最前)。见
> `docs/teamspeak-sdk-3.5.2/doc/client/channel-sort.html`:
>
> > The channel order is the ID of the predecessor channel after which the
> > given channel should be sorted. An order of 0 means the channel is
> > sorted on the top of its hirarchy.
>
> 官方示例:`Subsubchannel_2 ( ID = 7 , order = 6 )` 表示"排在 ID=6 之后",
> `Subchannel_2 ( ID = 5 , order = 4 )` 表示"排在 ID=4 之后"。
> 若按该值**数值排序**会得到 `1,4,6,2,3,5,7`,正好把树结构打乱。
>
> 若将来确实需要在客户端重建顺序(例如不再依赖响应顺序),
> 正确做法是消费 `channelDetailedJSON.order` 后按前驱指针**串链**,
> 而不是排序。注意 API 侧 `ChannelDetailInfo` 里 `pid` 取自
> `item["channel_order"]`(`api.go:265`),该处取值可疑,使用前需先核实。
### 2. 客户端列表 — `GetClientsJSON()` → `Repository.fetchClients()`
Go:`clientJSON` | Kotlin:`ClientInfo`
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
| --- | --- | --- | --- | --- | --- |
| `ID` | `id` | `id` | `Int` | 否 | `0` |
| `Nickname` | `nickname` | `nickname` | `String` | 否 | `""` |
| `UID` | `uid` | `uid` | `String` | 否 | `""` — **永久身份标识,等同账号 ID** |
| `ChannelID` | `channelId` | `channelId` | `String` | 否 | `"0"` — 字符串化 |
| `ServerGroups` | `serverGroups` | `serverGroups` | `List<String>` | 否 | `[]` |
| `IsSelf` | `isSelf` | `isSelf` | `Boolean` | 否 | `false` |
> `uid` 是敏感数据:`Repository.performInitialSync` 中的全量 dump 已改为
> `BuildConfig.DEBUG` 保护 + `proguard-rules.pro` 剥离 `Log.d`。
### 3. 服务器信息 — `GetServerInfoJSON()` → `ServerViewModel` / `Repository`
Go:`serverInfoJSON` | Kotlin:`ServerInfo`
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
| --- | --- | --- | --- | --- | --- |
| `Name` | `name` | `name` | `String` | 否 | `""` |
| `WelcomeMessage` | `welcomeMessage` | `welcomeMessage` | `String` | 否 | `""` |
| `MaxClients` | `maxClients` | `maxClients` | `Int` | 否 | `0` |
| `ClientsOnline` | `clientsOnline` | `clientsOnline` | `Int` | 否 | `0` |
| `ChannelsOnline` | `channelsOnline` | `channelsOnline` | `Int` | 否 | `0` |
| `Uptime` | `uptime` | `uptime` | `String` | 否 | `""` — **字符串化秒数** |
| `Version` | `version` | `version` | `String` | 否 | `""` |
| `Platform` | `platform` | `platform` | `String` | 否 | `""` |
| `Created` | `created` | `created` | `String` | 否 | `""` — **字符串化秒级时间戳** |
| `IconID` | `iconId` | `iconId` | `String` | 否 | `""` |
| `DefaultServerGroup` | `defaultServerGroup` | `defaultServerGroup` | `Int` | 否 | `0` |
| `DefaultChannelGroup` | `defaultChannelGroup` | `defaultChannelGroup` | `Int` | 否 | `0` |
> ⚠️ `uptime` / `created` 是**字符串**。`TestGolden_ServerInfoTypes` 钉住了类型——
> 改成数值会让 Kotlin 的 `String` 字段静默变空。
### 4. 文件下载 — `DownloadFileBytesJSON()` → `FileDownloadManager`
Go:`kotlin_api.go`(内联 map)| Kotlin:`TSBridge.downloadFileBytes()`
| JSON 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data` | `String` | base64 编码的文件内容 |
| `size` | `Int` | 解码后字节数 |
> **失败约定与其它接口不同**:失败返回 `"{}"`(而非 `"[]"`),消费方以 `data` 为空判定失败。
> 空字符串返回表示「未连接」,`"{}"` 表示「连接正常但下载失败」——两者不可混为一谈。
---
## 事件契约(A 类,编译期检查)
这些类型由 gomobile 直接导出为 Java 类,字段访问经编译器校验,**不需要也不应该**
为它们添加 JSON 字段常量。
### `TextMsg`(`EventCallback.OnTextMessage`)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `TargetMode` | `Int` | 见下方 targetMode 表 |
| `TargetID` | `String` | 目标 ID(字符串化) |
| `InvokerID` | `Int` | 发送者 clid;旧 AAR 可能为 `0` |
| `InvokerName` | `String` | 发送者昵称 |
| `InvokerUID` | `String` | 发送者 UID |
| `Message` | `String` | 消息内容(纯文本或文件消息 JSON) |
### `PokeEvent`(`EventCallback.OnPoked`)
| 字段 | 类型 |
| --- | --- |
| `InvokerID` | `Int` |
| `InvokerName` | `String` |
| `InvokerUID` | `String` |
| `Message` | `String` |
---
## targetMode 收发能力对照(重要)
`TargetMode` 的**接收**与**发送**能力不对称,读代码时极易误判为「三种都支持」:
| 值 | 含义 | 接收 | 发送 |
| --- | --- | --- | --- |
| `1` | 私聊 | ✅ 支持 | ❌ **未实现** |
| `2` | 频道 | ✅ 支持 | ✅ 支持 |
| `3` | 服务器 | ✅ 支持 | ❌ **未实现** |
`TSBridge.sendTextMessage` 仅实现 `mode=2`;其余分支返回显式错误串
`"暂不支持该消息类型"`(按桥接约定:非空 = 错误),由 `ChatViewModel` 映射为
`MessageSendState.Failed` 并将消息标记为 `FAILED`。
**当前状态:接收侧支持三种会话(通知 deep link 可跳转到指定会话),发送侧只有频道消息。**
私聊能收到、能弹通知,但无法在 App 内回复。
---
## 版本升级流程
任何**不兼容**变更(字段改名、类型变更、字段删除)必须:
1. 递增 `go/teamspeak/contract.go` 的 `BridgeContractVersion`
2. 同步 `BridgeContract.kt` 的 `EXPECTED_VERSION`
3. 更新本文档
4. 重编 AAR(`build.bat` 第 1 步)
只**新增可选字段**(Kotlin 侧有默认值)不需要递增版本。
> 版本校验不匹配时,`ServerViewModel.connect()` 会以 `ConnectState.FAILED`
> 明确报错并阻止连接——而不是带着错配的字段名继续跑。
> 旧 AAR(未导出 `getContractVersion()`)不阻断,仅记录 warning。
---
## 跑契约测试
```bash
# Go 侧:字段名常量 + golden 样本一致性
cd go && go test ./teamspeak/ -run "Contract|Golden" -v
# Kotlin 侧:反序列化 + 失效模式
cd android && ./gradlew testDebugUnitTest --tests '*BridgeContractTest*'
```
**golden 样本位于** `android/app/src/test/resources/contract/`,
由 Go 与 Kotlin **共用**:Go 测试断言样本字段集合 == struct tag,
Kotlin 测试断言样本能正确反序列化。
字段漂移时两侧都会变红——这是「改了字段名却无人发现」的兜底。