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