diff --git a/controller/token.go b/controller/token.go index 836e9b2952ac..a7f4799090a6 100644 --- a/controller/token.go +++ b/controller/token.go @@ -49,10 +49,11 @@ func SearchTokens(c *gin.Context) { userId := c.GetInt("id") keyword := c.Query("keyword") token := c.Query("token") + status := c.Query("status") pageInfo := common.GetPageQuery(c) - tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + tokens, total, err := model.SearchUserTokens(userId, keyword, token, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return @@ -277,11 +278,11 @@ func UpdateToken(c *gin.Context) { return } if token.Status == common.TokenStatusEnabled { - if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { + if cleanToken.ExpiredTime != -1 && cleanToken.ExpiredTime <= common.GetTimestamp() { common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable) return } - if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota { + if !cleanToken.UnlimitedQuota && cleanToken.RemainQuota <= 0 { common.ApiErrorI18n(c, i18n.MsgTokenExhaustedCannotEable) return } diff --git a/model/token.go b/model/token.go index 5d62258e7920..d17c971e8371 100644 --- a/model/token.go +++ b/model/token.go @@ -3,6 +3,7 @@ package model import ( "errors" "fmt" + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -131,7 +132,7 @@ func validateLikePattern(input string) error { const searchHardLimit = 100 -func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) { +func SearchUserTokens(userId int, keyword string, token string, status string, offset int, limit int) (tokens []*Token, total int64, err error) { // model 层强制截断 if limit <= 0 || limit > searchHardLimit { limit = searchHardLimit @@ -176,6 +177,20 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi baseQuery = baseQuery.Where(commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern) } + if status != "" { + now := common.GetTimestamp() + switch status { + case strconv.Itoa(common.TokenStatusEnabled): + baseQuery = baseQuery.Where("status = ?", common.TokenStatusEnabled) + case strconv.Itoa(common.TokenStatusDisabled): + baseQuery = baseQuery.Where("status = ?", common.TokenStatusDisabled) + case strconv.Itoa(common.TokenStatusExpired): + baseQuery = baseQuery.Where("expired_time != -1 AND expired_time < ?", now) + case strconv.Itoa(common.TokenStatusExhausted): + baseQuery = baseQuery.Where("unlimited_quota = ? AND remain_quota <= ?", false, 0) + } + } + // 先查匹配总数(用于分页,受 maxTokens 上限保护,避免全表 COUNT) err = baseQuery.Limit(maxTokens).Count(&total).Error if err != nil { @@ -198,29 +213,13 @@ func ValidateUserToken(key string) (token *Token, err error) { } token, err = GetTokenByKey(key, false) if err == nil { - if token.Status == common.TokenStatusExhausted || - token.Status == common.TokenStatusExpired || - token.Status != common.TokenStatusEnabled { + if token.Status != common.TokenStatusEnabled { return token, ErrTokenInvalid } if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { - if !common.RedisEnabled { - token.Status = common.TokenStatusExpired - err := token.SelectUpdate() - if err != nil { - common.SysLog("failed to update token status" + err.Error()) - } - } return token, ErrTokenInvalid } if !token.UnlimitedQuota && token.RemainQuota <= 0 { - if !common.RedisEnabled { - token.Status = common.TokenStatusExhausted - err := token.SelectUpdate() - if err != nil { - common.SysLog("failed to update token status" + err.Error()) - } - } return token, ErrTokenInvalid } return token, nil @@ -306,21 +305,6 @@ func (token *Token) Update() (err error) { return err } -func (token *Token) SelectUpdate() (err error) { - defer func() { - if shouldUpdateRedis(true, err) { - gopool.Go(func() { - err := cacheSetToken(*token) - if err != nil { - common.SysLog("failed to update token cache: " + err.Error()) - } - }) - } - }() - // This can update zero values - return DB.Model(token).Select("accessed_time", "status").Updates(token).Error -} - func (token *Token) Delete() (err error) { defer func() { if shouldUpdateRedis(true, err) { diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts index df3cc5ff74bc..24006dd24157 100644 --- a/web/src/features/keys/api.ts +++ b/web/src/features/keys/api.ts @@ -44,10 +44,11 @@ export async function getApiKeys( export async function searchApiKeys( params: SearchApiKeysParams ): Promise { - const { keyword = '', token = '', p, size } = params + const { keyword = '', token = '', status = '', p, size } = params const queryParams = new URLSearchParams() if (keyword) queryParams.set('keyword', keyword) if (token) queryParams.set('token', token) + if (status) queryParams.set('status', status) if (p != null) queryParams.set('p', String(p)) if (size != null) queryParams.set('size', String(size)) const res = await api.get(`/api/token/search?${queryParams.toString()}`) @@ -95,7 +96,9 @@ export async function updateApiKeyStatus( id: number, status: number ): Promise> { - const res = await api.put('/api/token/?status_only=true', { id, status }) + const res = await api.put('/api/token/?status_only=true', { id, status }, { + skipBusinessError: true, + }) return res.data } diff --git a/web/src/features/keys/components/api-keys-columns.tsx b/web/src/features/keys/components/api-keys-columns.tsx index 645d051388c6..0c667e867abc 100644 --- a/web/src/features/keys/components/api-keys-columns.tsx +++ b/web/src/features/keys/components/api-keys-columns.tsx @@ -36,7 +36,8 @@ import dayjs from '@/lib/dayjs' import { formatQuota } from '@/lib/format' import { cn } from '@/lib/utils' -import { API_KEY_STATUSES } from '../constants' +import { API_KEY_STATUS, API_KEY_STATUSES } from '../constants' +import { isApiKeyExpired, isApiKeyExhausted } from '../lib' import type { ApiKey } from '../types' import { ApiKeyTimestampCell } from './api-key-timestamp-cell' import { @@ -116,7 +117,31 @@ export function useApiKeysColumns(now: number): ColumnDef[] { accessorKey: 'status', header: t('Status'), cell: ({ row }) => { - const statusConfig = API_KEY_STATUSES[row.getValue('status') as number] + const apiKey = row.original + const statusValue = row.getValue('status') as number + + if (isApiKeyExpired(apiKey.expired_time)) { + return ( + + ) + } + if (isApiKeyExhausted(apiKey.remain_quota, apiKey.unlimited_quota)) { + return ( + + ) + } + + const statusConfig = API_KEY_STATUSES[statusValue] if (!statusConfig) return null return ( [] { /> ) }, - filterFn: (row, id, value) => value.includes(String(row.getValue(id))), + filterFn: (row, id, value) => { + const apiKey = row.original + const statusValue = row.getValue(id) as number + + if ( + value.includes(String(API_KEY_STATUS.EXPIRED)) && + isApiKeyExpired(apiKey.expired_time) + ) { + return true + } + if ( + value.includes(String(API_KEY_STATUS.EXHAUSTED)) && + isApiKeyExhausted(apiKey.remain_quota, apiKey.unlimited_quota) + ) { + return true + } + + return value.includes(String(statusValue)) + }, size: 120, meta: { mobileBadge: true }, }, diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx index 3561df5254c9..c6c45560b178 100644 --- a/web/src/features/keys/components/api-keys-table.tsx +++ b/web/src/features/keys/components/api-keys-table.tsx @@ -20,7 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import type { Table as TanstackTable } from '@tanstack/react-table' import { Database } from 'lucide-react' -import { useEffect, useState } from 'react' +import { type ReactNode, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -31,7 +31,7 @@ import { useDebouncedColumnFilter, useDataTable, } from '@/components/data-table' -import { StatusBadge } from '@/components/status-badge' +import { type StatusBadgeProps, StatusBadge } from '@/components/status-badge' import { Empty, EmptyDescription, @@ -52,6 +52,7 @@ import { API_KEY_STATUSES, ERROR_MESSAGES, } from '../constants' +import { isApiKeyExpired, isApiKeyExhausted } from '../lib' import type { ApiKey } from '../types' import { ApiKeyCell, UnlimitedQuotaBadge } from './api-keys-cells' import { useApiKeysColumns } from './api-keys-columns' @@ -66,8 +67,38 @@ const API_KEYS_MOBILE_SKELETON_IDS = Array.from( (_, index) => `api-key-mobile-skeleton-${index + 1}` ) -function isDisabledApiKeyRow(apiKey: ApiKey) { - return apiKey.status !== API_KEY_STATUS.ENABLED +function isDisabledApiKeyRow(apiKey: ApiKey): boolean { + return ( + apiKey.status !== API_KEY_STATUS.ENABLED || + isApiKeyExpired(apiKey.expired_time) || + isApiKeyExhausted(apiKey.remain_quota, apiKey.unlimited_quota) + ) +} + +function renderApiKeyStatusBadge( + t: (key: string) => string, + expired: boolean, + exhausted: boolean, + statusConfig: (Pick & { label: string }) | null +): ReactNode { + if (expired) { + return ( + + ) + } + if (exhausted) { + return ( + + ) + } + if (!statusConfig) return null + return ( + + ) } function ApiKeysMobileSkeleton() { @@ -129,9 +160,17 @@ function ApiKeysMobileList({
{rows.map((row) => { const apiKey = row.original - const statusConfig = API_KEY_STATUSES[apiKey.status] + const statusValue = apiKey.status + const expired = isApiKeyExpired(apiKey.expired_time) + const exhausted = isApiKeyExhausted( + apiKey.remain_quota, + apiKey.unlimited_quota + ) + const statusConfig = expired || exhausted ? null : API_KEY_STATUSES[statusValue] const total = apiKey.used_quota + apiKey.remain_quota + const mobileBadge = renderApiKeyStatusBadge(t, expired, exhausted, statusConfig) + return (
- {statusConfig && ( - - )} + {mobileBadge}
@@ -228,7 +261,14 @@ export function ApiKeysTable() { columnId: '_tokenSearch', onColumnFiltersChange, }) - const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim()) + const statusFilter = + (columnFilters.find((filter) => filter.id === 'status')?.value as + | string[] + | undefined) ?? [] + const statusFilterValue = statusFilter[0] ?? '' + const shouldSearch = Boolean( + globalFilter?.trim() || tokenFilter.trim() || statusFilterValue !== '' + ) // Fetch data with React Query // eslint-disable-next-line @tanstack/query/exhaustive-deps @@ -239,6 +279,7 @@ export function ApiKeysTable() { pagination.pageSize, globalFilter, tokenFilter, + statusFilterValue, refreshTrigger, ], queryFn: async () => { @@ -246,6 +287,7 @@ export function ApiKeysTable() { ? await searchApiKeys({ keyword: globalFilter, token: tokenFilter, + status: statusFilterValue, p: pagination.pageIndex + 1, size: pagination.pageSize, }) @@ -289,6 +331,7 @@ export function ApiKeysTable() { onGlobalFilterChange, onColumnFiltersChange, manualPagination: true, + manualFiltering: true, totalCount: data?.total || 0, ensurePageInRange, }) diff --git a/web/src/features/keys/lib/index.ts b/web/src/features/keys/lib/index.ts index 4f904acbc552..5ed57a5598b1 100644 --- a/web/src/features/keys/lib/index.ts +++ b/web/src/features/keys/lib/index.ts @@ -16,6 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +// ============================================================================ +// Utility Functions +// ============================================================================ +export { isApiKeyExpired, isApiKeyExhausted } from './utils' + // ============================================================================ // Form Utilities // ============================================================================ diff --git a/web/src/features/keys/lib/utils.ts b/web/src/features/keys/lib/utils.ts new file mode 100644 index 000000000000..dc379bdf4790 --- /dev/null +++ b/web/src/features/keys/lib/utils.ts @@ -0,0 +1,45 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +/** + * Utility functions for API keys + */ + +/** + * Check if an API key is expired based on expired_time + * @param expired_time - Unix timestamp in seconds (-1 means never expires) + * @returns true if the key is expired + */ +export function isApiKeyExpired(expired_time: number): boolean { + if (expired_time === -1) return false + return expired_time < Date.now() / 1000 +} + +/** + * Check if an API key is quota-exhausted based on remain_quota + * @param remain_quota - Remaining quota + * @param unlimited_quota - Whether the key has unlimited quota + * @returns true if the key is exhausted + */ +export function isApiKeyExhausted( + remain_quota: number, + unlimited_quota: boolean +): boolean { + if (unlimited_quota) return false + return remain_quota <= 0 +} diff --git a/web/src/features/keys/types.ts b/web/src/features/keys/types.ts index 1583e6497df7..a6630fcdc686 100644 --- a/web/src/features/keys/types.ts +++ b/web/src/features/keys/types.ts @@ -78,6 +78,7 @@ export interface GetApiKeysResponse { export interface SearchApiKeysParams { keyword?: string token?: string + status?: string p?: number size?: number }