模型限制: 系统配置全局开放/禁止 + 用户级限制 + 网关拦截
- User 新增 allowed_models/denied_models(用户级模型限制) - 系统配置 model_allowlist/model_denylist 全局策略, 保存即时失效网关缓存 - 网关 checkModelAllowed: 用户级 > 全局(禁止命中→403, 白名单非空→仅白名单) - 三个协议处理器均校验, 错误按客户端协议格式返回 - 配置页"模型限制"卡片(全局允许/禁止多选); 用户编辑支持允许/禁止模型 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,31 @@ const config = reactive<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const availableModels = ref<string[]>([])
|
||||
const allowList = ref<string[]>([])
|
||||
const denyList = ref<string[]>([])
|
||||
|
||||
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<string, unknown> = { ...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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">系统配置</h1>
|
||||
<p class="text-sm text-muted">注册策略等平台级配置</p>
|
||||
<p class="text-sm text-muted">注册策略与模型访问限制</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-sm font-semibold">模型限制</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
针对全部用户开放/禁止模型;用户级限制优先级更高(用户管理里可单独配置)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-muted">允许的模型(留空 = 全部开放)</p>
|
||||
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
|
||||
<label
|
||||
v-for="m in availableModels"
|
||||
:key="m"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
|
||||
:class="allowList.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
|
||||
>
|
||||
<input v-model="allowList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-accent)]" />
|
||||
{{ m }}
|
||||
</label>
|
||||
<p v-if="availableModels.length === 0" class="text-xs text-muted">暂无模型,请先在模型定价中添加</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium text-muted">禁止的模型(黑名单优先)</p>
|
||||
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
|
||||
<label
|
||||
v-for="m in availableModels"
|
||||
:key="m"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
|
||||
:class="denyList.includes(m) ? 'border-err bg-err-soft text-err' : 'border-edge2 text-muted hover:border-edge'"
|
||||
>
|
||||
<input v-model="denyList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-err)]" />
|
||||
{{ m }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-5 p-6">
|
||||
@@ -77,20 +153,12 @@ onMounted(load)
|
||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-md border border-edge bg-surface px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-ink">其他配置项</p>
|
||||
<p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted">M3+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :loading="saving" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :loading="saving" @click="save">保存配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,20 @@ const pageSize = 15
|
||||
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<User | null>(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<User | null>(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<string, unknown> = {
|
||||
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="留空则不修改"
|
||||
/>
|
||||
<Input
|
||||
v-model="editForm.allowed_models"
|
||||
label="允许的模型"
|
||||
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
|
||||
hint="留空不限制;用户级白名单优先于全局"
|
||||
/>
|
||||
<Input
|
||||
v-model="editForm.denied_models"
|
||||
label="禁止的模型"
|
||||
placeholder="逗号分隔"
|
||||
hint="黑名单优先"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
|
||||
|
||||
Reference in New Issue
Block a user