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

10 KiB
Raw Permalink Blame History

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。


跑契约测试

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