219 lines
10 KiB
Markdown
219 lines
10 KiB
Markdown
# 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 测试断言样本能正确反序列化。
|
|||
|
|
字段漂移时两侧都会变红——这是「改了字段名却无人发现」的兜底。
|