feat: 实现全局模型白名单功能 (#301)

* feat: 实现全局模型白名单功能

## 功能概述
实现全局模型白名单,允许管理员配置只启用特定模型的路由,实现精细化的模型访问控制。

## 主要变更

### 后端实现
- config.ts: 新增 globalAllowedModels 配置项
- settings.ts:
  - 新增 RuntimeSettingsBody 类型支持
  - 实现数据库加载和持久化
  - 配置变更自动触发路由重建
- modelService.ts:
  - 在路由重建中添加白名单过滤逻辑
  - 大小写不敏感匹配,自动trim空格
  - 空白名单时允许所有模型(向后兼容)
- stats.ts:
  - 候选API过滤 models/modelsWithoutToken/modelsMissingTokenGroups

### 前端实现
- Settings.tsx:
  - 新增"全局模型白名单"配置卡片
  - 支持手动输入和点击选择两种添加方式
  - 绿色徽章显示已选模型,支持删除
  - 保存后自动触发路由重建

## 技术特性
- ✅ 向后兼容(默认为空数组,允许所有模型)
- ✅ 大小写不敏感匹配
- ✅ 自动trim和去重
- ✅ 输入验证(类型检查、空值过滤)
- ✅ 配置变更自动重建路由

## 测试验证
- 白名单为空时所有模型可见
- 白名单有值时只显示白名单模型
- 路由重建正确过滤模型
- 候选API正确返回过滤结果
- 前端UI交互正常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add getModelTokenCandidates mock to Settings tests

* fix: add getModelTokenCandidates mock to proxy-transport test

---------

Co-authored-by: Ding <ding@local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Delicious233
2026-03-30 17:19:02 +08:00
committed by GitHub
parent 01481be511
commit fa25a501a8
11 changed files with 720 additions and 3 deletions
+13
View File
@@ -956,10 +956,23 @@ export async function rebuildTokenRoutesFromAvailability() {
// Load global brand filter
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
// Load global allowed models whitelist
const globalAllowedModels = new Set(
config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean),
);
function isModelAllowedByWhitelist(modelName: string): boolean {
// If whitelist is empty, allow all models (backward compatible)
if (globalAllowedModels.size === 0) return true;
// Check if model is in whitelist (case-insensitive)
return globalAllowedModels.has(modelName.toLowerCase().trim());
}
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (!isModelAllowedByWhitelist(modelName)) return;
if (isModelDisabledForSite(siteId, modelName)) return;
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());