fix(model):修复获取模型列表接口间歇性返回空的问题

This commit is contained in:
qian committed 2026-09-18 23:17:13 +08:00
1 parent 3039b5d7e3
commit c7ed4f6e55
3 files changed
+125 -42

No files matched your search

+4 -3
View File
@@ -369,9 +369,10 @@ func HandleModels(r *ghttp.Request) {
resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider)
if err != nil {
r.Response.WriteJsonExit(g.Map{
"error": g.Map{"type": "internal_error", "message": err.Error()},
})
// 统一走 WriteRelayError:OpenAI 原生错误格式 + 500 状态码 + 服务端日志。
// 此前 200 + 裸 error body,客户端无法感知失败(GetAvailableModels 内部错误
// 一度被吞掉后更是直接返回空列表)
handler.WriteRelayError(r.Response.Writer, err)
return
}
+85 -39
View File
@@ -178,10 +178,14 @@ func checkGroupModelAccess(ctx context.Context, tenantID int64, modelName string
}
}
// 缓存未命中:查询数据库
groupCount, _ := dao.MdlTenantGroups.Ctx(ctx).
// 缓存未命中:查询数据库。错误必须上抛:若把查询失败当成 count=0 并缓存"无分组"负结果,
// 一次瞬断会让后续 300s(TTL)内所有模型权限校验和 /v1/models 都把该租户当成无分组
groupCount, err := dao.MdlTenantGroups.Ctx(ctx).
Where("tenant_id", tenantID).
Count()
if err != nil {
return false, nil, err
}
if groupCount == 0 {
lcommon.TenantGroupModelCache.Set(ctx, cacheKey, nil)
@@ -226,19 +230,19 @@ func queryTenantGroupModels(ctx context.Context, tenantID int64) (map[string]boo
return result, nil
}
// GetTenantGroupModelIDs 获取租户通过分组可访问的模型内部ID集合(用于 GetAvailableModels)
func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) map[int64]bool {
// GetTenantGroupModelIDs 获取租户通过分组可访问的模型内部ID集合(用于 GetAvailableModels)。
// DB 查询错误原样上抛:调用方必须区分"无分组"(nil, nil)与"查询失败"(err),
// 吞错会让数据库瞬断被当成无分配,/v1/models 静默返回空列表。
func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) (map[int64]bool, error) {
cacheKey := fmt.Sprintf("%d", tenantID)
// 尝试从缓存获取 model_id string 集合
// 缓存 nil 表示该租户无任何分组(checkGroupModelAccess 写入的负结果),直接短路
cachedSet, found := lcommon.TenantGroupModelCache.Get(ctx, cacheKey)
if found {
if cachedSet == nil {
return nil
}
// 缓存中存的是 map[string]bool,需要转换为 map[int64]bool
// 这里直接查数据库获取 int64 版本
if found && cachedSet == nil {
return nil, nil
}
// 正结果缓存存的是模型名集合(map[string]bool),与本函数的内部 ID 集合类型不同,
// 无法直接复用,仍需查库
// 查询模型内部 ID
var models []struct {
@@ -253,15 +257,18 @@ func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) map[int64]bool
Where("m.status", "active").
Fields("m.id").
Scan(&models)
if err != nil || len(models) == 0 {
return nil
if err != nil {
return nil, err
}
if len(models) == 0 {
return nil, nil
}
result := make(map[int64]bool, len(models))
for _, m := range models {
result[m.Id] = true
}
return result
return result, nil
}
// GetModelMapping 实现 DataProvider.GetModelMapping
@@ -719,18 +726,24 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api
}
var models []modelRow
var err error
fieldsNoAlias := "model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities"
if tenantID > 0 {
// 获取显式分配的模型
tenantModelCount, _ := dao.MdlTenantModels.Ctx(ctx).
// 获取显式分配的模型。DB 错误必须上抛:此处吞错会把"查询失败"当成"无分配",
// 数据库瞬断时 /v1/models 会静默返回空列表(200)且无任何日志
tenantModelCount, err := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", tenantID).
Count()
if err != nil {
return nil, err
}
// 获取分组分配的模型
groupModelIDs := GetTenantGroupModelIDs(ctx, tenantID)
// 获取分组分配的模型(错误同样上抛,理由同上)
groupModelIDs, err := GetTenantGroupModelIDs(ctx, tenantID)
if err != nil {
return nil, err
}
hasExplicit := tenantModelCount > 0
hasGroup := len(groupModelIDs) > 0
@@ -749,7 +762,7 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api
if err := dao.MdlTenantModels.Ctx(ctx).
Where("tenant_id", tenantID).Where("enabled", true).
Fields("model_id").Scan(&explicitIDs); err != nil {
g.Log().Warningf(ctx, "[DataProvider] GetAvailableModels scan tenant models failed: tenantID=%d, err=%v", tenantID, err)
return nil, err
}
for _, id := range explicitIDs {
allModelIDs = append(allModelIDs, id.ModelID)
@@ -761,27 +774,27 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api
}
if len(allModelIDs) > 0 {
err = dao.MdlModels.Ctx(ctx).
if err := dao.MdlModels.Ctx(ctx).
WhereIn("id", allModelIDs).
Where("status", "active").
Fields(fieldsNoAlias).
OrderAsc("category").
OrderAsc("model_id").
Scan(&models)
Scan(&models); err != nil {
return nil, err
}
}
}
} else {
// tenantID == 0:返回所有活跃模型(公开端点场景)
err = dao.MdlModels.Ctx(ctx).
if err := dao.MdlModels.Ctx(ctx).
Where("status", "active").
Fields(fieldsNoAlias).
OrderAsc("category").
OrderAsc("model_id").
Scan(&models)
}
if err != nil {
return nil, err
Scan(&models); err != nil {
return nil, err
}
}
// 按 API Key 的模型范围过滤
@@ -789,10 +802,12 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api
var keyScopes []struct {
ModelName string `json:"model_name"`
}
_ = dao.ApiKeyModelScopes.Ctx(ctx).
if err := dao.ApiKeyModelScopes.Ctx(ctx).
Where("api_key_id", apiKeyID).
Fields("model_name").
Scan(&keyScopes)
Scan(&keyScopes); err != nil {
return nil, err
}
if len(keyScopes) > 0 {
allowed := make(map[string]bool, len(keyScopes))
@@ -1286,10 +1301,18 @@ func ClearTenantModelAccessCache(ctx context.Context, tenantID int64) {
}
// memberModelCache 成员模型范围缓存(TTL 60s)。
// 这是该缓存唯一的生产者(Get/Set),tenant 与 open 业务包通过
// InvalidateMemberModelScopeCache 显式失效本缓存(写操作后调用),
// 共享 "member_model" 前缀是故意的跨包失效机制,并非命名冲突——
// 详见 InvalidateMemberModelScopeCache。
// tenant 与 open 业务包通过 InvalidateMemberModelScopeCache 显式失效本缓存(写操作后调用),
// 共享 "member_model" 前缀是故意的跨包失效机制,并非命名冲突——详见 InvalidateMemberModelScopeCache。
//
// 本包内有且仅有两个写者:GetMemberAllowedModelNames 与 CheckMemberModelAccess,二者共用
// 同一 key({tenantID}:{userID}),必须遵循统一编码,修改任一侧前务必同步另一侧:
// - [] = 无范围记录(不限制)
// - [__none__] = 全部禁止(tnt_member_model_scopes.model_id = -1 哨兵)
//
// 历史上两写者编码不一致(列表路径曾用 [] 表示全禁),导致 chat 请求后 60s 内
// /v1/models 间歇性返回空列表、全禁成员反而被放行,2026-09 已统一。
const memberScopeNoneSentinel = "__none__"
var memberModelCache = lcommon.NewCache("member_model", 60*time.Second)
// InvalidateMemberModelScopeCache 失效指定成员的模型范围缓存。
@@ -1310,7 +1333,8 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]
var cachedNames []string
if memberModelCache.GetJSON(ctx, cacheKey, &cachedNames) {
return cachedNames, nil
// 缓存可能由 CheckMemberModelAccess 写入,必须走统一编码解码(见 memberScopeNoneSentinel 注释)
return decodeMemberScopeNames(cachedNames), nil
}
type scopeRow struct {
@@ -1335,7 +1359,7 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]
names := make([]string, 0, len(rows))
for _, r := range rows {
if r.ModelID == -1 {
memberModelCache.Set(ctx, cacheKey, []string{})
memberModelCache.Set(ctx, cacheKey, []string{memberScopeNoneSentinel})
return []string{}, nil
}
if r.ModelName != "" {
@@ -1343,10 +1367,32 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]
}
}
// scope 记录存在但关联模型已全部从目录删除:fail-closed 按全禁处理。
// 注意不能写空切片——统一编码里 [] 表示"无记录不限"
if len(names) == 0 {
memberModelCache.Set(ctx, cacheKey, []string{memberScopeNoneSentinel})
return []string{}, nil
}
memberModelCache.Set(ctx, cacheKey, names)
return names, nil
}
// decodeMemberScopeNames 解码 memberModelCache 中的成员模型范围(与 CheckMemberModelAccess
// 编码对齐,见 memberScopeNoneSentinel 注释):
// 含 __none__ 哨兵 → 全部禁止(空非 nil 切片);[] = 无范围记录(nil,不限制);其余原样返回
func decodeMemberScopeNames(cached []string) []string {
for _, n := range cached {
if n == memberScopeNoneSentinel {
return []string{}
}
}
if len(cached) == 0 {
return nil
}
return cached
}
// CheckMemberModelAccess 检查成员是否有权使用指定模型。
// 无 scope 记录表示不限制(向后兼容)。
// 哨兵 model_id=-1 表示该成员被限制使用所有模型。
@@ -1360,7 +1406,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
}
// 哨兵值:表示该成员被限制使用所有模型
for _, name := range cachedModelNames {
if name == "__none__" {
if name == memberScopeNoneSentinel {
return false, nil
}
}
@@ -1390,7 +1436,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
names := make([]string, 0, len(rows))
for _, r := range rows {
if r.ModelID == -1 {
names = append(names, "__none__")
names = append(names, memberScopeNoneSentinel)
} else if r.ModelName != "" {
names = append(names, r.ModelName)
}
@@ -1403,7 +1449,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
}
// 哨兵值:表示该成员被限制使用所有模型
for _, name := range names {
if name == "__none__" {
if name == memberScopeNoneSentinel {
return false, nil
}
}
+36
View File
@@ -67,6 +67,42 @@ func TestSafeUTF8Truncate(t *testing.T) {
}
}
// TestDecodeMemberScopeNames 验证成员模型范围缓存的统一编码解码
// (__none__ 哨兵 = 全禁;空数组 = 无范围记录不限制;白名单原样返回)。
// 该编码被 GetMemberAllowedModelNames / CheckMemberModelAccess 两个写者共用,
// 解码错误会直接导致 /v1/models 间歇性返回空列表,回归必测。
func TestDecodeMemberScopeNames(t *testing.T) {
tests := []struct {
name string
cached []string
want []string
wantN bool // 期望返回 nil(不限制)
}{
{"全禁哨兵解码为空非nil切片", []string{memberScopeNoneSentinel}, []string{}, false},
{"脏数据哨兵混入白名单仍全禁", []string{"gpt-4o", memberScopeNoneSentinel}, []string{}, false},
{"空数组表示无记录不限制", []string{}, nil, true},
{"nil表示无记录不限制", nil, nil, true},
{"白名单原样返回", []string{"gpt-4o", "claude-3-5-sonnet"}, []string{"gpt-4o", "claude-3-5-sonnet"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := decodeMemberScopeNames(tt.cached)
if tt.wantN {
if got != nil {
t.Fatalf("期望 nil(不限制),实际 %v", got)
}
return
}
if got == nil {
t.Fatalf("期望非 nil %v,实际 nil", tt.want)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("期望 %v,实际 %v", tt.want, got)
}
})
}
}
func TestTruncateBody_ShortReturnedVerbatim(t *testing.T) {
s := "short body"
if got := truncateBody(s, 100); got != s {