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

依据 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
+245
View File
@@ -0,0 +1,245 @@
package teamspeak
import (
"encoding/json"
"reflect"
"sort"
"testing"
)
// jsonFieldNames 通过反射取出 struct 上实际生效的 JSON 字段名。
// encoding/json 只认 tag,所以这是运行时的事实来源。
// 无 tag 的字段退化为 Go 字段名(与 encoding/json 行为一致)。
func jsonFieldNames(t reflect.Type) map[string]string {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
out := make(map[string]string, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
name := f.Name
if tag, ok := f.Tag.Lookup("json"); ok {
name = tag
// 去掉 ",omitempty" 之类的选项
for j := 0; j < len(name); j++ {
if name[j] == ',' {
name = name[:j]
break
}
}
}
out[f.Name] = name
}
return out
}
// assertFields 断言「契约常量」与「struct 实际 tag」完全一致。
// declared 的 key 是 Go 字段名,value 是 contract.go 里的常量值。
//
// 这正是文档所说「改动 bridge.go 任一字段名会导致测试失败」的机制。
func assertFields(t *testing.T, typeName string, sample any, declared map[string]string) {
t.Helper()
actual := jsonFieldNames(reflect.TypeOf(sample))
// 反向索引:tag 名 → Go 字段名,用于给出可读的报错
actualByTag := make(map[string]string, len(actual))
for goName, tagName := range actual {
actualByTag[tagName] = goName
}
for goName, wantTag := range declared {
gotTag, exists := actual[goName]
if !exists {
t.Errorf("%s: 契约声明了字段 %q,但 struct 中没有该字段(契约已过期)", typeName, goName)
continue
}
if gotTag != wantTag {
t.Errorf("%s.%s: JSON 字段名漂移:契约常量=%q,实际 tag=%q\n"+
" 改 tag 就必须同步 contract.go 与 docs/bridge-contract.md,并考虑递增 BridgeContractVersion",
typeName, goName, wantTag, gotTag)
}
}
// 反向检查:struct 中新增了字段却没登记进契约
var missing []string
for goName, tagName := range actual {
if _, ok := declared[goName]; !ok {
missing = append(missing, goName+"(json:"+tagName+")")
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("%s: struct 存在未登记进 contract.go 的字段:%v\n"+
" 新增字段需要在 contract.go 中声明,否则 Kotlin 侧的静默失效模式无人看守",
typeName, missing)
}
}
// ─── B 类契约:有 Kotlin 消费方,必须逐字段对齐 ────────────────
func TestChannelJSON_ContractStable(t *testing.T) {
assertFields(t, "channelJSON", channelJSON{}, map[string]string{
"ID": FieldChannelID,
"Name": FieldChannelName,
"ParentID": FieldChannelParentID,
"Description": FieldChannelDescription,
"IsPassword": FieldChannelIsPassword,
})
}
func TestClientJSON_ContractStable(t *testing.T) {
assertFields(t, "clientJSON", clientJSON{}, map[string]string{
"ID": FieldClientID,
"Nickname": FieldClientNickname,
"UID": FieldClientUID,
"ChannelID": FieldClientChannelID,
"ServerGroups": FieldClientServerGroups,
"IsSelf": FieldClientIsSelf,
})
}
func TestServerInfoJSON_ContractStable(t *testing.T) {
assertFields(t, "serverInfoJSON", serverInfoJSON{}, map[string]string{
"Name": FieldServerName,
"WelcomeMessage": FieldServerWelcomeMessage,
"MaxClients": FieldServerMaxClients,
"ClientsOnline": FieldServerClientsOnline,
"ChannelsOnline": FieldServerChannelsOnline,
"Uptime": FieldServerUptime,
"Version": FieldServerVersion,
"Platform": FieldServerPlatform,
"Created": FieldServerCreated,
"IconID": FieldServerIconID,
"DefaultServerGroup": FieldServerDefaultServerGroup,
"DefaultChannelGroup": FieldServerDefaultChannelGroup,
})
}
// ─── C 类契约:当前无消费方,但形状必须可验证 ──────────────────
func TestChannelDetailedJSON_ContractStable(t *testing.T) {
// channelDetailedJSON 内嵌了 channelJSON 的五个字段(id/name/parentId/description/isPassword),
// 这里只登记它自己的字段,公共字段由 TestChannelJSON_ContractStable 覆盖。
assertFields(t, "channelDetailedJSON", channelDetailedJSON{}, map[string]string{
"ID": FieldChannelID,
"Name": FieldChannelName,
"ParentID": FieldChannelParentID,
"Topic": FieldChannelDetailTopic,
"Order": FieldChannelDetailOrder,
"Codec": FieldChannelDetailCodec,
"CodecQuality": FieldChannelDetailCodecQuality,
"NeededTalkPower": FieldChannelDetailNeededTalkPower,
"MaxClients": FieldChannelDetailMaxClients,
"MaxFamilyClients": FieldChannelDetailMaxFamilyClients,
"IsMaxClientsUnlimited": FieldChannelDetailIsMaxClientsUnlimited,
"IsMaxFamilyClientsUnlimited": FieldChannelDetailIsMaxFamilyClientsUnlimited,
"IsPermanent": FieldChannelDetailIsPermanent,
"IsSemiPermanent": FieldChannelDetailIsSemiPermanent,
"IsDefault": FieldChannelDetailIsDefault,
"IsPassword": FieldChannelIsPassword,
"IsOrdered": FieldChannelDetailIsOrdered,
"IconID": FieldChannelDetailIconID,
"NeededModifyPower": FieldChannelDetailNeededModifyPower,
})
}
func TestInitialSyncJSON_ContractStable(t *testing.T) {
assertFields(t, "initialSyncJSON", initialSyncJSON{}, map[string]string{
"Channels": FieldSyncChannels,
"Clients": FieldSyncClients,
"SelfID": FieldSyncSelfID,
"SelfChannelID": FieldSyncSelfChannelID,
"Server": FieldSyncServer,
})
}
// ─── 往返测试:真实样本 → JSON → 字段名断言 ────────────────────
//
// 上面的反射测试保证「常量 == tag」,这里保证「tag == 实际序列化输出」,
// 两者合起来才能覆盖 encoding/json 的全部行为(如 omitempty、内嵌 struct)。
func TestChannelJSON_RoundTrip(t *testing.T) {
in := channelJSON{
ID: "42",
Name: "大厅",
ParentID: "0",
Description: "desc",
IsPassword: true,
}
raw, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var asMap map[string]json.RawMessage
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal to map: %v", err)
}
want := []string{
FieldChannelID, FieldChannelName, FieldChannelParentID,
FieldChannelDescription, FieldChannelIsPassword,
}
for _, k := range want {
if _, ok := asMap[k]; !ok {
t.Errorf("序列化结果缺少字段 %q;实际 JSON=%s", k, raw)
}
}
// 值必须真的能往返(父频道 ID 是字符串而非数字,Kotlin 侧按 String 解析)
var back channelJSON
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal back: %v", err)
}
if back != in {
t.Errorf("往返后数据不一致:in=%+v out=%+v", in, back)
}
}
// TestChannelJSON_IDIsStringType 钉住一个曾经的真实失效模式:
// Kotlin 的 ChannelInfo.id / parentId 是 String,Go 侧若改成数值类型,
// Kotlin 会在运行时抛序列化异常(而不是编译失败)。
func TestChannelJSON_IDIsStringType(t *testing.T) {
typ := reflect.TypeOf(channelJSON{})
for _, fieldName := range []string{"ID", "ParentID"} {
f, ok := typ.FieldByName(fieldName)
if !ok {
t.Fatalf("channelJSON 缺少字段 %s", fieldName)
}
if f.Type.Kind() != reflect.String {
t.Errorf("channelJSON.%s 必须是 string(Kotlin ChannelInfo 按 String 解析),实际为 %s",
fieldName, f.Type.Kind())
}
}
}
// TestClientJSON_ChannelIDIsStringType 同上:ClientInfo.channelId 是 String。
func TestClientJSON_ChannelIDIsStringType(t *testing.T) {
f, ok := reflect.TypeOf(clientJSON{}).FieldByName("ChannelID")
if !ok {
t.Fatal("clientJSON 缺少 ChannelID 字段")
}
if f.Type.Kind() != reflect.String {
t.Errorf("clientJSON.ChannelID 必须是 string(Kotlin ClientInfo.channelId 按 String 解析),实际为 %s",
f.Type.Kind())
}
}
// TestContractVersion_NonEmpty 版本号是对外承诺,不能为空。
func TestContractVersion_NonEmpty(t *testing.T) {
if GetContractVersion() == "" {
t.Fatal("BridgeContractVersion 不能为空——Kotlin 启动时会校验它")
}
if GetContractVersion() != BridgeContractVersion {
t.Fatalf("GetContractVersion()=%q 与常量 %q 不一致", GetContractVersion(), BridgeContractVersion)
}
}
// TestTargetModeValues 发送/接收能力不对称依赖这三个数值稳定。
func TestTargetModeValues(t *testing.T) {
if TargetModePrivate != 1 || TargetModeChannel != 2 || TargetModeServer != 3 {
t.Fatalf("targetMode 取值被改动:private=%d channel=%d server=%d(必须为 1/2/3)",
TargetModePrivate, TargetModeChannel, TargetModeServer)
}
}