diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 6d1759d..98ee6d4 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -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}) } diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 2aafbdc..99f0e9c 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -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, } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 9dfd0a6..c63d4d9 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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") diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index 1fb924a..276cfb1 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -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 { diff --git a/server/internal/proxy/handlers.go b/server/internal/proxy/handlers.go index 50267af..55cb2ab 100644 --- a/server/internal/proxy/handlers.go +++ b/server/internal/proxy/handlers.go @@ -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 { diff --git a/server/internal/store/models.go b/server/internal/store/models.go index cf3fd9b..e1e28f4 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -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"` diff --git a/web/src/types.ts b/web/src/types.ts index 9a7782f..dbe29b4 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -5,6 +5,8 @@ export interface User { role: 'user' | 'admin' balance: number status: string + allowed_models?: string[] | null + denied_models?: string[] | null created_at: string } diff --git a/web/src/views/admin/ConfigView.vue b/web/src/views/admin/ConfigView.vue index 1fe9899..988c0fa 100644 --- a/web/src/views/admin/ConfigView.vue +++ b/web/src/views/admin/ConfigView.vue @@ -9,12 +9,31 @@ const config = reactive>({}) const loading = ref(false) const saving = ref(false) +const availableModels = ref([]) +const allowList = ref([]) +const denyList = ref([]) + +function parseConfigList(v: unknown): string[] { + if (!v) return [] + if (Array.isArray(v)) return v.map(String) + try { + const a = JSON.parse(String(v)) + return Array.isArray(a) ? a.map(String) : [] + } catch { + return [] + } +} + async function load() { loading.value = true try { const { data } = await http.get('/admin/config') Object.keys(config).forEach((k) => delete config[k]) Object.assign(config, data.data.config) + allowList.value = parseConfigList(config.model_allowlist) + denyList.value = parseConfigList(config.model_denylist) + delete config.model_allowlist + delete config.model_denylist } catch (e) { toast.err(errMsg(e)) } finally { @@ -22,10 +41,22 @@ async function load() { } } +async function loadModels() { + try { + const { data } = await http.get('/admin/models') + availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name) + } catch { + /* 忽略 */ + } +} + async function save() { saving.value = true try { - await http.put('/admin/config', config) + const payload: Record = { ...config } + payload.model_allowlist = allowList.value + payload.model_denylist = denyList.value + await http.put('/admin/config', payload) toast.ok('配置已保存') } catch (e) { toast.err(errMsg(e)) @@ -34,14 +65,59 @@ async function save() { } } -onMounted(load) +onMounted(() => { + load() + loadModels() +}) + +
+ +
diff --git a/web/src/views/admin/UsersView.vue b/web/src/views/admin/UsersView.vue index 59b3b82..c90ebe8 100644 --- a/web/src/views/admin/UsersView.vue +++ b/web/src/views/admin/UsersView.vue @@ -21,7 +21,20 @@ const pageSize = 15 const editOpen = ref(false) const editing = ref(null) -const editForm = reactive({ username: '', email: '', password: '', role: 'user', status: 'active' }) +const editForm = reactive({ + username: '', + email: '', + password: '', + role: 'user', + status: 'active', + allowed_models: '', + denied_models: '', +}) + +function parseModelList(s: string): string[] | undefined { + const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean) + return arr.length ? arr : undefined +} const balanceOpen = ref(false) const balanceUser = ref(null) @@ -50,6 +63,8 @@ function openEdit(u: User) { password: '', role: u.role, status: u.status, + allowed_models: (u.allowed_models || []).join(', '), + denied_models: (u.denied_models || []).join(', '), }) editOpen.value = true } @@ -57,7 +72,18 @@ function openEdit(u: User) { async function saveEdit() { if (!editing.value) return try { - await http.patch(`/admin/users/${editing.value.id}`, editForm) + const payload: Record = { + username: editForm.username, + email: editForm.email, + role: editForm.role, + status: editForm.status, + } + if (editForm.password) payload.password = editForm.password + const allow = parseModelList(editForm.allowed_models) + const deny = parseModelList(editForm.denied_models) + payload.allowed_models = allow ?? [] + payload.denied_models = deny ?? [] + await http.patch(`/admin/users/${editing.value.id}`, payload) toast.ok('已更新') editOpen.value = false await load() @@ -187,6 +213,18 @@ onMounted(load) autocomplete="new-password" hint="留空则不修改" /> + +