Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
50 changes: 17 additions & 33 deletions model/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package model
import (
"errors"
"fmt"
"strconv"
"strings"

"github.com/QuantumNous/new-api/common"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions web/src/features/keys/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ export async function getApiKeys(
export async function searchApiKeys(
params: SearchApiKeysParams
): Promise<GetApiKeysResponse> {
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()}`)
Expand Down Expand Up @@ -95,7 +96,9 @@ export async function updateApiKeyStatus(
id: number,
status: number
): Promise<ApiResponse<ApiKey>> {
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
}

Expand Down
49 changes: 46 additions & 3 deletions web/src/features/keys/components/api-keys-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,7 +117,31 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
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 (
<StatusBadge
label={t('Expired')}
variant='warning'
copyable={false}
className='-ml-1.5'
/>
)
}
if (isApiKeyExhausted(apiKey.remain_quota, apiKey.unlimited_quota)) {
return (
<StatusBadge
label={t('Exhausted')}
variant='danger'
copyable={false}
className='-ml-1.5'
/>
)
}

const statusConfig = API_KEY_STATUSES[statusValue]
if (!statusConfig) return null
return (
<StatusBadge
Expand All @@ -127,7 +152,25 @@ export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
/>
)
},
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 },
},
Expand Down
69 changes: 56 additions & 13 deletions web/src/features/keys/components/api-keys-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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,
Expand All @@ -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'
Expand All @@ -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<StatusBadgeProps, 'variant'> & { label: string }) | null
): ReactNode {
if (expired) {
return (
<StatusBadge label={t('Expired')} variant='warning' copyable={false} />
)
}
if (exhausted) {
return (
<StatusBadge label={t('Exhausted')} variant='danger' copyable={false} />
)
}
if (!statusConfig) return null
return (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
)
}

function ApiKeysMobileSkeleton() {
Expand Down Expand Up @@ -129,9 +160,17 @@ function ApiKeysMobileList({
<div className='divide-border overflow-hidden rounded-lg border'>
{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 (
<div
key={row.id}
Expand All @@ -149,13 +188,7 @@ function ApiKeysMobileList({
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
)}
{mobileBadge}
</div>

<div className='flex min-w-0 items-center justify-between gap-2'>
Expand Down Expand Up @@ -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
Expand All @@ -239,13 +279,15 @@ export function ApiKeysTable() {
pagination.pageSize,
globalFilter,
tokenFilter,
statusFilterValue,
refreshTrigger,
],
queryFn: async () => {
const result = shouldSearch
? await searchApiKeys({
keyword: globalFilter,
token: tokenFilter,
status: statusFilterValue,
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
Expand Down Expand Up @@ -289,6 +331,7 @@ export function ApiKeysTable() {
onGlobalFilterChange,
onColumnFiltersChange,
manualPagination: true,
manualFiltering: true,
totalCount: data?.total || 0,
ensurePageInRange,
})
Expand Down
5 changes: 5 additions & 0 deletions web/src/features/keys/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
// ============================================================================
// Utility Functions
// ============================================================================
export { isApiKeyExpired, isApiKeyExhausted } from './utils'

// ============================================================================
// Form Utilities
// ============================================================================
Expand Down
Loading