// user 模块的全部单元测试集中在本文件,别再按接口拆一堆 xxx_test.go。 // 分区顺序:① 公共桩 ② getappconfig_v3 ③ getagents_v3 ④ getchannelapp ⑤ 老客户端兼容 ⑥ 杂项 ⑦ 线上联调探针。 // 用内部包 package user,测试可以直接调未导出的函数(resolveAgentVoices / mergeAppEnv 等)。 // // 约定:每个测试函数自带它要用的数据/地址/密钥(写在函数体内的局部 const/变量), // 不放包级全局——改一个接口的联调参数不会波及别的测试。①区只放无状态的桩类型与构造函数。 package user import ( "bytes" "encoding/json" "fmt" "io" "math/rand" "net/http" "os" "reflect" "strings" "sync" "testing" "time" "yunyan/comm" "yunyan/lego/utils/crypto/aes" "yunyan/pb" "github.com/golang-jwt/jwt/v4" "gorm.io/gorm/schema" ) // --------------------------------------------------------------------------- // ① 公共桩 // --------------------------------------------------------------------------- // fakeEchomeet 假的会议记录模块,只为把一份能力快照喂给 v3 的 meet 字段。 type fakeEchomeet struct{ caps comm.MeetCapabilities } func (f *fakeEchomeet) MeetCapabilities() comm.MeetCapabilities { return f.caps } // newV3Comp 造一个只够跑纯逻辑的 apiV3Comp:不 Init(Init 要真 service), // 只挂一个带 echomeet 的 User。echomeet 传 nil = 本服务没装 echomeet 模块。 func newV3Comp(echomeet comm.IEchomeet) *apiV3Comp { return &apiV3Comp{module: &User{echomeet: echomeet}} } // --------------------------------------------------------------------------- // ② user_getappconfig_v3 // --------------------------------------------------------------------------- // env 是客户端启动就要用的服务地址/密钥。合并口径错一点,客户端就连到别的区域去了。 func TestV3MergeAppEnv(t *testing.T) { config := []*pb.DBAppConfigItem{ {Key: "OSS_BUCKET", Value: "app-oss"}, {Key: "LLM_URL", Value: "https://app-default"}, // 会被本区域的 global 覆盖 } globalconfig := []*pb.DBGlobalConfigItem{ {Region: pb.Region_RegionChina, Key: "LLM_URL", Value: "https://cn"}, {Region: pb.Region_RegionChina, Key: "CN_ONLY", Value: "1"}, {Region: pb.Region_RegionUSA, Key: "LLM_URL", Value: "https://us"}, {Region: pb.Region_RegionUSA, Key: "US_ONLY", Value: "1"}, } t.Run("本区域 global 覆盖 app_config", func(t *testing.T) { env := mergeAppEnv(config, globalconfig, pb.Region_RegionChina) if env["LLM_URL"] != "https://cn" { t.Fatalf("同名 key 应被本区域 global_config 覆盖,实际 %s", env["LLM_URL"]) } if env["OSS_BUCKET"] != "app-oss" { t.Fatalf("global 没配的 key 应保留 app_config 的值,实际 %s", env["OSS_BUCKET"]) } if env["CN_ONLY"] != "1" { t.Fatal("本区域独有的 key 应下发") } if _, ok := env["US_ONLY"]; ok { t.Fatal("别的区域的 key 不能下发给中国用户") } }) t.Run("换个区域取到的是另一套值", func(t *testing.T) { env := mergeAppEnv(config, globalconfig, pb.Region_RegionUSA) if env["LLM_URL"] != "https://us" { t.Fatalf("美区应取美区的值,实际 %s", env["LLM_URL"]) } if _, ok := env["CN_ONLY"]; ok { t.Fatal("中国区独有的 key 不能下发给美区用户") } }) t.Run("没有该区域配置时只剩 app_config 打底", func(t *testing.T) { env := mergeAppEnv(config, globalconfig, pb.Region_RegionBrazil) if env["LLM_URL"] != "https://app-default" { t.Fatalf("无区域覆盖时应回落到 app_config,实际 %s", env["LLM_URL"]) } if len(env) != 2 { t.Fatalf("不该混入别区域的 key,实际 %+v", env) } }) t.Run("空配置返回空 map 而不是 nil", func(t *testing.T) { env := mergeAppEnv(nil, nil, pb.Region_RegionUSA) if env == nil { t.Fatal("env 必须非 nil,否则下发 JSON 是 null,客户端取值直接崩") } if len(env) != 0 { t.Fatalf("空输入应得空 map,实际 %+v", env) } }) } // echomeet 没随本服务装载时(比如只跑 home 不跑会议记录),meet 得是个字段齐全的空壳, // 不能是 null——客户端不该为此写一堆判空。 func TestV3MeetCapabilitiesWithoutEchomeet(t *testing.T) { item := newV3Comp(nil).meetCapabilities() if item == nil { t.Fatal("meet 不能为 nil") } if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil { t.Fatalf("列表必须是空数组而不是 nil:%+v", item) } buf, err := json.Marshal(item) if err != nil { t.Fatal(err) } if strings.Contains(string(buf), "null") { t.Fatalf("下发 JSON 不该出现 null:%s", buf) } } // 能力快照 → 下发结构的逐字段映射。漏一个字段客户端就会少一种语言/少一个可选模型。 func TestV3MeetCapabilitiesMapping(t *testing.T) { caps := comm.MeetCapabilities{ TranscribeLanguages: []string{"zh-CN", "en-US"}, TranscribeAnyLanguage: true, TranslateLanguages: []string{"ja-JP"}, TranslateAnyLanguage: false, SummaryClientChoice: true, SummaryServices: []comm.MeetSummaryService{ {SvcId: "llm_qwen", Name: "通义千问", IsDefault: true}, {SvcId: "llm_gpt", Name: "GPT", IsDefault: false}, }, } item := newV3Comp(&fakeEchomeet{caps: caps}).meetCapabilities() if !reflect.DeepEqual(item.TranscribeLanguages, caps.TranscribeLanguages) { t.Fatalf("识别语言映射错:%+v", item.TranscribeLanguages) } if !reflect.DeepEqual(item.TranslateLanguages, caps.TranslateLanguages) { t.Fatalf("翻译语言映射错:%+v", item.TranslateLanguages) } if !item.TranscribeAnyLanguage || item.TranslateAnyLanguage { t.Fatalf("不限语言标记映射错:transcribe=%v translate=%v", item.TranscribeAnyLanguage, item.TranslateAnyLanguage) } if !item.SummaryClientChoice { t.Fatal("summary_client_choice 映射错,客户端会以为不能选模型") } if len(item.SummaryServices) != 2 { t.Fatalf("总结模型应有 2 项,实际 %d", len(item.SummaryServices)) } got := item.SummaryServices[0] if got.SvcId != "llm_qwen" || got.Name != "通义千问" || !got.IsDefault { t.Fatalf("总结模型项映射错:%+v", got) } if item.SummaryServices[1].IsDefault { t.Fatal("非默认项不该被标成默认,客户端会选错模型") } } // echomeet 在,但后台还没编排出任何服务:语言列表仍要是空数组; // 「不限语言」这类标记即使列表为空也得照传(此时列表为空不代表不支持)。 func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) { item := newV3Comp(&fakeEchomeet{caps: comm.MeetCapabilities{TranscribeAnyLanguage: true}}).meetCapabilities() if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil { t.Fatalf("空快照下列表仍须为空数组:%+v", item) } if !item.TranscribeAnyLanguage { t.Fatal("列表为空时 any_language 更要照传,否则客户端会以为一种语言都不支持") } } // v3 的响应契约:不再有 mcps(并入 thirdsvcs),必须有 meet 与 call_translate。 // pb 重新生成或有人手改 proto 把字段动了,这里先炸,而不是等客户端解析失败。 func TestV3AppConfigRespContract(t *testing.T) { resp := &pb.UserGetAppConfigV3Resp{ Env: map[string]string{}, Agents: []*pb.AgentItemV3{}, // v3 下发的是带 type/服务编排的新结构,不是旧 agent 表 Products: []*pb.DBProduct{}, Thirdsvcs: []*pb.ThirdSvcItem{}, Meet: newV3Comp(nil).meetCapabilities(), CallTranslate: &pb.CallTranslateItem{ Rules: []*pb.CallTranslateRuleItem{}, Agents: []*pb.AgentItemV3{}, }, } buf, err := json.Marshal(resp) if err != nil { t.Fatal(err) } var got map[string]any if err = json.Unmarshal(buf, &got); err != nil { t.Fatal(err) } for _, k := range []string{"env", "agents", "products", "thirdsvcs", "meet", "call_translate"} { if _, ok := got[k]; !ok { t.Fatalf("v3 响应缺字段 %s:%s", k, buf) } } if _, ok := got["mcps"]; ok { t.Fatalf("v3 不该再下发 mcps(已并入 thirdsvcs):%s", buf) } } // 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。 func TestV3McpIdentifiedByCategories(t *testing.T) { svcs := []*pb.ThirdSvcItem{ {Id: "svc_llm", Categories: "1,2"}, {Id: "svc_mcp", Categories: fmt.Sprintf("3,%d", comm.SvcCatMCP)}, {Id: "svc_mcp_only", Categories: fmt.Sprintf("%d", comm.SvcCatMCP)}, } mcps := make([]string, 0, len(svcs)) for _, s := range svcs { if comm.CategoriesHasMCP(s.Categories) { mcps = append(mcps, s.Id) } } if !reflect.DeepEqual(mcps, []string{"svc_mcp", "svc_mcp_only"}) { t.Fatalf("MCP 识别错,实际 %+v", mcps) } } // 路由靠反射按方法名注册:方法改名 / 签名不合规 → 路由静默消失,客户端 404。 // Version 在 Init 里置为 "v3",这里按注册规则算出路由名对一遍。 func TestV3GetAppConfigRoute(t *testing.T) { m, ok := reflect.TypeOf(&apiV3Comp{}).MethodByName("GetAppConfig") if !ok { t.Fatal("apiV3Comp 上找不到 GetAppConfig,路由 user_getappconfig_v3 会消失") } mt := m.Type if mt.NumIn() != 3 || mt.NumOut() != 2 { t.Fatalf("签名不合反射注册约定:入参 %d 出参 %d", mt.NumIn(), mt.NumOut()) } if !mt.In(1).Implements(reflect.TypeOf((*comm.IUserSession)(nil)).Elem()) { t.Fatalf("第一个入参须是 comm.IUserSession,实际 %s", mt.In(1)) } if mt.In(2) != reflect.TypeOf(&pb.UserGetAppConfigV3Req{}) { t.Fatalf("请求类型应为 *pb.UserGetAppConfigV3Req,实际 %s", mt.In(2)) } if mt.Out(0) != reflect.TypeOf(&pb.UserGetAppConfigV3Resp{}) { t.Fatalf("响应类型应为 *pb.UserGetAppConfigV3Resp,实际 %s", mt.Out(0)) } if mt.Out(1) != reflect.TypeOf(&pb.ErrorData{}) { t.Fatalf("第二个出参应为 *pb.ErrorData,实际 %s", mt.Out(1)) } route := fmt.Sprintf("%s_%s_%s", comm.ModuleUser, strings.ToLower(m.Name), "v3") if route != "user_getappconfig_v3" { t.Fatalf("反射算出的路由是 %s,与客户端约定的 user_getappconfig_v3 不一致", route) } } // --------------------------------------------------------------------------- // ③ user_getagents_v3 // --------------------------------------------------------------------------- // v3 下发的 voice_map 是「补全后」的:客户端不带音色表,切语言时得能直接查到。 func TestResolveAgentVoices(t *testing.T) { a := &comm.AgentConfig{ Type: comm.AgentTypeChat, Gender: comm.GenderFemale, SupportLangs: "zh-CN, en-US ,ja-JP", DefaultLang: "zh-CN", // 与 support_langs 重复,不该出两条 VoiceMap: []comm.AgentVoice{ {Language: "zh-CN", Gender: comm.GenderFemale, Voice: "zh-CN-XiaoyiNeural"}, // 运营选过,优先 }, } got := resolveAgentVoices(a, comm.TTSProviderAzure) if len(got) != 3 { t.Fatalf("三种支持语言应各一条,实际 %d 条: %+v", len(got), got) } byLang := map[string]string{} for _, v := range got { if _, dup := byLang[v.Language]; dup { t.Fatalf("语言 %s 出现重复", v.Language) } byLang[v.Language] = v.Voice } if byLang["zh-CN"] != "zh-CN-XiaoyiNeural" { t.Fatalf("运营在后台选过的音色应优先,实际 %s", byLang["zh-CN"]) } if byLang["en-US"] != "en-US-JennyNeural" || byLang["ja-JP"] != "ja-JP-NanamiNeural" { t.Fatalf("未配的语言应按音色表取第一个女声,实际 %+v", byLang) } } // 音色表覆盖不到的:不能瞎发一个别的语言的音色,宁可不发让客户端走服务商默认。 func TestResolveAgentVoicesSkipsUnknown(t *testing.T) { a := &comm.AgentConfig{Type: comm.AgentTypeChat, Gender: comm.GenderMale, SupportLangs: "zh-CN,xx-YY"} got := resolveAgentVoices(a, comm.TTSProviderAzure) if len(got) != 1 || got[0].Language != "zh-CN" { t.Fatalf("未知语言不该下发,实际 %+v", got) } // 服务商不在音色表里(火山等)且运营没手配 → 一条都不发 if n := len(resolveAgentVoices(a, "volcengine")); n != 0 { t.Fatalf("音色表未覆盖的服务商应不下发音色,实际 %d 条", n) } // 但运营手填过的照发 a2 := &comm.AgentConfig{ Type: comm.AgentTypeChat, SupportLangs: "zh-CN", VoiceMap: []comm.AgentVoice{{Language: "zh-CN", Voice: "BV700_streaming"}}, } got2 := resolveAgentVoices(a2, "volcengine") if len(got2) != 1 || got2[0].Voice != "BV700_streaming" { t.Fatalf("手填音色应照常下发,实际 %+v", got2) } } // 出声的是哪个服务:对话类看 TTS,端到端对话看 STS(音色候选按服务商区分,取错就全错)。 // 三段式(chat / translate)出声靠 TTS,端到端由 STS / AST 服务自己合成。 // 取错服务就会拿别家服务商的音色表去补全 voice_map,下发一串对端根本不认的音色名。 func TestVoiceSvcIdOf(t *testing.T) { a := &comm.AgentConfig{TtsSvcId: "tts_azure", StsSvcId: "sts_x", AstSvcId: "ast_y"} for _, c := range []struct{ typ, want string }{ {comm.AgentTypeChat, "tts_azure"}, {comm.AgentTypeTranslate, "tts_azure"}, {comm.AgentTypeStsChat, "sts_x"}, {comm.AgentTypeAstTranslate, "ast_y"}, } { a.Type = c.typ if got := voiceSvcIdOf(a); got != c.want { t.Fatalf("%s 应取 %s,实际 %s", c.typ, c.want, got) } } } // --------------------------------------------------------------------------- // ④ user_getchannelapp(应用发版版本判定) // --------------------------------------------------------------------------- // 客户端拿到的就是这三个布尔值,各端据此决定「提示更新 / 阻断使用 / 隐藏未过审功能」, // 判定错了直接表现为线上事故,故逐个组合固化。 func TestEvalChannelUpgrade(t *testing.T) { cfg := &comm.AppRelease{Version: "1.10.0", MinVersion: "1.5.0", ForceUpdate: true, ReviewVersion: "1.11.0"} cases := []struct { name string cfg *comm.AppRelease client string need, force, reviewing bool }{ {"低于最低支持版本:强更", cfg, "1.4.9", true, true, false}, {"高于最低支持、低于线上:只提示更新", cfg, "1.9.0", true, false, false}, {"已是线上最新:什么都不提示", cfg, "1.10.0", false, false, false}, {"高于线上版本(内测包):不提示", cfg, "1.11.0", false, false, true}, {"送审版本:标记审核期", cfg, "1.11.0", false, false, true}, {"客户端未上报版本:一律 false,不能把老客户端全挡住", cfg, "", false, false, false}, {"该应用未配置发版信息:一律 false", nil, "1.0.0", false, false, false}, {"字符串比较陷阱:1.9 < 1.10 应判定为需更新", cfg, "1.9.9", true, false, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { need, force, reviewing := evalChannelUpgrade(c.cfg, c.client) if need != c.need || force != c.force || reviewing != c.reviewing { t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v", c.client, need, force, reviewing, c.need, c.force, c.reviewing) } }) } } // 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。 func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) { cfg := &comm.AppRelease{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true} need, force, _ := evalChannelUpgrade(cfg, "1.5.0") if !force { t.Fatal("低于最低支持版本应强更") } if !need { t.Fatal("强更时 needupdate 必须同时为 true,否则客户端会收到「必须更新但无更新」") } } // 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。 func TestEvalChannelUpgradeNilSafe(t *testing.T) { if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing { t.Fatal("nil 配置必须全 false") } } // --------------------------------------------------------------------------- // ⑤ 老客户端兼容字段 // --------------------------------------------------------------------------- // 老包按「非空 int」解析 factoryid,缺字段整个响应就解析不了。这里固化两件事: // 值等于 brandid,且 brandid=0 时也要输出 0(不能被 omitempty 吃掉变成 null)。 func TestCompatProductFillsFactoryid(t *testing.T) { cases := []struct { name string brandid uint32 }{ {"正常品牌商", 40981}, {"无品牌归属:仍要输出 0,不能缺字段", 0}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { p := compatProduct(&pb.DBProduct{Id: 45107, Brandid: c.brandid}) if p.Factoryid != c.brandid { t.Fatalf("factoryid=%d,期望等于 brandid=%d", p.Factoryid, c.brandid) } buf, err := json.Marshal(p) if err != nil { t.Fatal(err) } var got map[string]any if err = json.Unmarshal(buf, &got); err != nil { t.Fatal(err) } v, ok := got["factoryid"] if !ok || v == nil { t.Fatalf("下发 JSON 缺 factoryid(老客户端会 TypeError):%s", buf) } if v.(float64) != float64(c.brandid) { t.Fatalf("下发 factoryid=%v,期望 %d", v, c.brandid) } }) } } func TestCompatProductsNilSafe(t *testing.T) { if compatProduct(nil) != nil { t.Fatal("nil 入参不应造出对象") } compatProducts([]*pb.DBProduct{nil, {Brandid: 7}}) // 不能 panic } // factoryid 是纯下发字段:gorm 必须完全忽略它,否则 AutoMigrate 会给产品表建一个恒为 0 的多余列。 func TestFactoryidNotPersisted(t *testing.T) { s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatal(err) } if f := s.LookUpField("factoryid"); f != nil { t.Fatalf("factoryid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName) } if s.LookUpField("brandid") == nil { t.Fatal("brandid 应该是库字段,测试基准不成立") } } // --------------------------------------------------------------------------- // ⑥ 杂项(token / 加解密工具) // --------------------------------------------------------------------------- func Test_Claims(t *testing.T) { claims := &jwt.RegisteredClaims{ Issuer: "Earphone", Subject: "Sgin", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), // 24 hours expiration NotBefore: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()), ID: "2010882799132737536", } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString([]byte("asd123adqeqwe")) if err != nil { t.Fatalf("Error signing token: %v", err) } fmt.Println("Generated Token:", tokenString) // Call the parse function to verify the token parsedClaims, err := ParseToken(tokenString, []byte("asd123adqeqwe")) if err != nil { t.Fatalf("Error parsing token: %v", err) } // Validate the claims if parsedClaims.Issuer != claims.Issuer { t.Errorf("Expected Issuer %v, got %v", claims.Issuer, parsedClaims.Issuer) } if parsedClaims.Subject != claims.Subject { t.Errorf("Expected Subject %v, got %v", claims.Subject, parsedClaims.Subject) } if parsedClaims.ID != claims.ID { t.Errorf("Expected ID %v, got %v", claims.ID, parsedClaims.ID) } } func ParseToken(tokenString string, secretKey []byte) (*jwt.RegisteredClaims, error) { parsedToken, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { // Ensure the token method conforms to "SigningMethodHMAC" if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return secretKey, nil }) if err != nil { return nil, err } // Validate the token and extract the claims if claims, ok := parsedToken.Claims.(*jwt.RegisteredClaims); ok && parsedToken.Valid { return claims, nil } else { return nil, fmt.Errorf("invalid token") } } // 生成随机大写字母授权码(无重复字符) func generateRandomCode(length int) string { // 定义大写字母集合 const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" // 初始化随机种子 rand.Seed(time.Now().UnixNano()) // 打乱字母顺序 shuffled := []rune(letters) rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) // 取前 length 个字符 return string(shuffled[:length]) } func Test_CBC(t *testing.T) { token := aes.AesEncryptCBC("+8613536758045", "gDSiGlKJIGuu84ZNe+j4FQ==") phone := aes.AesDecryptCBC(token, "gDSiGlKJIGuu84ZNe+j4FQ==") fmt.Println(token, phone) } // --------------------------------------------------------------------------- // ⑦ 线上联调探针(会真发网络请求,`go test -short` 可跳过) // --------------------------------------------------------------------------- // 联调用:打一次线上接口,解密后反序列化成对象,方便打断点看数据。 // 每个探针的 url / token / key 都写在**它自己的函数里**,测哪个接口就只改那个函数,互不影响。 // key 留空 = 不解密(v1 之类不加密的接口)。⚠️ 数据含明文凭据,别外传。 // // probeCall 打一次接口并返回(必要时已解密的)响应体明文。 // probeEnv 给联调探针取环境参数:默认打阿龙测试环境,可用环境变量临时覆盖。 // // PROBE_HOST=https://xxx PROBE_TOKEN=<新token> PROBE_KEY=<该环境的key> \ // go test ./modules/user/ -run TestApiProbeAppConfigV3 -v // // ⚠️ token 必须由调用方给:JWT 会过期,写死在代码里的那个迟早失效, // 而这两个探针属于 `go test ./...` 的一部分,一旦失效就变成常态红灯 // (2026-09-03 之前就是这样:写死的 token 早已过期,每次跑都 code=18 NoLogin)。 // 没给 token 就 Skip —— 探针是手工联调工具,不该拖累整包测试。 func probeEnv(t *testing.T) (host, token, key string) { t.Helper() host = os.Getenv("PROBE_HOST") if host == "" { host = "https://ym-dev.ymaikj.com" // 阿龙测试环境,同 apps/client/.env 的 SERVER_URL } key = os.Getenv("PROBE_KEY") if key == "" { // 阿龙测试环境 .env 的 GATEWAY_ENCRYPT_KEY。 // ⚠️ 换环境必须同时换 key,否则响应解密不出来(各环境的 key 不同)。 key = "83KNTeWWQ4GiQHhINJH1H8RyN88HNUOf" } token = os.Getenv("PROBE_TOKEN") if token == "" { t.Skip("联调探针需要一个有效的登录 token:PROBE_TOKEN= go test ...") } return } func probeCall(t *testing.T, url, token, key string) string { t.Helper() req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("{}")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("请求失败: %v", err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) body := string(raw) if resp.Header.Get("X-Encrypted") == "1" { if key == "" { t.Fatal("响应是加密的,请在调用方的 key 里填该环境的 GATEWAY_ENCRYPT_KEY") } body = aes.AesDecryptCBC(strings.TrimSpace(body), key) if !strings.HasPrefix(strings.TrimSpace(body), "{") { t.Fatalf("解密失败:key 与该环境的 GATEWAY_ENCRYPT_KEY 不一致(长度须 16/24/32,当前 %d)", len(key)) } } return body } // user_getappconfig_v2 的线上联调: // // go test ./modules/user/ -run TestApiProbeAppConfigV2 -v func TestApiProbeAppConfigV2(t *testing.T) { if testing.Short() { t.Skip("联调探针会打线上接口,-short 下跳过") } host, token, key := probeEnv(t) body := probeCall(t, host+"/api/home/user_getappconfig_v2", token, key) // ① 强类型对象——断点看它,字段能直接展开 var result struct { Code int `json:"code"` Msg string `json:"msg"` Data pb.UserGetAppConfigResp `json:"data"` } if err := json.Unmarshal([]byte(body), &result); err != nil { t.Fatalf("反序列化失败: %v", err) } env := result.Data.Env agents := result.Data.Agents mcps := result.Data.Mcps products := result.Data.Products // ② 通用对象——换了接口懒得改 Data 类型时,看这个 var generic map[string]interface{} _ = json.Unmarshal([]byte(body), &generic) // 断点打在这一行,watch: result / env / mcps / generic t.Logf("code=%d msg=%s | env=%d agents=%d mcps=%d products=%d", result.Code, result.Msg, len(env), len(agents), len(mcps), len(products)) for k, v := range env { t.Logf(" env[%s] = %s", k, v) } for id, m := range mcps { t.Logf(" mcp[%s] name=%s url=%s type=%v tools=%s", id, m.Servername, m.Url, m.Type, m.Tools) } for _, a := range agents { t.Logf(" agent id=%s name=%s", a.Id, a.Name) } } // user_getappconfig_v3 的线上联调:核对 v3 相对 v2 的两处变化真的落到了响应里 // —— 不再有 mcps(MCP 混在 thirdsvcs 里按 categories 含 10 识别)、多了 meet。 // // go test ./modules/user/ -run TestApiProbeAppConfigV3 -v func TestApiProbeAppConfigV3(t *testing.T) { if testing.Short() { t.Skip("联调探针会打线上接口,-short 下跳过") } host, token, key := probeEnv(t) body := probeCall(t, host+"/api/home/user_getappconfig_v3", token, key) var result struct { Code int `json:"code"` Msg string `json:"msg"` Data pb.UserGetAppConfigV3Resp `json:"data"` } if err := json.Unmarshal([]byte(body), &result); err != nil { t.Fatalf("反序列化失败: %v", err) } if result.Code != 0 { t.Fatalf("接口返回失败 code=%d msg=%s", result.Code, result.Msg) } d := &result.Data // 取指针:pb 结构体含锁,直接复制会被 go vet 拦下 // 断点打在下一行,watch: result / d.Env / d.Thirdsvcs / d.Meet t.Logf("env=%d agents=%d products=%d thirdsvcs=%d", len(d.Env), len(d.Agents), len(d.Products), len(d.Thirdsvcs)) for k, v := range d.Env { t.Logf(" env[%s] = %s", k, v) } for _, s := range d.Thirdsvcs { t.Logf(" svc[%s] name=%s provider=%s categories=%s fields=%d mcp=%v", s.Id, s.Name, s.Provider, s.Categories, len(s.Fields), comm.CategoriesHasMCP(s.Categories)) } for _, a := range d.Agents { t.Logf(" agent id=%s name=%s", a.Id, a.Name) } // meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。 if d.Meet == nil { t.Fatal("v3 必须下发 meet 字段(没编排也要给空壳)") } t.Logf(" meet transcribe=%v(any=%v) translate=%v(any=%v) summary=%d(choice=%v)", d.Meet.TranscribeLanguages, d.Meet.TranscribeAnyLanguage, d.Meet.TranslateLanguages, d.Meet.TranslateAnyLanguage, len(d.Meet.SummaryServices), d.Meet.SummaryClientChoice) for _, s := range d.Meet.SummaryServices { t.Logf(" summary svc=%s name=%s default=%v", s.SvcId, s.Name, s.IsDefault) } // v3 不再单独下发 mcps:真出现了说明有人往回加字段,客户端会重复处理 MCP。 var generic map[string]any if err := json.Unmarshal([]byte(body), &generic); err == nil { if data, ok := generic["data"].(map[string]any); ok { if _, ok := data["mcps"]; ok { t.Fatal("v3 响应里出现了 mcps,应只在 thirdsvcs 里下发 MCP") } } } } // --------------------------------------------------------------------------- // ⑥ 下发结构由两张表拼装(app_release + channel_app) // --------------------------------------------------------------------------- // ⚠️ 这一条钉的是真实事故:运营在「游客显隐控制」里配好了游客登录,App 却一直不显示入口。 // 原因是 toPbChannelApp 在渠道行缺失时整个返回 nil —— 应用级的开关被「渠道分发里有没有 // 这一行」卡死。测试机是荣耀,品牌推导出 CHINA_HONOR,而后台只建过官方渠道的行。 func TestToPbChannelAppSurvivesMissingChannelRow(t *testing.T) { rel := &comm.AppRelease{ Version: "1.0.1", Tourists: true, TouristsVersion: "1.0.1", Address: "https://default/app.apk", } got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1") if got == nil { t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死") } if !got.Tourists { t.Fatal("游客登录是应用级开关,与有没有建这个渠道的行无关") } if got.Channel != comm.ChannelChinaHonor { t.Fatalf("渠道号应回显请求的渠道,实际 %d", got.Channel) } if got.Version != "1.0.1" { t.Fatalf("版本应取自 app_release,实际 %q", got.Version) } if got.Address != "https://default/app.apk" { t.Fatalf("没有渠道地址时应回退应用默认地址,实际 %q", got.Address) } } // 反过来:渠道行在、发版配置没配,也要照常下发渠道那部分(支付渠道等), // 只是版本相关字段为空 —— 客户端据此走「无更新」分支。 func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) { ch := &comm.ChannelApp{ Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", PayChannels: "wxpay", AllowSkipDeviceBinding: true, Status: comm.ChannelStatusOnline, } got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1") if got == nil { t.Fatal("发版配置未配时仍应下发渠道信息") } if got.Paychannels != "wxpay" || !got.Allowskipdevicebinding { t.Fatalf("渠道字段丢失:%+v", got) } if got.Version != "" || got.Tourists { t.Fatalf("发版配置缺失时版本/游客字段必须为零值,实际 %+v", got) } if got.Address != "https://xiaomi/store" { t.Fatalf("渠道地址应优先,实际 %q", got.Address) } } // 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。 func TestToPbChannelAppAddressPrecedence(t *testing.T) { rel := &comm.AppRelease{Address: "https://default/app.apk"} ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store"} if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1"); got.Address != "https://huawei/store" { t.Fatalf("渠道地址应覆盖应用默认地址,实际 %q", got.Address) } } // 两张表都没配才返回 nil。 func TestToPbChannelAppBothNil(t *testing.T) { if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1"); got != nil { t.Fatalf("两边都没配时应返回 nil,实际 %+v", got) } }