// user 模块的全部单元测试集中在本文件,别再按接口拆一堆 xxx_test.go。 // 分区顺序:① 公共桩 ② getappconfig_v3 ③ getagents_v3 ④ getchannelapp ⑤ 老客户端兼容 ⑥ 杂项 ⑦ 线上联调探针。 // 用内部包 package user,测试可以直接调未导出的函数(resolveAgentVoices / mergeAppEnv 等)。 // // 约定:每个测试函数自带它要用的数据/地址/密钥(写在函数体内的局部 const/变量), // 不放包级全局——改一个接口的联调参数不会波及别的测试。①区只放无状态的桩类型与构造函数。 package user import ( "bytes" "context" "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 } // ChatLLM 本测试用不到(user 模块只关心能力下发),给个空实现满足接口。 func (f *fakeEchomeet) ChatLLM(ctx context.Context, svcId, systemPrompt, userContent string) (string, string, error) { return "", "", nil } // 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 是客户端启动就要用的服务地址/密钥。凭据 2026-09-12 起统一收在 svc_config, // 由字段上的 env_key 回填一份 env 给存量客户端——这层改名映射错一点, // 老客户端就拿不到凭据,且只会表现为「某个能力鉴权失败」,不报配置错误。 func TestSvcEnvOverlay(t *testing.T) { svc := &comm.ThirdSvcConfig{ Id: "stt_azure", Name: "Azure STT", Categories: "1", Enable: true, Fields: []comm.SvcField{ {Key: "subscription_key", DefValue: "k-default", EnvKey: "AZURE_SPEECH_KEY"}, {Key: "region", DefValue: "eastasia", EnvKey: "AZURE_SPEECH_REGION"}, {Key: "note", DefValue: "内部备注"}, // 没有 env_key,不该进 env }, } t.Run("按 env_key 改名进 env,没配 env_key 的字段不进", func(t *testing.T) { fields, err := comm.ResolveSvcPlainFields(svc, nil, "") if err != nil { t.Fatal(err) } env := comm.ResolveSvcEnvKeys(svc, nil, fields) if env["AZURE_SPEECH_KEY"] != "k-default" { t.Fatalf("字段值应按 env_key 下发,实际 %q", env["AZURE_SPEECH_KEY"]) } if _, ok := env["note"]; ok { t.Fatal("没配 env_key 的字段不能出现在 env 里") } if len(env) != 2 { t.Fatalf("只有两个字段配了 env_key,实际 %+v", env) } }) t.Run("区域覆盖的值进 env,区域停用的字段不进", func(t *testing.T) { ovr := &comm.SvcRegionOverride{ SvcId: svc.Id, Region: int32(pb.Region_RegionChina), Overrides: map[string]string{"region": "chinaeast2"}, DisabledKeys: []string{"subscription_key"}, } fields, err := comm.ResolveSvcPlainFields(svc, ovr, "") if err != nil { t.Fatal(err) } env := comm.ResolveSvcEnvKeys(svc, ovr, fields) if env["AZURE_SPEECH_REGION"] != "chinaeast2" { t.Fatalf("应取本区域覆盖值,实际 %q", env["AZURE_SPEECH_REGION"]) } // 区域停用的字段在 ResolveSvcPlainFields 那一步就被剔掉了,env 里也不该凭空出现。 if _, ok := env["AZURE_SPEECH_KEY"]; ok { t.Fatal("该区域停用的字段不能下发") } }) t.Run("区域专属字段也能带自己的 env_key", func(t *testing.T) { ovr := &comm.SvcRegionOverride{ SvcId: svc.Id, Region: int32(pb.Region_RegionChina), ExtraFields: []comm.SvcField{{Key: "endpoint", DefValue: "https://cn", EnvKey: "AZURE_SPEECH_ENDPOINT"}}, } fields, err := comm.ResolveSvcPlainFields(svc, ovr, "") if err != nil { t.Fatal(err) } env := comm.ResolveSvcEnvKeys(svc, ovr, fields) if env["AZURE_SPEECH_ENDPOINT"] != "https://cn" { t.Fatalf("区域专属字段应按自己的 env_key 下发,实际 %q", env["AZURE_SPEECH_ENDPOINT"]) } }) // 这条是闸门:VIP 过期时 filterSvcsByVip 会把 AST 服务从 items 里拿掉, // env 必须跟着消失——否则老客户端照样能从 env 读出通话翻译凭据直连服务商。 t.Run("被过滤掉的服务,其 env 也不下发", func(t *testing.T) { envById := map[string]map[string]string{ "stt_azure": {"AZURE_SPEECH_KEY": "k"}, "ast_alibaba": {"ALIBABA_OPENSPEECH_APP_KEY": "secret"}, } kept := []*pb.ThirdSvcItem{{Id: "stt_azure", Categories: "1"}} env := svcEnvOverlay(kept, envById) if env["AZURE_SPEECH_KEY"] != "k" { t.Fatal("保留下来的服务,其 env 应下发") } if _, ok := env["ALIBABA_OPENSPEECH_APP_KEY"]; ok { t.Fatal("已被 VIP 过滤掉的服务,其凭据不能从 env 漏出去") } }) // 客户端大量写的是 `AppConfig.env('X') ?? 默认值`,Dart 的 `??` 只兜 null—— // 下发一个空串会把默认值顶掉。老的两张扁平表对「没配的键」是**根本不下发**, // 这里必须保持同样的行为。真机实测 AZURE_TRANSLATION_ENDPOINT 栽过这个坑。 t.Run("空值不产出 env 键", func(t *testing.T) { svc := &comm.ThirdSvcConfig{ Id: "mt_azure", Enable: true, Fields: []comm.SvcField{ {Key: "subscription_key", DefValue: "k", EnvKey: "AZURE_TRANSLATION_KEY"}, {Key: "endpoint", DefValue: "", EnvKey: "AZURE_TRANSLATION_ENDPOINT"}, {Key: "region", DefValue: " ", EnvKey: "AZURE_TRANSLATION_REGION"}, }, } fields, err := comm.ResolveSvcPlainFields(svc, nil, "") if err != nil { t.Fatal(err) } env := comm.ResolveSvcEnvKeys(svc, nil, fields) if env["AZURE_TRANSLATION_KEY"] != "k" { t.Fatal("有值的字段照常下发") } if _, ok := env["AZURE_TRANSLATION_ENDPOINT"]; ok { t.Fatal("空值字段不能下发——客户端的 ?? 默认值会被空串顶掉") } if _, ok := env["AZURE_TRANSLATION_REGION"]; ok { t.Fatal("只有空白字符的字段同样按没配处理") } }) t.Run("空输入返回空 map 而不是 nil", func(t *testing.T) { env := svcEnvOverlay(nil, nil) 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, nil, comm.ChannelChinaXiaomi, c.client, "https://dl/app.apk") 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, nil, comm.ChannelChinaXiaomi, "1.5.0", "https://dl/app.apk") 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, nil, comm.ChannelChinaXiaomi, "1.0.0", "https://dl/app.apk"); need || force || reviewing { t.Fatal("nil 配置必须全 false") } } // 2026-09-23 口径:版本号按平台分、渠道未上架不推、没地址不提示。每条都是线上会直接踩到的组合。 func TestEvalChannelUpgradePlatformStatusAddress(t *testing.T) { rel := &comm.AppRelease{Version: "1.0.3", IosVersion: "1.0.2", ForceUpdate: true} apk := "https://dl/app.apk" // iOS 渠道读 ios_version:Android 已发 1.0.3、iOS 商店还是 1.0.2 时,iOS 的 1.0.2 用户不该被提示 if need, _, _ := evalChannelUpgrade(rel, nil, comm.ChannelGlobalApple, "1.0.2", "https://apps.apple.com/x"); need { t.Fatal("iOS 客户端已是 iOS 线上版本,不该提示更新(不能拿 Android 的版本号判)") } if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || !force { t.Fatal("Android 1.0.2 低于 Android 线上 1.0.3,且最低版本留空 = 线上版本,应强更") } // 最低版本留空 = 线上版本:填了最低版本后,介于两者之间的只推荐不强制 withMin := &comm.AppRelease{Version: "1.0.3", MinVersion: "1.0.1", ForceUpdate: true} if need, force, _ := evalChannelUpgrade(withMin, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || force { t.Fatal("高于最低版本、低于线上版本:只推荐不强制") } // 渠道审核中:商店里还是旧包,推荐更新不推;强更不受影响 reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Status: comm.ChannelStatusReviewing, Address: "https://xiaomi/store"} if need, _, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.2", apk); need { t.Fatal("渠道审核中时不该推送推荐更新") } if need, force, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.0", apk); !need || !force { t.Fatal("渠道审核中也挡不住强更(地址已由回退链换成官方包)") } // 没有任何可去的地址:两种更新都不提示,强更也不例外 if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.0", ""); need || force { t.Fatal("没有下载地址时不该提示任何更新,否则弹出一个点了没反应又关不掉的框") } } // --------------------------------------------------------------------------- // ⑤ 老客户端兼容字段 // --------------------------------------------------------------------------- // 老包按「非空 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 应该是库字段,测试基准不成立") } } // devicetype 同样是纯下发字段(2026-09-08 废弃):产品上的「设备类型」已删除, // pb 里只留一个恒为 0 的兼容字段给老 App 包解析用。gorm 必须完全忽略它, // 否则 AutoMigrate 会把刚 DROP 掉的列又建回来。 func TestDevicetypeNotPersisted(t *testing.T) { s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatal(err) } if f := s.LookUpField("devicetype"); f != nil { t.Fatalf("devicetype 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName) } } // 老客户端按「非空 int」解析 devicetype,字段不下发即 TypeError、整个响应解析失败。 // 所以哪怕它已废弃,JSON 里也必须始终有这个键(值恒为 0)。 func TestDevicetypeStillEmittedInJSON(t *testing.T) { b, err := json.Marshal(&pb.DBProduct{Id: 1}) if err != nil { t.Fatal(err) } var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatal(err) } v, ok := m["devicetype"] if !ok { t.Fatal("下发的产品 JSON 里缺少 devicetype 键——已发布的 App 包会整个解析失败") } if v != float64(0) { t.Fatalf("devicetype 应恒为 0,实际 %v", v) } } // scanuuid 与 devicetype 同一处理(2026-09-08 废弃):设备校验与绑定奖励都改成按 MAC 判定, // 这个 UUID 不再有任何用途,库列已删,pb 里只留一个 gorm:"-" 的下发兼容字段。 func TestScanuuidNotPersisted(t *testing.T) { s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatal(err) } if f := s.LookUpField("scanuuid"); f != nil { t.Fatalf("scanuuid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName) } } // 老客户端按「非空 String」解析 scanuuid(`json['scanuuid'] as String`), // 字段不下发即 TypeError、整个 user_getappconfig 解析失败。所以键必须始终在,值恒为空串。 func TestScanuuidStillEmittedInJSON(t *testing.T) { b, err := json.Marshal(&pb.DBProduct{Id: 1}) if err != nil { t.Fatal(err) } var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatal(err) } v, ok := m["scanuuid"] if !ok { t.Fatal("下发的产品 JSON 里缺少 scanuuid 键——已发布的 App 包会整个解析失败") } if v != "" { t.Fatalf("scanuuid 应恒为空串,实际 %v", v) } } // --------------------------------------------------------------------------- // ⑥ 杂项(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", Status: comm.ChannelStatusOnline} if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://huawei/store" { t.Fatalf("渠道地址应覆盖官方渠道与应用默认地址,实际 %q", got.Address) } } // 回退链(2026-09-23):Android 渠道 → 官方渠道(100) → 默认地址;未上架的渠道地址视同没有;iOS 只认自己的商店链接。 func TestToPbChannelAppAddressFallbackChain(t *testing.T) { rel := &comm.AppRelease{Address: "https://default/app.apk", Version: "1.0.3", IosVersion: "1.0.2", MinVersion: "1.0.1"} // 渠道没填地址 → 官方渠道 if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" { t.Fatalf("渠道缺失时应先回退官方渠道地址,实际 %q", got.Address) } // 官方渠道也没有 → 默认地址 if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Address != "https://default/app.apk" { t.Fatalf("官方渠道也没有时应回退默认地址,实际 %q", got.Address) } // 渠道审核中:它的商店链接视同没有 reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", Status: comm.ChannelStatusReviewing} if got := toPbChannelApp(comm.ChannelChinaXiaomi, reviewing, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" { t.Fatalf("渠道未上架时不该用它的地址,实际 %q", got.Address) } // iOS:没填 App Store 链接就是空,绝不能回退到 apk if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "" { t.Fatalf("iOS 没有商店链接时地址必须为空,实际 %q", got.Address) } // iOS 的版本号来自 ios_version / ios_min_version(留空 = 线上版本) if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", ""); got.Version != "1.0.2" || got.Minversion != "1.0.2" { t.Fatalf("iOS 渠道应下发 iOS 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion) } if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Version != "1.0.3" || got.Minversion != "1.0.1" { t.Fatalf("Android 渠道应下发 Android 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion) } } // 两张表都没配才返回 nil。 func TestToPbChannelAppBothNil(t *testing.T) { if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1", ""); got != nil { t.Fatalf("两边都没配时应返回 nil,实际 %+v", got) } } // --------------------------------------------------------------------------- // ⑧ 实名绑定口径(idverify_binding.go) // --------------------------------------------------------------------------- // 已实名账号「修改认证」填错人:明细上的身份字段必须原样保留,只累计失败。 // 原实现先无条件覆盖身份再看结果,一次失败提交就把账号上的实名身份换成了没核验通过的人。 func TestIdVerifyFailureKeepsVerifiedIdentity(t *testing.T) { rec := &pb.DBUserIdVerify{Uid: "u1", Realname: "张三", Idcardmask: "4403**********1234", Idcardhash: "h-old", Provider: "aliyun", Svcid: "idverify_aliyun", Bizcode: "1", Verifytime: 1000} wrong := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h-new"} for _, bizcode := range []string{"2", ""} { // 不一致 / 服务商调用失败 replaced := applyIdVerifyAttempt(rec, false, wrong, "tencent", "idverify_tencent", bizcode, 2000) if replaced != nil { t.Fatalf("失败不算换人,不该返回旧身份") } if rec.Realname != "张三" || rec.Idcardhash != "h-old" || rec.Idcardmask != "4403**********1234" { t.Fatalf("失败的提交改写了已实名身份:%+v", rec) } if rec.Provider != "aliyun" || rec.Bizcode != "1" || rec.Verifytime != 1000 { t.Fatalf("失败的提交改写了通过时的核验信息:%+v", rec) } } if rec.Failcount != 2 || rec.Lastfailtime != 2000 { t.Fatalf("失败计数应累计:%+v", rec) } // 再提交回原来那个人 → 与记录一致,调用方据此免计费直接返回。 if !sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4403**********1234", idcardhash: "h-old"}) { t.Fatal("失败之后原身份应仍能匹配") } } // 未实名的记录可以记下最后一次尝试的身份(排查刷接口用),失败调用没有 bizcode 时不覆盖已有结果码。 func TestIdVerifyFailureOnUnverifiedRecord(t *testing.T) { rec := &pb.DBUserIdVerify{Uid: "u1", Bizcode: "2"} id := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h"} applyIdVerifyAttempt(rec, false, id, "aliyun", "s", "", 10) if rec.Realname != "李四" || rec.Idcardhash != "h" || rec.Bizcode != "2" || rec.Failcount != 1 { t.Fatalf("未实名记录应记下尝试身份且保留结果码:%+v", rec) } } // 通过:首次实名不算换人;已实名换成另一张证要把旧身份交出去留痕;同一个人重验不留痕。 func TestIdVerifySuccessRebind(t *testing.T) { rec := &pb.DBUserIdVerify{Uid: "u1"} a := idVerifyIdentity{realname: "张三", idcardmask: "4403**********1234", idcardhash: "h-a"} if r := applyIdVerifyAttempt(rec, true, a, "aliyun", "s", "1", 100); r != nil { t.Fatal("首次实名不该视为换人") } if rec.Verifytime != 100 || rec.Failcount != 0 || rec.Idcardhash != "h-a" { t.Fatalf("通过后记录未更新:%+v", rec) } rec.Failcount = 3 if r := applyIdVerifyAttempt(rec, true, a, "aliyun", "s", "1", 200); r != nil || rec.Failcount != 0 { t.Fatalf("同一个人重验不留痕且清零失败计数,实际 replaced=%v rec=%+v", r, rec) } b := idVerifyIdentity{realname: "李四", idcardmask: "1101**********5678", idcardhash: "h-b"} r := applyIdVerifyAttempt(rec, true, b, "tencent", "s2", "0", 300) if r == nil || r.Idcardhash != "h-a" || r.Idcardmask != "4403**********1234" || r.Verifytime != 200 { t.Fatalf("换人应交出旧身份留痕,实际 %+v", r) } if rec.Idcardhash != "h-b" || rec.Realname != "李四" || rec.Verifytime != 300 || rec.Provider != "tencent" { t.Fatalf("换人后记录应是新身份:%+v", rec) } } // 没配盐时指纹为空,退回比掩码;配了盐一律比指纹,掩码相同指纹不同是两个人。 func TestSameIdentityFallback(t *testing.T) { rec := &pb.DBUserIdVerify{Realname: "张三", Idcardmask: "4304**********8812", Idcardhash: ""} if !sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4304**********8812", idcardhash: "x"}) { t.Fatal("记录无指纹时应退回比掩码") } rec.Idcardhash = "h1" if sameIdentity(rec, idVerifyIdentity{realname: "张三", idcardmask: "4304**********8812", idcardhash: "h2"}) { t.Fatal("掩码相同但指纹不同是两张证件(测试机上真有这样一对)") } if sameIdentity(rec, idVerifyIdentity{realname: "张四", idcardmask: "4304**********8812", idcardhash: "h1"}) { t.Fatal("姓名不同不算同一个人") } } // 注销必须把 user 行上由实名派生的三个字段清掉,其余身份锚点不动。 func TestScrubCancelledUserIdVerify(t *testing.T) { u := &pb.DBUser{Uid: "u1", Phone: "+8613800000000", Idverified: true, Idverifiedtime: 123, Gender: 1, Isgiveaway: true} scrubCancelledUserIdVerify(u) if u.Idverified || u.Idverifiedtime != 0 || u.Gender != 0 { t.Fatalf("实名派生字段未清:%+v", u) } if u.Phone == "" || !u.Isgiveaway { t.Fatalf("不该动身份锚点与防刷标记:%+v", u) } } // 排序规则 → 字符集,且只接受白名单字符(它要拼进 DDL)。 func TestCollationCharset(t *testing.T) { for coll, want := range map[string]string{ "utf8mb4_0900_ai_ci": "utf8mb4", "utf8mb4_general_ci": "utf8mb4", "latin1_swedish_ci": "latin1", } { if got, ok := collationCharset(coll); !ok || got != want { t.Fatalf("%s → %q ok=%v,期望 %q", coll, got, ok, want) } } for _, bad := range []string{"", "utf8mb4", "utf8mb4_0900_ai_ci; DROP TABLE user", "UTF8MB4_0900_AI_CI"} { if _, ok := collationCharset(bad); ok { t.Fatalf("%q 不该被接受", bad) } } } // 实名认证的显隐随游客走:点名的那一版下发 idverify=true,其余版本 false(客户端据此整个放行实名闸门)。 func TestToPbChannelAppIdVerifyFollowsTourists(t *testing.T) { rel := &comm.AppRelease{Version: "1.0.3", Tourists: true, TouristsVersion: "1.0.3"} if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.3", ""); !got.Idverify || !got.Tourists { t.Fatalf("送审版本应同时显示游客与实名,实际 tourists=%v idverify=%v", got.Tourists, got.Idverify) } if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.2", ""); got.Idverify || got.Tourists { t.Fatalf("非送审版本应同时隐藏游客与实名,实际 tourists=%v idverify=%v", got.Tourists, got.Idverify) } }