工程改进:跨语言契约固化、日志脱敏、状态模型收敛

依据 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>
This commit is contained in:
sansen
2026-09-10 14:58:15 +08:00
co-authored by Claude Fable 5
parent 0ef1990d52
commit 8716b391f6
913 changed files with 4982 additions and 185656 deletions
+169
View File
@@ -0,0 +1,169 @@
package teamspeak
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
)
// goldenDir 是 Go 与 Kotlin **共用**的契约样本目录。
//
// 这不是普通测试夹具,而是跨语言契约的共享事实载体:
// - Go 侧(本文件):断言样本的字段集合与 struct tag 完全一致
// - Kotlin 侧(BridgeContractTest):断言同一样本能被数据类正确反序列化
//
// 于是字段漂移的传播链被钉死:
// 改 Go tag → 本文件失败 → 必须更新样本 → Kotlin 测试随之失败。
// 任何一环被跳过都会留下一个红灯,不存在「静默降级」的路径。
const goldenDir = "../../android/app/src/test/resources/contract"
func readGolden(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join(goldenDir, name)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取契约样本失败 %s: %v\n"+
"(该目录是 Go 与 Kotlin 共用的契约样本,缺失即契约失守)", path, err)
}
return raw
}
// keySetsEqual 比较两组字段名是否完全相同(顺序无关)。
func keySetsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
sort.Strings(a)
sort.Strings(b)
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// assertGoldenMatchesType 断言样本 JSON 的字段集合 == struct 的 tag 集合。
//
// 两个方向都查:
// - 样本多出字段 → 样本过期,或有人改了 tag 却没同步
// - 样本缺少字段 → struct 新增了字段,必须补进样本(否则 Kotlin 侧拿默认值)
func assertGoldenMatchesType(t *testing.T, sampleName string, raw []byte, target any, objectIsArray bool) {
t.Helper()
typ := reflect.TypeOf(target)
var keys []string
if objectIsArray {
var items []map[string]json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
t.Fatalf("%s 不是合法 JSON 数组: %v", sampleName, err)
}
if len(items) == 0 {
t.Fatalf("%s 样本为空数组,无法校验契约", sampleName)
}
for k := range items[0] {
keys = append(keys, k)
}
} else {
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("%s 不是合法 JSON 对象: %v", sampleName, err)
}
for k := range item {
keys = append(keys, k)
}
}
want := make([]string, 0, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
if name, ok := jsonFieldNames(typ)[typ.Field(i).Name]; ok {
want = append(want, name)
}
}
if !keySetsEqual(keys, want) {
sort.Strings(keys)
sort.Strings(want)
t.Errorf("%s 契约漂移:\n 样本字段 = %v\n struct tag = %v\n"+
" 两者必须一致——样本是 Kotlin 侧的唯一解析依据。",
sampleName, keys, want)
}
}
func TestGolden_Channels(t *testing.T) {
assertGoldenMatchesType(t, "channels.json", readGolden(t, "channels.json"), channelJSON{}, true)
}
func TestGolden_Clients(t *testing.T) {
assertGoldenMatchesType(t, "clients.json", readGolden(t, "clients.json"), clientJSON{}, true)
}
func TestGolden_ServerInfo(t *testing.T) {
assertGoldenMatchesType(t, "server_info.json", readGolden(t, "server_info.json"), serverInfoJSON{}, false)
}
// TestGolden_ChannelsRoundTrip 样本必须能被 Go 自己解析回结构体,
// 且关键字段值正确——保证样本描述的是「Go 真的会发出来的东西」。
func TestGolden_ChannelsRoundTrip(t *testing.T) {
var got []channelJSON
if err := json.Unmarshal(readGolden(t, "channels.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != 2 {
t.Fatalf("期望 2 个频道,实际 %d", len(got))
}
if got[0].ID != "1" || got[0].Name != "大厅" || got[0].ParentID != "0" {
t.Errorf("频道[0] 解析异常: %+v", got[0])
}
if !got[1].IsPassword {
t.Errorf("频道[1] 应为加密频道: %+v", got[1])
}
}
func TestGolden_ClientsRoundTrip(t *testing.T) {
var got []clientJSON
if err := json.Unmarshal(readGolden(t, "clients.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != 3 {
t.Fatalf("期望 3 个客户端,实际 %d", len(got))
}
if !got[0].IsSelf || got[0].UID != "abc123==" || got[0].ChannelID != "1" {
t.Errorf("客户端[0] 解析异常: %+v", got[0])
}
if len(got[2].ServerGroups) != 2 {
t.Errorf("客户端[2] 应有 2 个服务器组: %+v", got[2].ServerGroups)
}
}
// TestGolden_ServerInfoTypes 钉住 Kotlin 侧按 String 解析的字段。
// uptime/created 在 Go 侧是 string(数值被格式化过),
// 若改成数值类型,Kotlin 的 String 字段会静默变空。
func TestGolden_ServerInfoTypes(t *testing.T) {
var got serverInfoJSON
if err := json.Unmarshal(readGolden(t, "server_info.json"), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Name != "My Server" || got.ClientsOnline != 3 || got.MaxClients != 32 {
t.Errorf("服务器信息解析异常: %+v", got)
}
if got.Uptime != "3600" || got.Created != "1700000000" {
t.Errorf("uptime/created 必须保持字符串形态: uptime=%q created=%q", got.Uptime, got.Created)
}
typ := reflect.TypeOf(serverInfoJSON{})
for _, name := range []string{"Uptime", "Created", "IconID"} {
f, ok := typ.FieldByName(name)
if !ok {
t.Fatalf("serverInfoJSON 缺少字段 %s", name)
}
if f.Type.Kind() != reflect.String {
t.Errorf("serverInfoJSON.%s 必须是 string(Kotlin ServerInfo 按 String 解析),实际 %s",
name, f.Type.Kind())
}
}
}