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.
 
 
 
 
 
 

832 lines
32 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 是客户端启动就要用的服务地址/密钥。合并口径错一点,客户端就连到别的区域去了。
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 应该是库字段,测试基准不成立")
}
}
// 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"}
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)
}
}