模型限制: 系统配置全局开放/禁止 + 用户级限制 + 网关拦截

- User 新增 allowed_models/denied_models(用户级模型限制)
- 系统配置 model_allowlist/model_denylist 全局策略, 保存即时失效网关缓存
- 网关 checkModelAllowed: 用户级 > 全局(禁止命中→403, 白名单非空→仅白名单)
- 三个协议处理器均校验, 错误按客户端协议格式返回
- 配置页"模型限制"卡片(全局允许/禁止多选); 用户编辑支持允许/禁止模型

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 01:34:12 +08:00
co-authored by Claude
parent 866690b7ce
commit b365779188
9 changed files with 240 additions and 28 deletions
+20 -5
View File
@@ -48,11 +48,13 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
return
}
var req struct {
Username *string `json:"username"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"`
Status *string `json:"status"`
Username *string `json:"username"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"`
Status *string `json:"status"`
AllowedModels *[]string `json:"allowed_models"`
DeniedModels *[]string `json:"denied_models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
@@ -113,6 +115,15 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
}
updates["status"] = *req.Status
}
// 模型限制(jsonb):手动序列化
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
updates["allowed_models"] = string(raw)
}
if req.DeniedModels != nil {
raw, _ := json.Marshal(*req.DeniedModels)
updates["denied_models"] = string(raw)
}
if len(updates) == 0 {
resp.OK(c, gin.H{"ok": true})
return
@@ -211,5 +222,9 @@ func (h *Handler) AdminPutConfig(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to save config")
return
}
// 模型限制等策略可能变化,立即失效缓存
if h.gw != nil {
h.gw.ResetModelPolicy()
}
resp.OK(c, gin.H{"ok": true})
}
+7 -3
View File
@@ -8,18 +8,20 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/proxy"
"github.com/openteam/server/internal/store"
)
// Handler 聚合所有管理 API。
type Handler struct {
a *app.App
a *app.App
gw *proxy.Gateway
}
func NewHandler(a *app.App) *Handler { return &Handler{a: a} }
func NewHandler(a *app.App, gw *proxy.Gateway) *Handler { return &Handler{a: a, gw: gw} }
// ---------------------------------------------------------------------------
// 认证
@@ -219,6 +221,8 @@ func (h *Handler) publicUser(u *store.User) gin.H {
"role": u.Role,
"balance": u.Balance,
"status": u.Status,
"allowed_models": u.AllowedModels,
"denied_models": u.DeniedModels,
"created_at": u.CreatedAt,
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
h := NewHandler(a)
h := NewHandler(a, gw)
// --- 代理端点(对外)---
proxyGroup := r.Group("/v1")
+71
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -35,6 +36,76 @@ type Gateway struct {
lim *ratelimit.Limiter
userRPS int
hc *http.Client
policyMu sync.Mutex
policy modelPolicy
}
// modelPolicy 全局模型限制策略(来自系统配置,短时缓存)。
type modelPolicy struct {
allow []string
deny []string
at time.Time
}
const modelPolicyTTL = 5 * time.Second
// ResetModelPolicy 清空全局模型限制缓存(系统配置保存后调用)。
func (g *Gateway) ResetModelPolicy() {
g.policyMu.Lock()
g.policy = modelPolicy{}
g.policyMu.Unlock()
}
// globalModelRestrictions 读取全局模型允许/禁止列表(缓存 30s)。
func (g *Gateway) globalModelRestrictions() (allow, deny []string) {
g.policyMu.Lock()
defer g.policyMu.Unlock()
if time.Since(g.policy.at) < modelPolicyTTL {
return g.policy.allow, g.policy.deny
}
var raw string
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
g.policy = modelPolicy{allow: allow, deny: deny, at: time.Now()}
return
}
// checkModelAllowed 模型访问控制:用户级 > 全局。
// 1. 用户禁止列表命中 → 拒绝
// 2. 用户允许列表非空 → 仅列表内可访问(不再看全局)
// 3. 全局禁止命中 → 拒绝
// 4. 全局允许列表非空 → 仅列表内可访问
func (g *Gateway) checkModelAllowed(u *store.User, model string) bool {
if model == "" {
return true
}
if contains(u.DeniedModels, model) {
return false
}
if len(u.AllowedModels) > 0 {
return contains(u.AllowedModels, model)
}
allow, deny := g.globalModelRestrictions()
if contains(deny, model) {
return false
}
if len(allow) > 0 {
return contains(allow, model)
}
return true
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway {
+12
View File
@@ -23,6 +23,10 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
}
c.Set("protocol", convert.ProtoChat)
c.Set("model_name", br.Model)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
@@ -51,6 +55,10 @@ func (g *Gateway) responses(c *gin.Context) {
}
c.Set("protocol", convert.ProtoResponses)
c.Set("model_name", br.Model)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
@@ -79,6 +87,10 @@ func (g *Gateway) messages(c *gin.Context) {
}
c.Set("protocol", convert.ProtoMessages)
c.Set("model_name", br.Model)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model)
if len(cands) == 0 {
+2
View File
@@ -52,6 +52,8 @@ type User struct {
Role string `gorm:"size:16;not null;default:user" json:"role"`
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
InviteCode *string `json:"invite_code,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"`