feat(admin-v2): add ProTable search, scenarios/quick_commands form, tests, remove quota_reset_interval

- Enable ProTable search on Accounts (username/email), Models (model_id/alias),
  Providers (display_name/name) with hideInSearch for non-searchable columns
- Add scenarios (Select tags) and quick_commands (Form.List) to AgentTemplates
  create form, plus service type updates
- Remove unused quota_reset_interval from ProviderKey model, key_pool SQL,
  handlers, and frontend types; add migration + bump schema to v11
- Add Vitest config, test setup, request interceptor tests (7 cases),
  authStore tests (8 cases) — all 15 passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-31 11:13:16 +08:00
parent f79560a911
commit ee51d5abcd
20 changed files with 1528 additions and 31 deletions

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
@@ -23,6 +25,9 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -31,8 +36,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.1",
"msw": "^2.12.14",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^4.1.2"
}
}

1049
admin-v2/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,30 +69,34 @@ export default function Accounts() {
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '显示名', dataIndex: 'display_name', width: 120 },
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
{ title: '邮箱', dataIndex: 'email', width: 180 },
{
title: '角色',
dataIndex: 'role',
width: 120,
hideInSearch: true,
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
hideInSearch: true,
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
},
{
title: '2FA',
dataIndex: 'totp_enabled',
width: 80,
hideInSearch: true,
render: (_, record) => record.totp_enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: 'LLM 路由',
dataIndex: 'llm_routing',
width: 120,
hideInSearch: true,
valueType: 'select',
valueEnum: {
relay: { text: 'SaaS 中转', status: 'Success' },
@@ -103,11 +107,13 @@ export default function Accounts() {
title: '最后登录',
dataIndex: 'last_login_at',
width: 180,
hideInSearch: true,
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 200,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
@@ -141,7 +147,7 @@ export default function Accounts() {
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
search={{}}
toolBarRender={() => []}
pagination={{
total: data?.total ?? 0,

View File

@@ -4,7 +4,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions, MinusCircleOutlined } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
@@ -176,6 +176,30 @@ export default function AgentTemplates() {
<Form.Item name="source_id" label="模板标识">
<Input placeholder="如 medical-assistant-v1" />
</Form.Item>
<Form.Item name="scenarios" label="使用场景">
<Select mode="tags" placeholder="输入场景标签后按回车" />
</Form.Item>
<Form.List name="quick_commands">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item {...restField} name={[name, 'label']} rules={[{ required: true, message: '请输入标签' }]}>
<Input placeholder="标签" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'command']} rules={[{ required: true, message: '请输入命令' }]}>
<Input placeholder="命令/提示词" style={{ width: 280 }} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form>
</Modal>

View File

@@ -66,34 +66,39 @@ export default function Models() {
title: '服务商',
dataIndex: 'provider_id',
width: 140,
hideInSearch: true,
render: (_, r) => {
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
return provider?.display_name || r.provider_id.substring(0, 8)
},
},
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, hideInSearch: true, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, hideInSearch: true, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{
title: '流式',
dataIndex: 'supports_streaming',
width: 70,
hideInSearch: true,
render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '视觉',
dataIndex: 'supports_vision',
width: 70,
hideInSearch: true,
render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 70,
hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
@@ -123,7 +128,7 @@ export default function Models() {
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>

View File

@@ -100,17 +100,19 @@ export default function Providers() {
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 140 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
{ title: '协议', dataIndex: 'api_protocol', width: 100, hideInSearch: true },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 260,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
@@ -185,7 +187,7 @@ export default function Providers() {
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>

View File

@@ -1,14 +1,61 @@
// ============================================================
// 路由守卫 — 未登录重定向到 /login
// ZCLAW Admin V2 — Auth Guard with session restore
// ============================================================
//
// Auth strategy:
// 1. If Zustand has token (normal flow after login) → authenticated
// 2. If no token but account in localStorage → call GET /auth/me
// to validate HttpOnly cookie and restore session
// 3. If cookie invalid → clean up and redirect to /login
import { useEffect, useRef, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Spin } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { authService } from '@/services/auth'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
const account = useAuthStore((s) => s.account)
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
const location = useLocation()
// Track restore attempt to avoid double-calling
const restoreAttempted = useRef(false)
const [restoring, setRestoring] = useState(false)
useEffect(() => {
if (restoreAttempted.current) return
restoreAttempted.current = true
// If no in-memory token but account exists in localStorage,
// try to validate the HttpOnly cookie via /auth/me
if (!token && account) {
setRestoring(true)
authService.me()
.then((meAccount) => {
// Cookie is valid — restore session
// Use sentinel token since real auth is via HttpOnly cookie
login('cookie-session', '', meAccount)
setRestoring(false)
})
.catch(() => {
// Cookie expired or invalid — clean up stale data
logout()
setRestoring(false)
})
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
if (restoring) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
)
}
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />
}

View File

@@ -18,6 +18,8 @@ export const agentTemplateService = {
visibility?: string; emoji?: string; personality?: string
soul_content?: string; welcome_message?: string
communication_style?: string; source_id?: string
scenarios?: string[]
quick_commands?: Array<{ label: string; command: string }>
}, signal?: AbortSignal) =>
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),

View File

@@ -257,7 +257,6 @@ export interface ProviderKey {
priority: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
is_active: boolean
last_429_at?: string
cooldown_until?: string

View File

@@ -0,0 +1,179 @@
// ============================================================
// request.ts 拦截器测试
// ============================================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
// ── Hoisted: mock functions + store (accessible in vi.mock factory) ──
const { mockSetToken, mockSetRefreshToken, mockLogout, _store } = vi.hoisted(() => {
const mockSetToken = vi.fn()
const mockSetRefreshToken = vi.fn()
const mockLogout = vi.fn()
const _store = {
token: null as string | null,
refreshToken: null as string | null,
setToken: mockSetToken,
setRefreshToken: mockSetRefreshToken,
logout: mockLogout,
}
return { mockSetToken, mockSetRefreshToken, mockLogout, _store }
})
vi.mock('@/stores/authStore', () => ({
useAuthStore: {
getState: () => _store,
},
}))
import request, { ApiRequestError } from '@/services/request'
function setStoreState(overrides: Partial<typeof _store>) {
Object.assign(_store, overrides)
}
// ── MSW server ──────────────────────────────────────────────
const server = setupServer()
beforeEach(() => {
server.listen({ onUnhandledRequest: 'bypass' })
mockSetToken.mockClear()
mockSetRefreshToken.mockClear()
mockLogout.mockClear()
_store.token = null
_store.refreshToken = null
})
afterEach(() => {
server.close()
})
describe('request interceptor', () => {
it('attaches Authorization header when token exists', async () => {
let capturedAuth: string | null = null
server.use(
http.get('*/api/v1/test', ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({ ok: true })
}),
)
setStoreState({ token: 'test-jwt-token' })
await request.get('/test')
expect(capturedAuth).toBe('Bearer test-jwt-token')
})
it('does not attach Authorization header when no token', async () => {
let capturedAuth: string | null = null
server.use(
http.get('*/api/v1/test', ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({ ok: true })
}),
)
setStoreState({ token: null })
await request.get('/test')
expect(capturedAuth).toBeNull()
})
it('wraps non-401 errors as ApiRequestError', async () => {
server.use(
http.get('*/api/v1/test', () => {
return HttpResponse.json(
{ error: 'not_found', message: 'Resource not found' },
{ status: 404 },
)
}),
)
try {
await request.get('/test')
expect.fail('Should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiRequestError)
expect((err as ApiRequestError).status).toBe(404)
expect((err as ApiRequestError).body.message).toBe('Resource not found')
}
})
it('wraps network errors as ApiRequestError with status 0', async () => {
server.use(
http.get('*/api/v1/test', () => {
return HttpResponse.error()
}),
)
try {
await request.get('/test')
expect.fail('Should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(ApiRequestError)
expect((err as ApiRequestError).status).toBe(0)
}
})
it('handles 401 with refresh token success', async () => {
let callCount = 0
server.use(
http.get('*/api/v1/protected', () => {
callCount++
if (callCount === 1) {
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
}
return HttpResponse.json({ data: 'success' })
}),
http.post('*/api/v1/auth/refresh', () => {
return HttpResponse.json({ token: 'new-jwt', refresh_token: 'new-refresh' })
}),
)
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
const res = await request.get('/protected')
expect(res.data).toEqual({ data: 'success' })
expect(mockSetToken).toHaveBeenCalledWith('new-jwt')
expect(mockSetRefreshToken).toHaveBeenCalledWith('new-refresh')
})
it('handles 401 with no refresh token — calls logout immediately', async () => {
server.use(
http.get('*/api/v1/norefresh', () => {
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
}),
)
setStoreState({ token: 'old-jwt', refreshToken: null })
try {
await request.get('/norefresh')
expect.fail('Should have thrown')
} catch {
expect(mockLogout).toHaveBeenCalled()
}
})
it('handles 401 with refresh failure — calls logout', async () => {
server.use(
http.get('*/api/v1/refreshfail', () => {
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
}),
http.post('*/api/v1/auth/refresh', () => {
return HttpResponse.json({ error: 'invalid' }, { status: 401 })
}),
)
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
try {
await request.get('/refreshfail')
expect.fail('Should have thrown')
} catch {
expect(mockLogout).toHaveBeenCalled()
}
})
})

43
admin-v2/tests/setup.ts Normal file
View File

@@ -0,0 +1,43 @@
// ============================================================
// Test setup: globals, jsdom polyfills, localStorage mock
// ============================================================
import { beforeAll, beforeEach, vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// ── localStorage mock (jsdom provides one but we ensure clean state) ──────
beforeEach(() => {
localStorage.clear()
})
// ── Ant Design / rc-util requires matchMedia ──────────────────────────────
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Ant Design's scrollTo polyfill
window.scrollTo = vi.fn()
// React 19 + jsdom: ensure getComputedStyle returns something useful
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
try {
return originalGetComputedStyle(elt, pseudoElt)
} catch {
return {} as CSSStyleDeclaration
}
}
})

View File

@@ -0,0 +1,115 @@
// ============================================================
// authStore 测试
// ============================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
import type { AccountPublic } from '@/types'
// Mock fetch for logout
const mockFetch = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', mockFetch)
const mockAccount: AccountPublic = {
id: 'test-id',
username: 'testuser',
display_name: 'Test User',
email: 'test@example.com',
role: 'admin',
status: 'active',
totp_enabled: false,
llm_routing: 'relay',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
const superAdminAccount: AccountPublic = {
...mockAccount,
id: 'super-id',
username: 'superadmin',
role: 'super_admin',
}
describe('authStore', () => {
beforeEach(() => {
localStorage.clear()
mockFetch.mockClear()
// Reset store state
useAuthStore.setState({
token: null,
refreshToken: null,
account: null,
permissions: [],
})
})
it('login sets token, refreshToken, account and permissions', () => {
const store = useAuthStore.getState()
store.login('jwt-token', 'refresh-token', mockAccount)
const state = useAuthStore.getState()
expect(state.token).toBe('jwt-token')
expect(state.refreshToken).toBe('refresh-token')
expect(state.account).toEqual(mockAccount)
expect(state.permissions).toContain('provider:manage')
})
it('super_admin gets admin:full + all permissions', () => {
const store = useAuthStore.getState()
store.login('jwt', 'refresh', superAdminAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('admin:full')
expect(state.permissions).toContain('account:admin')
expect(state.permissions).toContain('prompt:admin')
})
it('user role gets only basic permissions', () => {
const userAccount: AccountPublic = { ...mockAccount, role: 'user' }
const store = useAuthStore.getState()
store.login('jwt', 'refresh', userAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('model:read')
expect(state.permissions).toContain('relay:use')
expect(state.permissions).not.toContain('provider:manage')
})
it('logout clears all state', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.account).toBeNull()
expect(state.permissions).toEqual([])
expect(localStorage.getItem('zclaw_admin_account')).toBeNull()
})
it('hasPermission returns true for matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
expect(useAuthStore.getState().hasPermission('provider:manage')).toBe(true)
expect(useAuthStore.getState().hasPermission('config:write')).toBe(true)
})
it('hasPermission returns false for non-matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
expect(useAuthStore.getState().hasPermission('admin:full')).toBe(false)
})
it('admin:full grants all permissions via wildcard', () => {
useAuthStore.getState().login('jwt', 'refresh', superAdminAccount)
expect(useAuthStore.getState().hasPermission('anything:here')).toBe(true)
expect(useAuthStore.getState().hasPermission('made:up')).toBe(true)
})
it('persists account to localStorage on login', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
const stored = localStorage.getItem('zclaw_admin_account')
expect(stored).not.toBeNull()
expect(JSON.parse(stored!).username).toBe('testuser')
})
})

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "vitest/globals"],
"skipLibCheck": true,
/* Bundler mode */

18
admin-v2/vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

View File

@@ -0,0 +1,6 @@
-- 20260331000001_accounts_llm_routing.sql
-- 账号级 LLM 路由模式: relay=SaaS中转(Token池), local=本地直连
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS llm_routing TEXT NOT NULL DEFAULT 'local'
CHECK (llm_routing IN ('relay', 'local'));
COMMENT ON COLUMN accounts.llm_routing IS 'LLM路由模式: relay=SaaS中转, local=本地直连';

View File

@@ -0,0 +1,3 @@
-- 20260401000002_remove_quota_reset_interval.sql
-- 移除未使用的 quota_reset_interval 字段 (RPM/TPM 限流已足够)
ALTER TABLE provider_keys DROP COLUMN IF EXISTS quota_reset_interval;

View File

@@ -4,7 +4,7 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use crate::error::SaasResult;
const SCHEMA_VERSION: i32 = 10;
const SCHEMA_VERSION: i32 = 11;
/// 初始化数据库
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {

View File

@@ -10,7 +10,6 @@ pub struct ProviderKeySelectRow {
pub priority: i32,
pub max_rpm: Option<i64>,
pub max_tpm: Option<i64>,
pub quota_reset_interval: Option<String>,
}
/// provider_keys 完整行 (用于列表查询)
@@ -22,7 +21,6 @@ pub struct ProviderKeyRow {
pub priority: i32,
pub max_rpm: Option<i64>,
pub max_tpm: Option<i64>,
pub quota_reset_interval: Option<String>,
pub is_active: bool,
pub last_429_at: Option<String>,
pub cooldown_until: Option<String>,

View File

@@ -375,7 +375,6 @@ pub struct AddKeyRequest {
pub priority: i32,
pub max_rpm: Option<i64>,
pub max_tpm: Option<i64>,
pub quota_reset_interval: Option<String>,
}
pub async fn add_provider_key(
@@ -406,7 +405,6 @@ pub async fn add_provider_key(
let key_id = super::key_pool::add_provider_key(
&state.db, &provider_id, &req.key_label, &encrypted_value,
req.priority, req.max_rpm, req.max_tpm,
req.quota_reset_interval.as_deref(),
).await?;
log_operation(&state.db, &ctx.account_id, "provider_key.add", "provider_key", &key_id,

View File

@@ -26,7 +26,6 @@ pub struct PoolKey {
pub priority: i32,
pub max_rpm: Option<i64>,
pub max_tpm: Option<i64>,
pub quota_reset_interval: Option<String>,
}
/// Key 选择结果
@@ -43,9 +42,9 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
let current_minute = chrono::Utc::now().format("%Y-%m-%dT%H:%M").to_string();
// 单次查询: 活跃 Key + 当前分钟的 RPM/TPM 使用量 (LEFT JOIN)
let rows: Vec<(String, String, i32, Option<i64>, Option<i64>, Option<String>, Option<i64>, Option<i64>)> =
let rows: Vec<(String, String, i32, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
sqlx::query_as(
"SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm, pk.quota_reset_interval,
"SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm,
uw.request_count, uw.token_count
FROM provider_keys pk
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id AND uw.window_minute = $1
@@ -54,7 +53,7 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
ORDER BY pk.priority ASC, pk.last_used_at ASC NULLS FIRST"
).bind(&current_minute).bind(provider_id).bind(&now).fetch_all(db).await?;
for (id, key_value, priority, max_rpm, max_tpm, quota_reset_interval, req_count, token_count) in &rows {
for (id, key_value, priority, max_rpm, max_tpm, req_count, token_count) in &rows {
// RPM 检查
if let Some(rpm_limit) = max_rpm {
if *rpm_limit > 0 {
@@ -86,7 +85,6 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
priority: *priority,
max_rpm: *max_rpm,
max_tpm: *max_tpm,
quota_reset_interval: quota_reset_interval.clone(),
},
key_id: id.clone(),
});
@@ -124,7 +122,6 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
priority: 0,
max_rpm: None,
max_tpm: None,
quota_reset_interval: None,
},
key_id: "provider-fallback".to_string(),
});
@@ -212,7 +209,7 @@ pub async fn list_provider_keys(
) -> SaasResult<Vec<serde_json::Value>> {
let rows: Vec<ProviderKeyRow> =
sqlx::query_as(
"SELECT id, provider_id, key_label, priority, max_rpm, max_tpm, quota_reset_interval, is_active,
"SELECT id, provider_id, key_label, priority, max_rpm, max_tpm, is_active,
last_429_at, cooldown_until, total_requests, total_tokens, created_at, updated_at
FROM provider_keys WHERE provider_id = $1 ORDER BY priority ASC"
).bind(provider_id).fetch_all(db).await?;
@@ -225,7 +222,6 @@ pub async fn list_provider_keys(
"priority": r.priority,
"max_rpm": r.max_rpm,
"max_tpm": r.max_tpm,
"quota_reset_interval": r.quota_reset_interval,
"is_active": r.is_active,
"last_429_at": r.last_429_at,
"cooldown_until": r.cooldown_until,
@@ -246,17 +242,16 @@ pub async fn add_provider_key(
priority: i32,
max_rpm: Option<i64>,
max_tpm: Option<i64>,
quota_reset_interval: Option<&str>,
) -> SaasResult<String> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO provider_keys (id, provider_id, key_label, key_value, priority, max_rpm, max_tpm, quota_reset_interval, is_active, total_requests, total_tokens, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, TRUE, 0, 0, $9, $9)"
"INSERT INTO provider_keys (id, provider_id, key_label, key_value, priority, max_rpm, max_tpm, is_active, total_requests, total_tokens, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE, 0, 0, $8, $8)"
)
.bind(&id).bind(provider_id).bind(key_label).bind(key_value)
.bind(priority).bind(max_rpm).bind(max_tpm).bind(quota_reset_interval).bind(&now)
.bind(priority).bind(max_rpm).bind(max_tpm).bind(&now)
.execute(db).await?;
tracing::info!("Added key '{}' to provider {}", key_label, provider_id);