fix(model):成员只能看见自己能调用的模型

This commit is contained in:
qian committed 2026-05-29 07:50:09 +08:00
1 parent 019f2368f8
commit 7c281743ff
10 files changed
+217 -59

No files matched your search

+2 -2
View File
@@ -317,7 +317,7 @@ func HandleGeminiGenerateContent(r *ghttp.Request) {
func HandleModels(r *ghttp.Request) {
rc := buildRelayContext(r) // 获取 TenantID
resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, dataProvider)
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()},
@@ -371,7 +371,7 @@ func HandleModelDetail(r *ghttp.Request) {
func HandleGeminiModels(r *ghttp.Request) {
rc := buildRelayContext(r)
resp, err := handler.HandleGeminiModels(r.Context(), rc.TenantID, rc.ApiKeyID, dataProvider)
resp, err := handler.HandleGeminiModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider)
if err != nil {
handler.WriteGeminiRelayError(r.Response.Writer, err)
return
+11
View File
@@ -57,6 +57,10 @@ func HandleTaskSubmit(r *ghttp.Request) {
statusCode = 403
errType = "permission_denied"
errMsg = "当前租户未启用该模型,请联系管理员"
} else if err == relay_common.ErrMemberModelNotAllowed {
statusCode = 403
errType = "permission_denied"
errMsg = "当前成员无权使用该模型"
} else if err == relay_common.ErrChannelUnavailable {
statusCode = 503
errType = "server_error"
@@ -172,6 +176,13 @@ func selectTaskChannel(r *ghttp.Request, body []byte) (*relay_common.ChannelMeta
return nil, err
}
// 检查成员模型范围
if allowed, err := relayDataProvider.CheckMemberModelAccess(ctx, tenantID, userID, req.Model); err != nil {
return nil, err
} else if !allowed {
return nil, relay_common.ErrMemberModelNotAllowed
}
return &relay_common.ChannelMeta{
ChannelID: selection.ChannelID,
ChannelType: selection.ChannelType,
+24 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
@@ -20,6 +21,9 @@ import (
type sOpen struct{}
// openMemberModelCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同)
var openMemberModelCache = common.NewCache("member_model", 60*time.Second)
func New() *sOpen {
return &sOpen{}
}
@@ -283,20 +287,36 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo
}
// Insert new scopes
for _, modelID := range req.ModelIDs {
if modelID == 0 {
continue
if len(req.ModelIDs) > 0 {
for _, modelID := range req.ModelIDs {
if modelID == 0 {
continue
}
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: modelID,
}).Insert()
if err != nil {
return nil, err
}
}
} else {
// 空列表表示禁止所有模型,插入哨兵记录(model_id = -1)
_, err = dao.TntMemberModelScopes.Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: modelID,
ModelId: -1,
}).Insert()
if err != nil {
return nil, err
}
}
// 清除该成员的模型范围缓存,使下次请求时重新从数据库读取
cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id)
openMemberModelCache.Delete(ctx, cacheKey)
return nil, nil
}
+100 -3
View File
@@ -716,7 +716,7 @@ func (p *DataProviderImpl) ResetConsecutiveFailure(ctx context.Context, channelI
// GetAvailableModels 实现 DataProvider.GetAvailableModels
// 如果 tenantID > 0,返回该租户有权使用的模型列表
// 如果 apiKeyID > 0,进一步按 API Key 的模型范围过滤
func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID int64, apiKeyID int64) ([]common.ModelInfo, error) {
func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, apiKeyID, userID int64) ([]common.ModelInfo, error) {
type modelRow struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
@@ -821,6 +821,31 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID int6
}
}
// 按成员模型范围过滤
if userID > 0 {
allowedNames, err := GetMemberAllowedModelNames(ctx, tenantID, userID)
if err != nil {
return nil, err
}
if allowedNames != nil {
if len(allowedNames) == 0 {
models = nil
} else {
allowed := make(map[string]bool, len(allowedNames))
for _, n := range allowedNames {
allowed[n] = true
}
filtered := make([]modelRow, 0, len(models))
for _, m := range models {
if allowed[m.ModelId] {
filtered = append(filtered, m)
}
}
models = filtered
}
}
}
result := make([]common.ModelInfo, 0, len(models))
for _, m := range models {
result = append(result, common.ModelInfo{
@@ -1100,8 +1125,57 @@ func (p *DataProviderImpl) InvalidateModelCache(modelName string) {
// memberModelCache 成员模型范围缓存(TTL 60s)
var memberModelCache = lcommon.NewCache("member_model", 60*time.Second)
// GetMemberAllowedModelNames 获取成员允许使用的 model_id 列表。
// 返回 nil 表示不限制(向后兼容),返回空切片表示禁止所有模型(哨兵),返回非空切片表示允许的模型集合。
func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]string, error) {
if userID <= 0 || tenantID <= 0 {
return nil, nil
}
cacheKey := fmt.Sprintf("%d:%d", tenantID, userID)
var cachedNames []string
if memberModelCache.GetJSON(ctx, cacheKey, &cachedNames) {
return cachedNames, nil
}
type scopeRow struct {
ModelID int64 `json:"model_id"`
ModelName string `json:"model_name"`
}
var rows []scopeRow
err := g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx).
LeftJoin("mdl_models m ON ms.model_id = m.id").
Where("ms.tenant_id", tenantID).
Where("ms.user_id", userID).
Fields("ms.model_id, m.model_id as model_name").
Scan(&rows)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
names := make([]string, 0, len(rows))
for _, r := range rows {
if r.ModelID == -1 {
memberModelCache.Set(ctx, cacheKey, []string{})
return []string{}, nil
}
if r.ModelName != "" {
names = append(names, r.ModelName)
}
}
memberModelCache.Set(ctx, cacheKey, names)
return names, nil
}
// CheckMemberModelAccess 检查成员是否有权使用指定模型。
// 无 scope 记录表示不限制(向后兼容)。
// 哨兵 model_id=-1 表示该成员被限制使用所有模型。
func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, userID int64, modelName string) (bool, error) {
cacheKey := fmt.Sprintf("%d:%d", tenantID, userID)
@@ -1110,6 +1184,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
if len(cachedModelNames) == 0 {
return true, nil
}
// 哨兵值:表示该成员被限制使用所有模型
for _, name := range cachedModelNames {
if name == "__none__" {
return false, nil
}
}
for _, name := range cachedModelNames {
if name == modelName {
return true, nil
@@ -1119,6 +1199,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
}
type scopeRow struct {
ModelID int64 `json:"model_id"`
ModelName string `json:"model_name"`
}
var rows []scopeRow
@@ -1126,7 +1207,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
LeftJoin("mdl_models m ON ms.model_id = m.id").
Where("ms.tenant_id", tenantID).
Where("ms.user_id", userID).
Fields("m.model_id as model_name").
Fields("ms.model_id, m.model_id as model_name").
Scan(&rows)
if err != nil {
return false, err
@@ -1134,7 +1215,11 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
names := make([]string, 0, len(rows))
for _, r := range rows {
names = append(names, r.ModelName)
if r.ModelID == -1 {
names = append(names, "__none__")
} else if r.ModelName != "" {
names = append(names, r.ModelName)
}
}
memberModelCache.Set(ctx, cacheKey, names)
@@ -1142,6 +1227,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
if len(names) == 0 {
return true, nil
}
// 哨兵值:表示该成员被限制使用所有模型
for _, name := range names {
if name == "__none__" {
return false, nil
}
}
for _, name := range names {
if name == modelName {
return true, nil
@@ -1150,6 +1241,12 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID,
return false, nil
}
// InvalidateMemberModelCache 实现 DataProvider.InvalidateMemberModelCache
func (p *DataProviderImpl) InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64) {
cacheKey := fmt.Sprintf("%d:%d", tenantID, userID)
memberModelCache.Delete(ctx, cacheKey)
}
// apiKeyModelCache API Key 模型范围缓存(TTL 60s)
var apiKeyModelCache = lcommon.NewCache("apikey_model", 60*time.Second)
+25 -45
View File
@@ -2,13 +2,16 @@ package tenant
import (
"context"
"github.com/qianfree/team-api/internal/dao"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
v1 "github.com/qianfree/team-api/api/tenant/v1"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/middleware"
)
@@ -29,11 +32,16 @@ func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberMod
ids := make([]int64, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ModelID)
if r.ModelID > 0 {
ids = append(ids, r.ModelID)
}
}
return &v1.TenantMemberModelScopesRes{ModelIDs: ids}, nil
}
// memberModelScopeCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同)
var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second)
// MemberModelScopesSet sets the available models for a member (full replace).
func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) {
tenantID := middleware.GetTenantID(ctx)
@@ -48,8 +56,8 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember
return err
}
// Insert new scopes
if len(req.ModelIDs) > 0 {
// Insert new scopes
data := make([]do.TntMemberModelScopes, len(req.ModelIDs))
for i, mID := range req.ModelIDs {
data[i] = do.TntMemberModelScopes{
@@ -62,6 +70,16 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember
if err != nil {
return err
}
} else {
// 空列表表示禁止所有模型,插入哨兵记录(model_id = -1)
_, err = tx.Model("tnt_member_model_scopes").Ctx(ctx).Data(do.TntMemberModelScopes{
TenantId: tenantID,
UserId: req.Id,
ModelId: -1,
}).Insert()
if err != nil {
return err
}
}
return nil
@@ -70,47 +88,9 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember
return nil, err
}
// 清除该成员的模型范围缓存,使下次请求时重新从数据库读取
cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id)
memberModelScopeCache.Delete(ctx, cacheKey)
return &v1.TenantMemberModelScopesSetRes{}, nil
}
// revokeMemberModels removes specified models from a member and cascades to their API keys.
func revokeMemberModels(ctx context.Context, tenantID, userID int64, modelIDs []int64) error {
if len(modelIDs) == 0 {
return nil
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Remove from member model scopes
_, err := tx.Model("tnt_member_model_scopes").Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
WhereIn("model_id", modelIDs).
Delete()
if err != nil {
return err
}
// Cascade: remove model bindings from all keys owned by this user
keyIDs, err := tx.Model("api_keys").Ctx(ctx).
Where("tenant_id", tenantID).
Where("user_id", userID).
Where("status", "active").
Fields("id").
Array()
if err != nil {
return err
}
if len(keyIDs) > 0 {
_, err = tx.Model("api_key_model_scopes").Ctx(ctx).
WhereIn("api_key_id", keyIDs).
WhereIn("model_id", modelIDs).
Delete()
if err != nil {
return err
}
}
return nil
})
}
+35
View File
@@ -258,6 +258,41 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
list = append(list, item)
}
// 按成员模型范围过滤
userID := middleware.GetUserID(ctx)
if userID > 0 {
var memberScopes []struct {
ModelID int64 `json:"model_id"`
ModelName string `json:"model_name"`
}
_ = g.DB().Model("tnt_member_model_scopes ms").Ctx(ctx).
LeftJoin("mdl_models m ON ms.model_id = m.id").
Where("ms.tenant_id", tenantID).
Where("ms.user_id", userID).
Fields("ms.model_id, m.model_id as model_name").
Scan(&memberScopes)
if len(memberScopes) > 0 {
allowed := make(map[string]bool, len(memberScopes))
for _, s := range memberScopes {
if s.ModelID == -1 {
// 哨兵:禁止所有模型
return &v1.TenantAvailableModelsRes{List: nil}, nil
}
if s.ModelName != "" {
allowed[s.ModelName] = true
}
}
filtered := make([]v1.TenantAvailableModelItem, 0, len(list))
for _, item := range list {
if allowed[item.ModelId] {
filtered = append(filtered, item)
}
}
list = filtered
}
}
return &v1.TenantAvailableModelsRes{List: list}, nil
}
+8 -1
View File
@@ -18,6 +18,9 @@ var ErrStreamInterrupted = errors.New("stream interrupted by client disconnect")
// ErrTenantModelNotEnabled 租户未启用该模型
var ErrTenantModelNotEnabled = errors.New("model not enabled for this tenant")
// ErrMemberModelNotAllowed 成员无权使用该模型
var ErrMemberModelNotAllowed = errors.New("model not allowed for this member")
// DeprecationInfo 模型弃用信息
type DeprecationInfo struct {
Deprecated bool
@@ -63,7 +66,8 @@ type DataProvider interface {
// GetAvailableModels 获取指定租户可用的模型列表。
// apiKeyID > 0 时进一步按 API Key 的模型范围过滤。
GetAvailableModels(ctx context.Context, tenantID int64, apiKeyID int64) ([]ModelInfo, error)
// userID > 0 时进一步按成员的模型范围过滤。
GetAvailableModels(ctx context.Context, tenantID, apiKeyID, userID int64) ([]ModelInfo, error)
// GetModelDetail 获取单个模型的详细信息。
// tenantID 用于权限校验(检查租户是否有权使用该模型)。
@@ -87,6 +91,9 @@ type DataProvider interface {
// CheckApiKeyModelAccess 检查 API Key 是否有权使用指定模型。
// 无 scope 记录表示不限制(向后兼容)。
CheckApiKeyModelAccess(ctx context.Context, apiKeyID int64, modelName string) (bool, error)
// InvalidateMemberModelCache 清除指定成员的模型范围缓存。
InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64)
}
// ApiKeyInfo API Key 验证结果
+3 -2
View File
@@ -41,8 +41,9 @@ func HandleResponses(ctx context.Context, body []byte, path string, headers http
// HandleModels 处理 /v1/models 请求(列出可用模型)
// tenantID 用于过滤该租户有权使用的模型,apiKeyID 用于进一步按 Key 的模型范围过滤
func HandleModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.ModelsResponse, error) {
models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID)
// userID 用于进一步按成员的模型范围过滤
func HandleModels(ctx context.Context, tenantID, apiKeyID, userID int64, provider common.DataProvider) (*dto.ModelsResponse, error) {
models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID, userID)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -20,8 +20,8 @@ func HandleGeminiGenerateContent(ctx context.Context, body []byte, path string,
}
// HandleGeminiModels 处理 GET /v1beta/models 请求(Gemini 原生格式模型列表)
func HandleGeminiModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.GeminiModelsResponse, error) {
models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID)
func HandleGeminiModels(ctx context.Context, tenantID, apiKeyID, userID int64, provider common.DataProvider) (*dto.GeminiModelsResponse, error) {
models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID, userID)
if err != nil {
return nil, err
}
+7
View File
@@ -71,6 +71,13 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext,
return nil, nil, fmt.Errorf("model not found: %s", modelName)
}
// 3.1 检查成员模型范围
if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, modelName); err != nil {
return nil, nil, err
} else if !allowed {
return nil, nil, constant.NewAuthError("model not allowed for this member")
}
// 4. 渠道选择
selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, modelName, nil)
if err != nil {