You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1063 lines
45 KiB

// 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=<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)
}
}
}