From c365accf80fc83fbd99a3c2977a53620c006f60e Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 17 Jul 2026 09:19:19 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E6=B7=BB=E5=8A=A0openai=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E8=AE=A1=E8=B4=B9=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/usage-logs/components/dialogs/details-dialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index 60131ca..411579d 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -189,7 +189,6 @@ function BillingBreakdown(props: { const { t } = useTranslation() const { log, other, isAdmin } = props const isPerCall = isPerCallBilling(other.model_price) - const isClaude = other.claude === true const isTieredExpr = other.billing_mode === 'tiered_expr' const tieredSummary = getTieredBillingSummary(other) @@ -256,7 +255,7 @@ function BillingBreakdown(props: { }) } - if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) { + if (!isTieredExpr && hasAnyCacheTokens(other)) { if (other.cache_ratio != null && other.cache_ratio !== 1) { rows.push({ label: t('Cache Read'), From d07cf81498980b5b0874dad483f9f1f519a4c03c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 17 Jul 2026 13:28:41 +0800 Subject: [PATCH 02/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/components/public-header.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx index 5f758fd..10cdab7 100644 --- a/web/default/src/components/layout/components/public-header.tsx +++ b/web/default/src/components/layout/components/public-header.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useRouterState } from '@tanstack/react-router' +import { useMediaQuery } from '@/hooks' import { useTranslation } from 'react-i18next' import { IconGithub } from '@/assets/brand-icons' import { useAuthStore } from '@/stores/auth-store' @@ -101,6 +102,7 @@ export function PublicHeader(props: PublicHeaderProps) { const notifications = useNotifications() const routerState = useRouterState() const pathname = routerState.location.pathname + const isDesktopNavigation = useMediaQuery(DESKTOP_NAVIGATION_MEDIA_QUERY) const user = auth.user const isAuthenticated = !!user @@ -286,7 +288,7 @@ export function PublicHeader(props: PublicHeaderProps) { {showLanguageSwitcher && } {showThemeSwitch && } - {showNotifications && ( + {showNotifications && isDesktopNavigation && ( - {showNotifications && ( + {showNotifications && !isDesktopNavigation && ( Date: Sat, 18 Jul 2026 00:19:11 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/data-table-row-actions.tsx | 17 +- .../dialogs/channel-test-dialog.tsx | 67 +++++-- .../channels/lib/channel-actions.test.ts | 77 +++++++++ .../features/channels/lib/channel-actions.ts | 163 +++++++++++++++++- web/default/src/features/channels/types.ts | 1 + .../models/model-ratio-visual-editor.tsx | 63 +++++-- web/default/src/i18n/locales/en.json | 12 +- web/default/src/i18n/locales/fr.json | 12 +- web/default/src/i18n/locales/ja.json | 12 +- web/default/src/i18n/locales/ru.json | 12 +- web/default/src/i18n/locales/vi.json | 12 +- web/default/src/i18n/locales/zh.json | 12 +- 12 files changed, 405 insertions(+), 55 deletions(-) create mode 100644 web/default/src/features/channels/lib/channel-actions.test.ts diff --git a/web/default/src/features/channels/components/data-table-row-actions.tsx b/web/default/src/features/channels/components/data-table-row-actions.tsx index 3f69a50..cb22557 100644 --- a/web/default/src/features/channels/components/data-table-row-actions.tsx +++ b/web/default/src/features/channels/components/data-table-row-actions.tsx @@ -53,12 +53,13 @@ import { import { ConfirmDialog } from '@/components/confirm-dialog' import { MODEL_FETCHABLE_TYPES } from '../constants' import { - channelsQueryKeys, + createChannelTestCachePatch, handleDeleteChannel, handleTestChannel, handleToggleChannelStatus, isChannelEnabled, isMultiKeyChannel, + refreshChannelListsWithTestPatch, } from '../lib' import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils' import type { Channel } from '../types' @@ -94,9 +95,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { e.stopPropagation() setIsTesting(true) try { - await handleTestChannel(channel.id, undefined, () => { - queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) - }) + await handleTestChannel( + channel.id, + { channelName: channel.name }, + (_success, responseTime) => { + refreshChannelListsWithTestPatch( + queryClient, + channel.id, + createChannelTestCachePatch(responseTime) + ) + } + ) } finally { setIsTesting(false) } diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index a7f83bf..b8a9d1c 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { type ColumnDef, type RowSelectionState, @@ -26,7 +27,14 @@ import { getPaginationRowModel, useReactTable, } from '@tanstack/react-table' -import { Check, CheckCircle2, Copy, Info, Loader2, Settings } from 'lucide-react' +import { + Check, + CheckCircle2, + Copy, + Info, + Loader2, + Settings, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -83,7 +91,12 @@ import { sideDrawerHeaderClassName, } from '@/components/drawer-layout' import { StatusBadge } from '@/components/status-badge' -import { formatResponseTime, handleTestChannel } from '../../lib' +import { + createChannelTestCachePatch, + formatResponseTime, + handleTestChannel, + refreshChannelListsWithTestPatch, +} from '../../lib' import { useChannels } from '../channels-provider' type ChannelTestDialogProps = { @@ -100,6 +113,7 @@ type TestStatus = 'idle' | 'testing' | 'success' | 'error' type TestResult = { status: TestStatus responseTime?: number + completedAt?: number error?: string errorCode?: string } @@ -233,6 +247,8 @@ export function ChannelTestDialog({ }: ChannelTestDialogProps) { const { t } = useTranslation() const { currentRow } = useChannels() + const queryClient = useQueryClient() + const currentChannelId = currentRow?.id const [endpointType, setEndpointType] = useState('auto') const [isStreamTest, setIsStreamTest] = useState(false) const [searchTerm, setSearchTerm] = useState('') @@ -242,9 +258,7 @@ export function ChannelTestDialog({ () => new Set() ) const [isBatchTesting, setIsBatchTesting] = useState(false) - const [batchProgress, setBatchProgress] = useState( - null - ) + const [batchProgress, setBatchProgress] = useState(null) const [failureDetails, setFailureDetails] = useState(null) const [pagination, setPagination] = useState({ @@ -331,8 +345,24 @@ export function ChannelTestDialog({ })) }, []) + const refreshChannelLists = useCallback( + (result?: TestResult) => { + if (!currentChannelId) return + refreshChannelListsWithTestPatch( + queryClient, + currentChannelId, + createChannelTestCachePatch(result?.responseTime, result?.completedAt) + ) + }, + [currentChannelId, queryClient] + ) + const testSingleModel = useCallback( - async (model: string, silent = false): Promise => { + async ( + model: string, + silent = false, + refreshList = true + ): Promise => { if (!currentRow) return markModelTesting(model, true) @@ -343,15 +373,18 @@ export function ChannelTestDialog({ await handleTestChannel( currentRow.id, { + channelName: currentRow.name, testModel: model, endpointType: endpointType === 'auto' ? undefined : endpointType, stream: isStreamTest || undefined, silent, }, (success, responseTime, error, errorCode) => { + const completedAt = Date.now() finalResult = { status: success ? 'success' : 'error', responseTime, + completedAt, error, errorCode, } @@ -361,11 +394,15 @@ export function ChannelTestDialog({ } catch (error: unknown) { finalResult = { status: 'error', + completedAt: Date.now(), error: error instanceof Error ? error.message : t('Test failed'), } updateTestResult(model, finalResult) } finally { markModelTesting(model, false) + if (refreshList) { + refreshChannelLists(finalResult) + } } return finalResult }, @@ -374,6 +411,7 @@ export function ChannelTestDialog({ endpointType, isStreamTest, markModelTesting, + refreshChannelLists, t, updateTestResult, ] @@ -397,6 +435,7 @@ export function ChannelTestDialog({ let completedCount = 0 let successCount = 0 let failedCount = 0 + let latestResultWithResponse: TestResult | undefined try { const recordBatchResult = (result?: TestResult) => { @@ -424,12 +463,15 @@ export function ChannelTestDialog({ startIndex + BATCH_TEST_CONCURRENCY ) const settled = await Promise.allSettled( - batch.map((modelName) => testSingleModel(modelName, true)) + batch.map((modelName) => testSingleModel(modelName, true, false)) ) for (const result of settled) { - recordBatchResult( + const resultValue = result.status === 'fulfilled' ? result.value : undefined - ) + if (typeof resultValue?.responseTime === 'number') { + latestResultWithResponse = resultValue + } + recordBatchResult(resultValue) } if (startIndex + BATCH_TEST_CONCURRENCY < uniqueModels.length) { @@ -455,12 +497,13 @@ export function ChannelTestDialog({ ) } } finally { + refreshChannelLists(latestResultWithResponse) setIsBatchTesting(false) setBatchProgress(null) setRowSelection({}) } }, - [t, testSingleModel] + [refreshChannelLists, t, testSingleModel] ) const handleSelectSuccessfulModels = useCallback(() => { @@ -703,7 +746,9 @@ export function ChannelTestDialog({ - {batchProgress && } + {batchProgress && ( + + )} {!isAnyTesting && successModels.length > 0 && (
diff --git a/web/default/src/features/channels/lib/channel-actions.test.ts b/web/default/src/features/channels/lib/channel-actions.test.ts new file mode 100644 index 0000000..ffcd117 --- /dev/null +++ b/web/default/src/features/channels/lib/channel-actions.test.ts @@ -0,0 +1,77 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 https://github.com/MAX-API-Next/MAX-API/issues +*/ +import type { QueryClient } from '@tanstack/react-query' +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + createChannelTestCachePatch, + updateChannelTestCache, +} from './channel-actions' + +describe('channel test cache patch helpers', () => { + test('creates a cache patch from a finite response time', () => { + const originalNow = Date.now + Date.now = () => 1_234_567_890_123 + + try { + assert.deepEqual(createChannelTestCachePatch(345), { + responseTime: 345, + testTime: 1_234_567_890, + }) + assert.equal(createChannelTestCachePatch(undefined), undefined) + } finally { + Date.now = originalNow + } + }) + + test('updates the matching channel entry in list caches', () => { + const oldData = { + success: true, + data: { + items: [ + { id: 1, response_time: 10, test_time: 11 }, + { id: 2, response_time: 20, test_time: 21 }, + ], + total: 2, + page: 1, + page_size: 20, + }, + } + + let nextData = oldData + const queryClient = { + setQueriesData: ( + _options: unknown, + updater: (value: typeof oldData) => typeof oldData + ) => { + nextData = updater(nextData) + }, + } as unknown as QueryClient + + updateChannelTestCache(queryClient, 2, { + responseTime: 99, + testTime: 123, + }) + + assert.equal(nextData.data.items[0].response_time, 10) + assert.equal(nextData.data.items[0].test_time, 11) + assert.equal(nextData.data.items[1].response_time, 99) + assert.equal(nextData.data.items[1].test_time, 123) + }) +}) diff --git a/web/default/src/features/channels/lib/channel-actions.ts b/web/default/src/features/channels/lib/channel-actions.ts index b0170de..70b464c 100644 --- a/web/default/src/features/channels/lib/channel-actions.ts +++ b/web/default/src/features/channels/lib/channel-actions.ts @@ -37,7 +37,12 @@ import { updateChannelBalance, } from '../api' import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' -import type { CopyChannelParams } from '../types' +import type { + ChannelTestResponse, + CopyChannelParams, + GetChannelsResponse, + SearchChannelsResponse, +} from '../types' // ============================================================================ // Query Keys @@ -52,6 +57,131 @@ export const channelsQueryKeys = { detail: (id: number) => [...channelsQueryKeys.details(), id] as const, } +export type ChannelTestCachePatch = { + responseTime: number + testTime: number +} + +type ChannelListCache = GetChannelsResponse | SearchChannelsResponse + +function getChannelTestResponseTime( + response: ChannelTestResponse +): number | undefined { + const responseTime = response.data?.response_time + if (typeof responseTime === 'number' && Number.isFinite(responseTime)) { + return responseTime + } + + if ( + typeof response.time === 'number' && + Number.isFinite(response.time) && + response.time > 0 + ) { + return Math.round(response.time * 1000) + } + + return undefined +} + +function formatChannelTestDuration(responseTime?: number): string | undefined { + if (responseTime === undefined) return undefined + + if (responseTime >= 1000) { + return `${(responseTime / 1000).toFixed(2)} s` + } + + return `${Math.max(1, Math.round(responseTime))} ms` +} + +function getChannelTestLabel(options?: { + channelName?: string + testModel?: string +}): string { + const channelName = options?.channelName?.trim() + const testModel = options?.testModel?.trim() + + if (channelName && testModel) { + return i18next.t('Channel {{name}} model {{model}}', { + name: channelName, + model: testModel, + }) + } + + if (channelName) { + return i18next.t('Channel {{name}}', { name: channelName }) + } + + if (testModel) { + return i18next.t('Model {{model}}', { model: testModel }) + } + + return i18next.t('Channel') +} + +export function createChannelTestCachePatch( + responseTime?: number, + completedAt = Date.now() +): ChannelTestCachePatch | undefined { + if (typeof responseTime !== 'number' || !Number.isFinite(responseTime)) { + return undefined + } + + return { + responseTime, + testTime: Math.floor(completedAt / 1000), + } +} + +export function updateChannelTestCache( + queryClient: QueryClient, + channelId: number, + patch?: ChannelTestCachePatch +) { + if (!patch) return + + queryClient.setQueriesData( + { queryKey: channelsQueryKeys.lists() }, + (oldData) => { + const data = oldData?.data + if (!oldData || !data?.items.length) return oldData + + let changed = false + const nextItems = data.items.map((channel) => { + if (channel.id !== channelId) return channel + + changed = true + return { + ...channel, + response_time: patch.responseTime, + test_time: patch.testTime, + } + }) + + if (!changed) return oldData + + return { + ...oldData, + data: { + ...data, + items: nextItems, + }, + } + } + ) +} + +export function refreshChannelListsWithTestPatch( + queryClient: QueryClient, + channelId: number, + patch?: ChannelTestCachePatch +) { + updateChannelTestCache(queryClient, channelId, patch) + void queryClient + .invalidateQueries({ queryKey: channelsQueryKeys.lists() }) + .then(() => updateChannelTestCache(queryClient, channelId, patch)) + .catch(() => undefined) +} + // ============================================================================ // Single Channel Actions // ============================================================================ @@ -212,6 +342,7 @@ export async function handleUpdateTagField( export async function handleTestChannel( id: number, options?: { + channelName?: string testModel?: string endpointType?: string stream?: boolean @@ -237,23 +368,43 @@ export async function handleTestChannel( try { const response = await testChannel(id, payload) + const responseTime = getChannelTestResponseTime(response) + const duration = formatChannelTestDuration(responseTime) + const target = getChannelTestLabel(options) if (response.success) { if (!options?.silent) { - toast.success(i18next.t(SUCCESS_MESSAGES.TESTED)) + toast.success( + i18next.t('{{target}} test succeeded', { target }), + duration + ? { + description: i18next.t('Response time: {{duration}}', { + duration, + }), + } + : undefined + ) } - onTestComplete?.(true, response.data?.response_time) + onTestComplete?.(true, responseTime) } else { + const errorMsg = response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED) if (!options?.silent) { - toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)) + toast.error(i18next.t('{{target}} test failed', { target }), { + description: response.error_code + ? `${errorMsg} (${response.error_code})` + : errorMsg, + }) } - onTestComplete?.(false, undefined, response.message, response.error_code) + onTestComplete?.(false, responseTime, errorMsg, response.error_code) } } catch (_error: unknown) { const err = _error as { response?: { data?: { message?: string } } } const errorMsg = err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED) + const target = getChannelTestLabel(options) if (!options?.silent) { - toast.error(errorMsg) + toast.error(i18next.t('{{target}} test failed', { target }), { + description: errorMsg, + }) } onTestComplete?.(false, undefined, errorMsg) } diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index 80757cf..1c724a8 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -188,6 +188,7 @@ export interface ChannelTestResponse { success: boolean message?: string error_code?: string + time?: number data?: { response_time?: number error?: string diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index a78f4df..bd4417b 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -121,6 +121,11 @@ type ModelRow = { billingExpr?: string requestRuleExpr?: string hasConflict: boolean + saved?: ModelRow + draft?: ModelRow + isDraftChanged?: boolean + isDraftDeleted?: boolean + isDraftNew?: boolean } const STORAGE_KEY = 'model-ratio-column-visibility' @@ -224,6 +229,23 @@ const isBasePricingUnset = (row?: ModelRow) => !hasValue(row.price) && !hasValue(row.ratio)) +const getModelRowSignature = (row?: ModelRow) => { + if (!row) return '' + return JSON.stringify({ + price: row.price || '', + ratio: row.ratio || '', + cacheRatio: row.cacheRatio || '', + createCacheRatio: row.createCacheRatio || '', + completionRatio: row.completionRatio || '', + imageRatio: row.imageRatio || '', + audioRatio: row.audioRatio || '', + audioCompletionRatio: row.audioCompletionRatio || '', + billingMode: row.billingMode || 'per-token', + billingExpr: row.billingExpr || '', + requestRuleExpr: row.requestRuleExpr || '', + }) +} + const buildModelRows = ({ modelPrice, modelRatio, @@ -461,15 +483,23 @@ export const ModelRatioVisualEditor = memo( .map((name) => { const draft = draftByName.get(name) const saved = savedByName.get(name) - return ( - draft ?? + const displayed = draft ?? saved ?? { name, billingMode: 'per-token', hasConflict: false, } - ) + return { + ...displayed, + saved, + draft, + isDraftChanged: + getModelRowSignature(saved) !== getModelRowSignature(draft), + isDraftDeleted: Boolean(saved && !draft), + isDraftNew: Boolean(!saved && draft), + } }) + .filter((row) => !row.isDraftDeleted) .filter( (row) => filterMode !== 'unset' || @@ -524,24 +554,25 @@ export const ModelRatioVisualEditor = memo( const handleEdit = useCallback( (model: ModelRow) => { + const editableModel = model.draft ?? model.saved ?? model setEditData({ - name: model.name, - price: model.price, - ratio: model.ratio, - cacheRatio: model.cacheRatio, - createCacheRatio: model.createCacheRatio, - completionRatio: model.completionRatio, - imageRatio: model.imageRatio, - audioRatio: model.audioRatio, - audioCompletionRatio: model.audioCompletionRatio, + name: editableModel.name, + price: editableModel.price, + ratio: editableModel.ratio, + cacheRatio: editableModel.cacheRatio, + createCacheRatio: editableModel.createCacheRatio, + completionRatio: editableModel.completionRatio, + imageRatio: editableModel.imageRatio, + audioRatio: editableModel.audioRatio, + audioCompletionRatio: editableModel.audioCompletionRatio, billingMode: - model.billingMode === 'tiered_expr' + editableModel.billingMode === 'tiered_expr' ? 'tiered_expr' - : model.price && model.price !== '' + : editableModel.price && editableModel.price !== '' ? 'per-request' : 'per-token', - billingExpr: model.billingExpr, - requestRuleExpr: model.requestRuleExpr, + billingExpr: editableModel.billingExpr, + requestRuleExpr: editableModel.requestRuleExpr, }) setEditorOpen(true) if (isMobile) setSheetOpen(true) diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index c93433e..8f3c2ce 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": ". Please fix the JSON before saving.", ". This action cannot be undone.": ". This action cannot be undone.", "...": "...", - "= {{count}} tokens": "= {{count}} tokens", - "= {{count}}K tokens": "= {{count}}K tokens", - "= {{count}}M tokens": "= {{count}}M tokens", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"", "({{total}} total, {{omit}} omitted)": "({{total}} total, {{omit}} omitted)", "(Leave empty to dissolve tag)": "(Leave empty to dissolve tag)", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "{{routeContext}} groups must not be empty", "{{routeContext}} groups must not exceed 64 entries": "{{routeContext}} groups must not exceed 64 entries", "{{success}} succeeded, {{failed}} failed": "{{success}} succeeded, {{failed}} failed", + "{{target}} test failed": "{{target}} test failed", + "{{target}} test succeeded": "{{target}} test succeeded", "{{title}} usage: {{percent}}%": "{{title}} usage: {{percent}}%", "{{value}}ms": "{{value}}ms", "{{value}}s": "{{value}}s", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "`, and `-nothinking` suffixes while routing to the correct Gemini variant.", "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. All rights reserved.", "+{{count}} more": "+{{count}} more", + "= {{count}} tokens": "= {{count}} tokens", + "= {{count}}K tokens": "= {{count}}K tokens", + "= {{count}}M tokens": "= {{count}}M tokens", "| Based on": "| Based on", "0 means data is kept permanently": "0 means data is kept permanently", "0 means unlimited": "0 means unlimited", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "Changes are written to the settings draft on save.", "Changing...": "Changing...", "Channel": "Channel", + "Channel {{name}}": "Channel {{name}}", + "Channel {{name}} model {{model}}": "Channel {{name}} model {{model}}", "Channel Affinity": "Channel Affinity", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.", "Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit", @@ -2694,6 +2698,7 @@ "Mode": "Mode", "model": "model", "Model": "Model", + "Model {{model}}": "Model {{model}}", "Model access": "Model access", "Model Access": "Model Access", "Model access fabric": "Model access fabric", @@ -3887,6 +3892,7 @@ "Response path for task status": "Response path for task status", "Response path for upstream task ID": "Response path for upstream task ID", "Response Time": "Response Time", + "Response time: {{duration}}": "Response time: {{duration}}", "Responses API Version": "Responses API Version", "Responses APIs": "Responses APIs", "Restore defaults": "Restore defaults", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 3e77977..11cd94e 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": ". Veuillez corriger le JSON avant d'enregistrer.", ". This action cannot be undone.": ". Cette action ne peut pas être annulée.", "...": "...", - "= {{count}} tokens": "= {{count}} jetons", - "= {{count}}K tokens": "= {{count}} milliers de jetons", - "= {{count}}M tokens": "= {{count}} millions de jetons", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"", "({{total}} total, {{omit}} omitted)": "({{total}} au total, {{omit}} omis)", "(Leave empty to dissolve tag)": "(Laisser vide pour dissoudre le tag)", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "Les groupes {{routeContext}} ne doivent pas être vides", "{{routeContext}} groups must not exceed 64 entries": "Les groupes {{routeContext}} ne doivent pas dépasser 64 entrées", "{{success}} succeeded, {{failed}} failed": "{{success}} réussis, {{failed}} échoués", + "{{target}} test failed": "Échec du test de {{target}}", + "{{target}} test succeeded": "Test de {{target}} réussi", "{{title}} usage: {{percent}}%": "Utilisation de {{title}} : {{percent}} %", "{{value}}ms": "{{value}} ms", "{{value}}s": "{{value}} s", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "`, et les suffixes `-nothinking` lors du routage vers la variante Gemini correcte.", "© 2025 Your Company. All rights reserved.": "© 2025 Votre entreprise. Tous droits réservés.", "+{{count}} more": "+{{count}} de plus", + "= {{count}} tokens": "= {{count}} jetons", + "= {{count}}K tokens": "= {{count}} milliers de jetons", + "= {{count}}M tokens": "= {{count}} millions de jetons", "| Based on": "| Basé sur", "0 means data is kept permanently": "0 signifie que les données sont conservées indéfiniment", "0 means unlimited": "0 signifie illimité", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "Les modifications sont écrites dans le brouillon des paramètres lors de l’enregistrement.", "Changing...": "Modification en cours...", "Channel": "Canal", + "Channel {{name}}": "Canal {{name}}", + "Channel {{name}} model {{model}}": "Canal {{name}} modèle {{model}}", "Channel Affinity": "Affinité de canal", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.", "Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont", @@ -2694,6 +2698,7 @@ "Mode": "Mode", "model": "modèle", "Model": "Modèle", + "Model {{model}}": "Modèle {{model}}", "Model access": "Accès aux modèles", "Model Access": "Accès au modèle", "Model access fabric": "Maillage d'accès aux modèles", @@ -3887,6 +3892,7 @@ "Response path for task status": "Chemin de réponse pour le statut", "Response path for upstream task ID": "Chemin de réponse pour l’ID de tâche amont", "Response Time": "Temps de réponse", + "Response time: {{duration}}": "Temps de réponse : {{duration}}", "Responses API Version": "Version de l'API des réponses", "Responses APIs": "API Responses", "Restore defaults": "Restaurer les paramètres par défaut", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index c4450f1..2c27026 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": "。保存する前にJSONを修正してください。", ". This action cannot be undone.": "。この操作は元に戻せません。", "...": "...", - "= {{count}} tokens": "= {{count}} トークン", - "= {{count}}K tokens": "= {{count}}K トークン", - "= {{count}}M tokens": "= {{count}}M トークン", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default \":\" us - central 1 \", \"claude -3 -5 - sonnet -20240620 \":\" europe - west 1 \"", "({{total}} total, {{omit}} omitted)": "(合計 {{total}} 件、{{omit}} 件を省略)", "(Leave empty to dissolve tag)": "(タグを解除するには空欄のままにしてください)", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "{{routeContext}} のグループは空にできません", "{{routeContext}} groups must not exceed 64 entries": "{{routeContext}} のグループは64件以下にしてください", "{{success}} succeeded, {{failed}} failed": "{{success}} 件成功、{{failed}} 件失敗", + "{{target}} test failed": "{{target}} のテストに失敗しました", + "{{target}} test succeeded": "{{target}} のテストに成功しました", "{{title}} usage: {{percent}}%": "{{title}} の使用率: {{percent}}%", "{{value}}ms": "{{value}}ミリ秒", "{{value}}s": "{{value}}秒", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "`, および `-nothinking` サフィックスを、正しい Gemini バリアントにルーティングする際に使用します。", "© 2025 Your Company. All rights reserved.": "© 2025 Your Company. 全著作権所有。", "+{{count}} more": "他 {{count}} 件", + "= {{count}} tokens": "= {{count}} トークン", + "= {{count}}K tokens": "= {{count}}K トークン", + "= {{count}}M tokens": "= {{count}}M トークン", "| Based on": "| に基づく", "0 means data is kept permanently": "0 はデータを永続的に保持することを意味します", "0 means unlimited": "0は無制限を意味します", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "保存すると変更は設定ドラフトに書き込まれます。", "Changing...": "変更中...", "Channel": "チャネル", + "Channel {{name}}": "チャネル {{name}}", + "Channel {{name}} model {{model}}": "チャネル {{name}} モデル {{model}}", "Channel Affinity": "チャネルアフィニティ", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。", "Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット", @@ -2694,6 +2698,7 @@ "Mode": "モード", "model": "モデル", "Model": "モデル", + "Model {{model}}": "モデル {{model}}", "Model access": "モデルアクセス", "Model Access": "モデルアクセス", "Model access fabric": "モデルアクセスファブリック", @@ -3887,6 +3892,7 @@ "Response path for task status": "タスクステータスのレスポンスパス", "Response path for upstream task ID": "上流タスク ID のレスポンスパス", "Response Time": "応答時間", + "Response time: {{duration}}": "応答時間: {{duration}}", "Responses API Version": "応答APIバージョン", "Responses APIs": "Responses API", "Restore defaults": "既定に戻す", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 4240291..8ad37d7 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": ". Пожалуйста, исправьте JSON перед сохранением.", ". This action cannot be undone.": ". Это действие невозможно отменить.", "...": "...", - "= {{count}} tokens": "= {{count}} токенов", - "= {{count}}K tokens": "= {{count}} тыс. токенов", - "= {{count}}M tokens": "= {{count}} млн токенов", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"", "({{total}} total, {{omit}} omitted)": "({{total}} всего, {{omit}} скрыто)", "(Leave empty to dissolve tag)": "(Оставьте пустым, чтобы удалить тег)", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "Группы {{routeContext}} не должны быть пустыми", "{{routeContext}} groups must not exceed 64 entries": "Группы {{routeContext}} не должны содержать более 64 элементов", "{{success}} succeeded, {{failed}} failed": "{{success}} успешно, {{failed}} с ошибкой", + "{{target}} test failed": "Тест {{target}} не выполнен", + "{{target}} test succeeded": "Тест {{target}} успешно выполнен", "{{title}} usage: {{percent}}%": "Использование {{title}}: {{percent}}%", "{{value}}ms": "{{value}} мс", "{{value}}s": "{{value}} с", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "суффиксы `, и `-nothinking` при маршрутизации к правильному варианту Gemini.", "© 2025 Your Company. All rights reserved.": "© 2025 Ваша Компания. Все права защищены.", "+{{count}} more": "ещё {{count}}", + "= {{count}} tokens": "= {{count}} токенов", + "= {{count}}K tokens": "= {{count}} тыс. токенов", + "= {{count}}M tokens": "= {{count}} млн токенов", "| Based on": "| На основе", "0 means data is kept permanently": "0 означает, что данные хранятся постоянно", "0 means unlimited": "0 означает без ограничений", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "Изменения будут записаны в черновик настроек при сохранении.", "Changing...": "Изменение...", "Channel": "Канал", + "Channel {{name}}": "Канал {{name}}", + "Channel {{name}} model {{model}}": "Канал {{name}}, модель {{model}}", "Channel Affinity": "Привязка к каналу", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.", "Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream", @@ -2694,6 +2698,7 @@ "Mode": "Режим", "model": "модель", "Model": "Модель", + "Model {{model}}": "Модель {{model}}", "Model access": "Доступ к моделям", "Model Access": "Доступ к моделям", "Model access fabric": "Сеть доступа к моделям", @@ -3887,6 +3892,7 @@ "Response path for task status": "Путь ответа для статуса задачи", "Response path for upstream task ID": "Путь ответа для ID задачи апстрима", "Response Time": "Время ответа", + "Response time: {{duration}}": "Время ответа: {{duration}}", "Responses API Version": "Версия API ответов", "Responses APIs": "Responses API", "Restore defaults": "Сбросить к значениям по умолчанию", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index f21c94b..9e70edd 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": ". Vui lòng sửa JSON trước khi lưu.", ". This action cannot be undone.": ". Hành động này không thể hoàn tác.", "...": "...", - "= {{count}} tokens": "= {{count}} token", - "= {{count}}K tokens": "= {{count}} nghìn token", - "= {{count}}M tokens": "= {{count}} triệu token", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"", "({{total}} total, {{omit}} omitted)": "({{total}} tổng cộng, đã lược bỏ {{omit}})", "(Leave empty to dissolve tag)": "Để trống để xóa thẻ.", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "Các nhóm {{routeContext}} không được để trống", "{{routeContext}} groups must not exceed 64 entries": "Các nhóm {{routeContext}} không được vượt quá 64 mục", "{{success}} succeeded, {{failed}} failed": "{{success}} thành công, {{failed}} thất bại", + "{{target}} test failed": "Kiểm tra {{target}} thất bại", + "{{target}} test succeeded": "Kiểm tra {{target}} thành công", "{{title}} usage: {{percent}}%": "Mức sử dụng {{title}}: {{percent}}%", "{{value}}ms": "{{value}} ms", "{{value}}s": "{{value}} s", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": ", và", "© 2025 Your Company. All rights reserved.": "© 2025 Công ty của bạn. Mọi quyền được bảo lưu.", "+{{count}} more": "thêm {{count}} mục", + "= {{count}} tokens": "= {{count}} token", + "= {{count}}K tokens": "= {{count}} nghìn token", + "= {{count}}M tokens": "= {{count}} triệu token", "| Based on": "| Dựa trên", "0 means data is kept permanently": "0 nghĩa là dữ liệu được giữ vĩnh viễn", "0 means unlimited": "0 có nghĩa là không giới hạn", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "Các thay đổi sẽ được ghi vào bản nháp cài đặt khi lưu.", "Changing...": "Đang thay đổi...", "Channel": "Kênh", + "Channel {{name}}": "Kênh {{name}}", + "Channel {{name}} model {{model}}": "Kênh {{name}} mô hình {{model}}", "Channel Affinity": "Ưu tiên kênh", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.", "Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream", @@ -2694,6 +2698,7 @@ "Mode": "Chế độ", "model": "mô hình", "Model": "Mô hình", + "Model {{model}}": "Mô hình {{model}}", "Model access": "Truy cập mô hình", "Model Access": "Truy cập mô hình", "Model access fabric": "Mạng truy cập mô hình", @@ -3887,6 +3892,7 @@ "Response path for task status": "Đường dẫn phản hồi cho trạng thái tác vụ", "Response path for upstream task ID": "Đường dẫn phản hồi cho ID tác vụ upstream", "Response Time": "Thời gian phản hồi", + "Response time: {{duration}}": "Thời gian phản hồi: {{duration}}", "Responses API Version": "Phiên bản API Phản hồi", "Responses APIs": "Responses API", "Restore defaults": "Khôi phục mặc định", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 85db650..3e22782 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -12,9 +12,6 @@ ". Please fix the JSON before saving.": "。请在保存前修复 JSON。", ". This action cannot be undone.": "。此操作无法撤销。", "...": "...", - "= {{count}} tokens": "= {{count}} 个 token", - "= {{count}}K tokens": "= {{count}}K 个 token", - "= {{count}}M tokens": "= {{count}}M 个 token", "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"": "\"default\": \"us-central1\", \"claude-3-5-sonnet-20240620\": \"europe-west1\"", "({{total}} total, {{omit}} omitted)": "(共 {{total}} 个,省略 {{omit}} 个)", "(Leave empty to dissolve tag)": "(留空以删除标签)", @@ -67,6 +64,8 @@ "{{routeContext}} groups must not be empty": "{{routeContext}} 分组不能为空", "{{routeContext}} groups must not exceed 64 entries": "{{routeContext}} 分组不能超过 64 项", "{{success}} succeeded, {{failed}} failed": "{{success}} 个成功,{{failed}} 个失败", + "{{target}} test failed": "{{target}} 测试失败", + "{{target}} test succeeded": "{{target}} 测试成功", "{{title}} usage: {{percent}}%": "{{title}} 使用率:{{percent}}%", "{{value}}ms": "{{value}} 毫秒", "{{value}}s": "{{value}} 秒", @@ -82,6 +81,9 @@ "`, and `-nothinking` suffixes while routing to the correct Gemini variant.": "`, 和 `-nothinking` 后缀,同时路由到正确的 Gemini 变体。", "© 2025 Your Company. All rights reserved.": "© 2025 您的公司。保留所有权利。", "+{{count}} more": "还有 {{count}} 项", + "= {{count}} tokens": "= {{count}} 个 token", + "= {{count}}K tokens": "= {{count}}K 个 token", + "= {{count}}M tokens": "= {{count}}M 个 token", "| Based on": "| 基于", "0 means data is kept permanently": "0 表示永久保留数据", "0 means unlimited": "0 表示不限", @@ -796,6 +798,8 @@ "Changes are written to the settings draft on save.": "保存后会写入设置草稿。", "Changing...": "修改中...", "Channel": "渠道", + "Channel {{name}}": "渠道 {{name}}", + "Channel {{name}} model {{model}}": "渠道 {{name}} 模型 {{model}}", "Channel Affinity": "渠道亲和性", "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。", "Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中", @@ -2694,6 +2698,7 @@ "Mode": "模式", "model": "模型", "Model": "模型", + "Model {{model}}": "模型 {{model}}", "Model access": "模型接入", "Model Access": "模型访问", "Model access fabric": "模型访问网络", @@ -3887,6 +3892,7 @@ "Response path for task status": "任务状态的响应路径", "Response path for upstream task ID": "上游任务 ID 的响应路径", "Response Time": "响应时间", + "Response time: {{duration}}": "响应时间:{{duration}}", "Responses API Version": "响应 API 版本", "Responses APIs": "Responses API", "Restore defaults": "恢复默认", From 233a35c642b3a43493ff57d109c1135e9ec4ebf2 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 18 Jul 2026 00:34:40 +0800 Subject: [PATCH 04/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/model-ratio-visual-editor.tsx | 18 +++++++++++------- web/default/src/i18n/locales/ru.json | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index bd4417b..74b654a 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -128,6 +128,8 @@ type ModelRow = { isDraftNew?: boolean } +type BillingMode = 'per-token' | 'per-request' | 'tiered_expr' + const STORAGE_KEY = 'model-ratio-column-visibility' const hasValue = (value?: string) => value !== undefined && value !== '' @@ -229,7 +231,7 @@ const isBasePricingUnset = (row?: ModelRow) => !hasValue(row.price) && !hasValue(row.ratio)) -const getModelRowSignature = (row?: ModelRow) => { +const getModelRowSignature = (row?: ModelRow): string => { if (!row) return '' return JSON.stringify({ price: row.price || '', @@ -555,6 +557,13 @@ export const ModelRatioVisualEditor = memo( const handleEdit = useCallback( (model: ModelRow) => { const editableModel = model.draft ?? model.saved ?? model + let derivedBillingMode: BillingMode = 'per-token' + if (editableModel.billingMode === 'tiered_expr') { + derivedBillingMode = 'tiered_expr' + } else if (editableModel.price && editableModel.price !== '') { + derivedBillingMode = 'per-request' + } + setEditData({ name: editableModel.name, price: editableModel.price, @@ -565,12 +574,7 @@ export const ModelRatioVisualEditor = memo( imageRatio: editableModel.imageRatio, audioRatio: editableModel.audioRatio, audioCompletionRatio: editableModel.audioCompletionRatio, - billingMode: - editableModel.billingMode === 'tiered_expr' - ? 'tiered_expr' - : editableModel.price && editableModel.price !== '' - ? 'per-request' - : 'per-token', + billingMode: derivedBillingMode, billingExpr: editableModel.billingExpr, requestRuleExpr: editableModel.requestRuleExpr, }) diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 8ad37d7..f2db895 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -64,7 +64,7 @@ "{{routeContext}} groups must not be empty": "Группы {{routeContext}} не должны быть пустыми", "{{routeContext}} groups must not exceed 64 entries": "Группы {{routeContext}} не должны содержать более 64 элементов", "{{success}} succeeded, {{failed}} failed": "{{success}} успешно, {{failed}} с ошибкой", - "{{target}} test failed": "Тест {{target}} не выполнен", + "{{target}} test failed": "Тест {{target}} не пройден", "{{target}} test succeeded": "Тест {{target}} успешно выполнен", "{{title}} usage: {{percent}}%": "Использование {{title}}: {{percent}}%", "{{value}}ms": "{{value}} мс", From a408869ce0b4b7afb4fd19e6b94ed1ed6f70a774 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 18 Jul 2026 00:58:55 +0800 Subject: [PATCH 05/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/channel-test-dialog.tsx | 20 +++++++---- .../channels/lib/channel-actions.test.ts | 17 ++++++++++ .../features/channels/lib/channel-actions.ts | 34 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index b8a9d1c..58f037e 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -95,6 +95,7 @@ import { createChannelTestCachePatch, formatResponseTime, handleTestChannel, + selectLatestCompletedChannelTestResult, refreshChannelListsWithTestPatch, } from '../../lib' import { useChannels } from '../channels-provider' @@ -465,12 +466,19 @@ export function ChannelTestDialog({ const settled = await Promise.allSettled( batch.map((modelName) => testSingleModel(modelName, true, false)) ) - for (const result of settled) { - const resultValue = - result.status === 'fulfilled' ? result.value : undefined - if (typeof resultValue?.responseTime === 'number') { - latestResultWithResponse = resultValue - } + const batchResults = settled.map((result) => + result.status === 'fulfilled' ? result.value : undefined + ) + const batchLatestResult = + selectLatestCompletedChannelTestResult(batchResults) + if (batchLatestResult) { + latestResultWithResponse = + selectLatestCompletedChannelTestResult([ + latestResultWithResponse, + batchLatestResult, + ]) ?? latestResultWithResponse + } + for (const resultValue of batchResults) { recordBatchResult(resultValue) } diff --git a/web/default/src/features/channels/lib/channel-actions.test.ts b/web/default/src/features/channels/lib/channel-actions.test.ts index ffcd117..34b2085 100644 --- a/web/default/src/features/channels/lib/channel-actions.test.ts +++ b/web/default/src/features/channels/lib/channel-actions.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' import { createChannelTestCachePatch, + selectLatestCompletedChannelTestResult, updateChannelTestCache, } from './channel-actions' @@ -74,4 +75,20 @@ describe('channel test cache patch helpers', () => { assert.equal(nextData.data.items[1].response_time, 99) assert.equal(nextData.data.items[1].test_time, 123) }) + + test('selects the last completed batch result instead of the last input result', () => { + const slowFirstInput = { + responseTime: 1200, + completedAt: 2_000, + } + const fastSecondInput = { + responseTime: 200, + completedAt: 1_000, + } + + assert.equal( + selectLatestCompletedChannelTestResult([slowFirstInput, fastSecondInput]), + slowFirstInput + ) + }) }) diff --git a/web/default/src/features/channels/lib/channel-actions.ts b/web/default/src/features/channels/lib/channel-actions.ts index 70b464c..a69c227 100644 --- a/web/default/src/features/channels/lib/channel-actions.ts +++ b/web/default/src/features/channels/lib/channel-actions.ts @@ -62,6 +62,11 @@ export type ChannelTestCachePatch = { testTime: number } +export type ChannelTestCompletionResult = { + responseTime?: number + completedAt?: number +} + type ChannelListCache = GetChannelsResponse | SearchChannelsResponse function getChannelTestResponseTime( @@ -132,6 +137,35 @@ export function createChannelTestCachePatch( } } +export function selectLatestCompletedChannelTestResult< + T extends ChannelTestCompletionResult, +>(results: readonly (T | undefined)[]): T | undefined { + let latestResult: T | undefined + let latestCompletedAt = Number.NEGATIVE_INFINITY + + for (const result of results) { + if ( + typeof result?.responseTime !== 'number' || + !Number.isFinite(result.responseTime) + ) { + continue + } + + const completedAt = + typeof result.completedAt === 'number' && + Number.isFinite(result.completedAt) + ? result.completedAt + : Number.NEGATIVE_INFINITY + + if (!latestResult || completedAt >= latestCompletedAt) { + latestResult = result + latestCompletedAt = completedAt + } + } + + return latestResult +} + export function updateChannelTestCache( queryClient: QueryClient, channelId: number, From 8ae50e9bc5b0979a7a62958030a4045e798f3a87 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 18 Jul 2026 08:52:01 +0800 Subject: [PATCH 06/21] fix bugs --- common/redis.go | 109 ++++++- controller/discord.go | 6 +- controller/github.go | 14 +- controller/linuxdo.go | 13 +- controller/oauth.go | 18 ++ controller/oauth_test.go | 31 ++ controller/oidc.go | 6 +- controller/telegram.go | 6 +- controller/token.go | 4 +- controller/wechat.go | 13 +- go.mod | 7 +- go.sum | 4 + middleware/turnstile-check.go | 15 +- middleware/turnstile_check_test.go | 36 +++ model/checkin.go | 6 +- model/errors.go | 7 +- model/main.go | 23 +- model/main_migration_test.go | 14 + model/redemption.go | 5 +- model/redemption_search_test.go | 20 ++ model/subscription.go | 9 +- model/token.go | 175 +++++----- model/token_cache.go | 45 ++- model/usedata.go | 61 ++-- model/usedata_test.go | 48 +++ model/user.go | 165 ++++++---- model/user_cache.go | 68 ++-- model/user_oauth_binding.go | 10 + model/user_update_test.go | 303 ++++++++++++++++++ model/utils.go | 2 +- pkg/billingexpr/compile.go | 15 +- pkg/billingexpr/expr.md | 17 +- pkg/billingexpr/run.go | 49 ++- pkg/billingexpr/security_cache_test.go | 59 ++++ service/billing_session.go | 81 +++-- service/billing_session_test.go | 166 ++++++++++ service/funding_source.go | 9 +- service/pre_consume_quota.go | 8 +- service/quota.go | 4 +- service/task_billing_test.go | 6 +- setting/config/config.go | 45 +-- setting/config/config_test.go | 17 + tools/jsonwrapcheck/allowlist.txt | 6 - web/bun.lock | 3 + web/default/package.json | 1 + .../layout/components/chat-presets-item.tsx | 2 +- .../components/layout/components/footer.tsx | 5 +- web/default/src/features/auth/api.ts | 21 +- .../auth/lib/turnstile-request.test.ts | 36 +++ .../features/auth/lib/turnstile-request.ts | 25 ++ .../channels/components/channels-columns.tsx | 6 +- .../dialogs/channel-test-dialog.tsx | 6 +- .../components/data-table-row-actions.tsx | 2 +- .../components/dialogs/cc-switch-dialog.tsx | 2 +- .../dialogs/view-details-dialog.tsx | 8 +- .../playground/components/message-error.tsx | 6 +- .../hooks/use-stream-request.test.ts | 31 ++ .../playground/hooks/use-stream-request.ts | 60 ++-- .../features/pricing/lib/tier-expr.test.ts | 57 ++++ .../src/features/pricing/lib/tier-expr.ts | 60 +++- web/default/src/features/profile/api.ts | 17 +- .../dialogs/subscription-purchase-dialog.tsx | 9 +- .../ionet-deployment-settings-section.tsx | 6 +- .../models/tiered-pricing-support.tsx | 6 +- .../dialogs/audio-preview-dialog.tsx | 4 +- .../wallet/hooks/use-creem-payment.ts | 2 +- .../src/features/wallet/hooks/use-payment.ts | 6 +- .../wallet/hooks/use-waffo-payment.ts | 2 +- .../src/lib/handle-server-error.test.ts | 40 +++ web/default/src/lib/handle-server-error.ts | 35 +- web/default/src/lib/oauth.ts | 55 +++- web/default/src/lib/safe-redirect.test.ts | 42 +++ web/default/src/lib/safe-redirect.ts | 38 +++ web/default/src/routes/__root.tsx | 43 +-- web/default/src/routes/oauth/$provider.tsx | 24 +- 75 files changed, 1876 insertions(+), 509 deletions(-) create mode 100644 middleware/turnstile_check_test.go create mode 100644 model/redemption_search_test.go create mode 100644 model/usedata_test.go create mode 100644 pkg/billingexpr/security_cache_test.go create mode 100644 service/billing_session_test.go create mode 100644 web/default/src/features/auth/lib/turnstile-request.test.ts create mode 100644 web/default/src/features/auth/lib/turnstile-request.ts create mode 100644 web/default/src/features/playground/hooks/use-stream-request.test.ts create mode 100644 web/default/src/features/pricing/lib/tier-expr.test.ts create mode 100644 web/default/src/lib/handle-server-error.test.ts create mode 100644 web/default/src/lib/safe-redirect.test.ts create mode 100644 web/default/src/lib/safe-redirect.ts diff --git a/common/redis.go b/common/redis.go index f0a5cc8..442d11f 100644 --- a/common/redis.go +++ b/common/redis.go @@ -81,6 +81,8 @@ end return 0 `) +var errRedisCacheVersionChanged = errors.New("redis cache version changed") + func RedisSet(key string, value string, expiration time.Duration) error { if DebugEnabled { SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration)) @@ -130,10 +132,34 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { } ctx := context.Background() - data := make(map[string]interface{}) + data, err := redisHashData(obj) + if err != nil { + return err + } + + txn := RDB.TxPipeline() + txn.HSet(ctx, key, data) + + // 只有在 expiration 大于 0 时才设置过期时间 + if expiration > 0 { + txn.Expire(ctx, key, expiration) + } - // 使用反射遍历结构体字段 - v := reflect.ValueOf(obj).Elem() + _, err = txn.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to execute transaction: %w", err) + } + return nil +} + +func redisHashData(obj interface{}) (map[string]interface{}, error) { + value := reflect.ValueOf(obj) + if value.Kind() != reflect.Ptr || value.IsNil() || value.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("obj must be a non-nil pointer to a struct, got %T", obj) + } + + data := make(map[string]interface{}) + v := value.Elem() t := v.Type() for i := 0; i < v.NumField(); i++ { field := t.Field(i) @@ -162,20 +188,81 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { // 其他类型直接转换为字符串 data[field.Name] = fmt.Sprintf("%v", value.Interface()) } + return data, nil +} - txn := RDB.TxPipeline() - txn.HSet(ctx, key, data) +// RedisGetCacheVersion reads a version used to fence cache-aside refills. +// A missing version key is the initial version 0. +func RedisGetCacheVersion(key string) (int64, error) { + version, err := RDB.Get(context.Background(), key).Int64() + if errors.Is(err, redis.Nil) { + return 0, nil + } + return version, err +} - // 只有在 expiration 大于 0 时才设置过期时间 - if expiration > 0 { - txn.Expire(ctx, key, expiration) +// RedisHSetObjIfVersion stores a DB snapshot only while its cache generation +// is unchanged. WATCH makes the comparison and write safe across processes. +func RedisHSetObjIfVersion(key, versionKey string, expectedVersion int64, obj interface{}, expiration time.Duration) (bool, error) { + data, err := redisHashData(obj) + if err != nil { + return false, err } + return redisHSetIfVersion(key, versionKey, expectedVersion, data, expiration) +} - _, err := txn.Exec(ctx) +// RedisHSetFieldIfVersion is the field-level variant used by narrow cache +// refills that must not overwrite unrelated hash fields. +func RedisHSetFieldIfVersion(key, versionKey string, expectedVersion int64, field string, value interface{}, expiration time.Duration) (bool, error) { + return redisHSetIfVersion(key, versionKey, expectedVersion, map[string]interface{}{field: value}, expiration) +} + +func redisHSetIfVersion(key, versionKey string, expectedVersion int64, data map[string]interface{}, expiration time.Duration) (bool, error) { + stored := false + ctx := context.Background() + err := RDB.Watch(ctx, func(tx *redis.Tx) error { + version, err := tx.Get(ctx, versionKey).Int64() + if errors.Is(err, redis.Nil) { + version, err = 0, nil + } + if err != nil { + return err + } + if version != expectedVersion { + return errRedisCacheVersionChanged + } + + _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.HSet(ctx, key, data) + if expiration > 0 { + pipe.Expire(ctx, key, expiration) + } + return nil + }) + if err == nil { + stored = true + } + return err + }, versionKey) + if errors.Is(err, errRedisCacheVersionChanged) || errors.Is(err, redis.TxFailedErr) { + return false, nil + } if err != nil { - return fmt.Errorf("failed to execute transaction: %w", err) + return false, err } - return nil + return stored, nil +} + +// RedisInvalidateVersionedHash advances the shared generation and removes the +// cached hash atomically, preventing older DB snapshots from refilling it. +func RedisInvalidateVersionedHash(key, versionKey string) error { + ctx := context.Background() + _, err := RDB.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.Incr(ctx, versionKey) + pipe.Del(ctx, key) + return nil + }) + return err } func RedisHGetObj(key string, obj interface{}) error { diff --git a/controller/discord.go b/controller/discord.go index d6d9033..afe0ca1 100644 --- a/controller/discord.go +++ b/controller/discord.go @@ -132,11 +132,7 @@ func DiscordOAuth(c *gin.Context) { } if model.IsDiscordIdAlreadyTaken(user.DiscordId) { err := user.FillUserByDiscordId() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) + if handleOAuthUserLookupError(c, err) { return } } else { diff --git a/controller/github.go b/controller/github.go index 7b1d8ef..cd99f7d 100644 --- a/controller/github.go +++ b/controller/github.go @@ -115,19 +115,7 @@ func GitHubOAuth(c *gin.Context) { if model.IsGitHubIdAlreadyTaken(user.GitHubId) { // FillUserByGitHubId is scoped err := user.FillUserByGitHubId() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - // if user.Id == 0 , user has been deleted - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if handleOAuthUserLookupError(c, err) { return } } else { diff --git a/controller/linuxdo.go b/controller/linuxdo.go index b49d29c..bdc1e2a 100644 --- a/controller/linuxdo.go +++ b/controller/linuxdo.go @@ -209,18 +209,7 @@ func LinuxdoOAuth(c *gin.Context) { // Check if user exists if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) { err := user.FillUserByLinuxDOId() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if handleOAuthUserLookupError(c, err) { return } } else { diff --git a/controller/oauth.go b/controller/oauth.go index f4ce8b8..4985e74 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -35,6 +35,18 @@ func oauthProviderUserUpdateField(provider oauth.Provider) (model.UserUpdateFiel } } +func handleOAuthUserLookupError(c *gin.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, model.ErrUserDeleted) { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "用户已注销"}) + } else { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + } + return true +} + // GenerateOAuthCode generates a state code for OAuth CSRF protection func GenerateOAuthCode(c *gin.Context) { session := sessions.Default(c) @@ -230,6 +242,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o if provider.IsUserIDTaken(oauthUser.ProviderUserID) { err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID) if err != nil { + if errors.Is(err, model.ErrUserDeleted) { + return nil, &OAuthUserDeletedError{} + } return nil, err } // Check if user has been deleted @@ -244,6 +259,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o if provider.IsUserIDTaken(legacyID) { err := provider.FillUserByProviderID(user, legacyID) if err != nil { + if errors.Is(err, model.ErrUserDeleted) { + return nil, &OAuthUserDeletedError{} + } return nil, err } if user.Id != 0 { diff --git a/controller/oauth_test.go b/controller/oauth_test.go index fd79911..c03fbcc 100644 --- a/controller/oauth_test.go +++ b/controller/oauth_test.go @@ -1,12 +1,32 @@ package controller import ( + "context" + "errors" "testing" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/oauth" + "github.com/gin-gonic/gin" ) +type deletedUserOAuthProvider struct{} + +func (*deletedUserOAuthProvider) GetName() string { return "deleted-test" } +func (*deletedUserOAuthProvider) IsEnabled() bool { return true } +func (*deletedUserOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) { + return nil, nil +} +func (*deletedUserOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) { + return nil, nil +} +func (*deletedUserOAuthProvider) IsUserIDTaken(string) bool { return true } +func (*deletedUserOAuthProvider) FillUserByProviderID(*model.User, string) error { + return model.ErrUserDeleted +} +func (*deletedUserOAuthProvider) SetProviderUserID(*model.User, string) {} +func (*deletedUserOAuthProvider) GetProviderPrefix() string { return "deleted_" } + func TestOAuthProviderUserUpdateField(t *testing.T) { tests := []struct { name string @@ -31,3 +51,14 @@ func TestOAuthProviderUserUpdateField(t *testing.T) { }) } } + +func TestFindOrCreateOAuthUserMapsDeletedUserDomainError(t *testing.T) { + _, err := findOrCreateOAuthUser(nil, &deletedUserOAuthProvider{}, &oauth.OAuthUser{ + ProviderUserID: "deleted-user-id", + }, nil) + + var deletedErr *OAuthUserDeletedError + if !errors.As(err, &deletedErr) { + t.Fatalf("expected OAuthUserDeletedError, got %v", err) + } +} diff --git a/controller/oidc.go b/controller/oidc.go index c4cabac..5b6a1a8 100644 --- a/controller/oidc.go +++ b/controller/oidc.go @@ -134,11 +134,7 @@ func OidcAuth(c *gin.Context) { } if model.IsOidcIdAlreadyTaken(user.OidcId) { err := user.FillUserByOidcId() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) + if handleOAuthUserLookupError(c, err) { return } } else { diff --git a/controller/telegram.go b/controller/telegram.go index db66fb6..8145ab1 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -92,11 +92,7 @@ func TelegramLogin(c *gin.Context) { telegramId := params["id"][0] user := model.User{TelegramId: telegramId} - if err := user.FillUserByTelegramId(); err != nil { - c.JSON(200, gin.H{ - "message": err.Error(), - "success": false, - }) + if err := user.FillUserByTelegramId(); handleOAuthUserLookupError(c, err) { return } setupLogin(&user, c) diff --git a/controller/token.go b/controller/token.go index 19859be..0470897 100644 --- a/controller/token.go +++ b/controller/token.go @@ -185,7 +185,7 @@ func AddToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } - maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) + maxQuotaValue := int64(1000000000 * common.QuotaPerUnit) if token.RemainQuota > maxQuotaValue { common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return @@ -269,7 +269,7 @@ func UpdateToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative) return } - maxQuotaValue := int((1000000000 * common.QuotaPerUnit)) + maxQuotaValue := int64(1000000000 * common.QuotaPerUnit) if token.RemainQuota > maxQuotaValue { common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue}) return diff --git a/controller/wechat.go b/controller/wechat.go index f20662f..580711a 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -75,18 +75,7 @@ func WeChatAuth(c *gin.Context) { } if model.IsWeChatIdAlreadyTaken(wechatId) { err := user.FillUserByWeChatId() - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if handleOAuthUserLookupError(c, err) { return } } else { diff --git a/go.mod b/go.mod index f33a45d..04ad5da 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,12 @@ require ( gorm.io/gorm v1.25.2 ) -require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 +require ( + github.com/alicebob/miniredis/v2 v2.35.0 + github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 +) + +require github.com/yuin/gopher-lua v1.1.1 // indirect require ( github.com/DmitriyVTitov/size v1.5.0 // indirect diff --git a/go.sum b/go.sum index ff5e8e6..d32aaaa 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW5 github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M= github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs= @@ -318,6 +320,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go index 4de2385..8fbb490 100644 --- a/middleware/turnstile-check.go +++ b/middleware/turnstile-check.go @@ -1,9 +1,9 @@ package middleware import ( - "encoding/json" "net/http" "net/url" + "strings" "github.com/MAX-API-Next/MAX-API/common" "github.com/gin-contrib/sessions" @@ -14,6 +14,15 @@ type turnstileCheckResponse struct { Success bool `json:"success"` } +const turnstileTokenHeader = "X-Turnstile-Token" + +func getTurnstileToken(c *gin.Context) string { + if token := strings.TrimSpace(c.GetHeader(turnstileTokenHeader)); token != "" { + return token + } + return strings.TrimSpace(c.Query("turnstile")) +} + func TurnstileCheck() gin.HandlerFunc { return func(c *gin.Context) { if common.TurnstileCheckEnabled { @@ -23,7 +32,7 @@ func TurnstileCheck() gin.HandlerFunc { c.Next() return } - response := c.Query("turnstile") + response := getTurnstileToken(c) if response == "" { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -48,7 +57,7 @@ func TurnstileCheck() gin.HandlerFunc { } defer rawRes.Body.Close() var res turnstileCheckResponse - err = json.NewDecoder(rawRes.Body).Decode(&res) + err = common.DecodeJson(rawRes.Body, &res) if err != nil { common.SysLog(err.Error()) c.JSON(http.StatusOK, gin.H{ diff --git a/middleware/turnstile_check_test.go b/middleware/turnstile_check_test.go new file mode 100644 index 0000000..34b2b81 --- /dev/null +++ b/middleware/turnstile_check_test.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestGetTurnstileTokenPrefersHeaderWithQueryFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + header string + query string + want string + }{ + {name: "header", header: "header-token", query: "query-token", want: "header-token"}, + {name: "query fallback", query: "query-token", want: "query-token"}, + {name: "trim whitespace", header: " header-token ", want: "header-token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/?turnstile="+tt.query, nil) + if tt.header != "" { + req.Header.Set(turnstileTokenHeader, tt.header) + } + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = req + assert.Equal(t, tt.want, getTurnstileToken(ctx)) + }) + } +} diff --git a/model/checkin.go b/model/checkin.go index f229ea9..ad4041c 100644 --- a/model/checkin.go +++ b/model/checkin.go @@ -113,10 +113,8 @@ func userCheckinWithTransaction(checkin *Checkin, userId int, quotaAwarded int) return nil, err } - // 事务成功后,异步更新缓存 - go func() { - _ = cacheIncrUserQuota(userId, int64(quotaAwarded)) - }() + // 事务成功后同步失效缓存,下一次读取以数据库结果回填。 + invalidateUserQuotaCache(userId) return checkin, nil } diff --git a/model/errors.go b/model/errors.go index 7f53a03..63f8e1d 100644 --- a/model/errors.go +++ b/model/errors.go @@ -4,7 +4,12 @@ import "errors" // Common errors var ( - ErrDatabase = errors.New("database error") + ErrDatabase = errors.New("database error") + ErrUserNotFound = errors.New("user not found") + ErrUserDeleted = errors.New("user deleted") + ErrTokenNotFound = errors.New("token not found") + ErrUserQuotaInsufficient = errors.New("user quota is not enough") + ErrTokenQuotaInsufficient = errors.New("token quota is not enough") ) // User auth errors diff --git a/model/main.go b/model/main.go index 929895d..540186a 100644 --- a/model/main.go +++ b/model/main.go @@ -282,6 +282,9 @@ func migrateDB() error { if err := migrateUserQuotaColumnsToBigInt(); err != nil { return err } + if err := migrateTokenQuotaColumnsToBigInt(); err != nil { + return err + } err := DB.AutoMigrate( &Channel{}, @@ -338,6 +341,9 @@ func migrateDBFast() error { if err := migrateUserQuotaColumnsToBigInt(); err != nil { return err } + if err := migrateTokenQuotaColumnsToBigInt(); err != nil { + return err + } var wg sync.WaitGroup @@ -672,15 +678,22 @@ func migrateTokenModelLimitsToText() error { // a noticeable time, so operators with large deployments should schedule this // upgrade off-peak or run an equivalent online-DDL migration before booting. func migrateUserQuotaColumnsToBigInt() error { - if DB == nil || common.UsingSQLite || !DB.Migrator().HasTable(&User{}) { + return migrateQuotaColumnsToBigInt("users", []string{"quota", "used_quota", "aff_quota", "aff_history"}) +} + +// migrateTokenQuotaColumnsToBigInt keeps token accounting columns aligned +// with User quota columns on MySQL and PostgreSQL. +func migrateTokenQuotaColumnsToBigInt() error { + return migrateQuotaColumnsToBigInt("tokens", []string{"remain_quota", "used_quota"}) +} + +func migrateQuotaColumnsToBigInt(tableName string, columnNames []string) error { + if DB == nil || common.UsingSQLite || !DB.Migrator().HasTable(tableName) { return nil } - tableName := "users" - columnNames := []string{"quota", "used_quota", "aff_quota", "aff_history"} - for _, columnName := range columnNames { - if !DB.Migrator().HasColumn(&User{}, columnName) { + if !DB.Migrator().HasColumn(tableName, columnName) { continue } diff --git a/model/main_migration_test.go b/model/main_migration_test.go index a9a441f..ca32b6e 100644 --- a/model/main_migration_test.go +++ b/model/main_migration_test.go @@ -2,9 +2,23 @@ package model import ( "database/sql" + "reflect" "testing" ) +func TestTokenQuotaFieldsUseInt64(t *testing.T) { + tokenType := reflect.TypeOf(Token{}) + for _, fieldName := range []string{"RemainQuota", "UsedQuota"} { + field, ok := tokenType.FieldByName(fieldName) + if !ok { + t.Fatalf("Token.%s is missing", fieldName) + } + if field.Type.Kind() != reflect.Int64 { + t.Fatalf("Token.%s kind = %s, want int64", fieldName, field.Type.Kind()) + } + } +} + func TestIsZeroColumnDefault(t *testing.T) { tests := []struct { name string diff --git a/model/redemption.go b/model/redemption.go index a56f0a2..76d518f 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -77,10 +77,11 @@ func SearchRedemptions(keyword string, status string, startIdx int, num int) (re status = strings.TrimSpace(status) if keyword != "" { + keywordPattern := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(keyword) + "%" if id, err := strconv.Atoi(keyword); err == nil { - query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + query = query.Where("id = ? OR name LIKE ? ESCAPE '!'", id, keywordPattern) } else { - query = query.Where("name LIKE ?", keyword+"%") + query = query.Where("name LIKE ? ESCAPE '!'", keywordPattern) } } diff --git a/model/redemption_search_test.go b/model/redemption_search_test.go new file mode 100644 index 0000000..e609194 --- /dev/null +++ b/model/redemption_search_test.go @@ -0,0 +1,20 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSearchRedemptionsTreatsUnderscoreAsLiteral(t *testing.T) { + truncateTables(t) + require.NoError(t, DB.Create(&Redemption{Name: "promo_code", Key: "underscore-key"}).Error) + require.NoError(t, DB.Create(&Redemption{Name: "promoXcode", Key: "wildcard-key"}).Error) + + rows, total, err := SearchRedemptions("promo_", "", 0, 10) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, rows, 1) + assert.Equal(t, "promo_code", rows[0].Name) +} diff --git a/model/subscription.go b/model/subscription.go index c2ea5b6..e2a8aaa 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -835,13 +835,8 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error { return err } - if chargedQuota > 0 { - if err := cacheDecrUserQuota(userId, int64(chargedQuota)); err != nil { - common.SysLog("failed to decrease user quota cache after subscription balance purchase: " + err.Error()) - } - } - if upgradeGroup != "" { - _ = UpdateUserGroupCache(userId, upgradeGroup) + if chargedQuota > 0 || upgradeGroup != "" { + invalidateUserQuotaCache(userId) } msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota) RecordLog(userId, LogTypeTopup, msg) diff --git a/model/token.go b/model/token.go index 7f7b249..665d36b 100644 --- a/model/token.go +++ b/model/token.go @@ -20,12 +20,12 @@ type Token struct { CreatedTime int64 `json:"created_time" gorm:"bigint"` AccessedTime int64 `json:"accessed_time" gorm:"bigint"` ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired - RemainQuota int `json:"remain_quota" gorm:"default:0"` + RemainQuota int64 `json:"remain_quota" gorm:"type:bigint;default:0"` UnlimitedQuota bool `json:"unlimited_quota"` ModelLimitsEnabled bool `json:"model_limits_enabled"` ModelLimits string `json:"model_limits" gorm:"type:text"` AllowIps *string `json:"allow_ips" gorm:"default:''"` - UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota + UsedQuota int64 `json:"used_quota" gorm:"type:bigint;default:0"` // used quota Group string `json:"group" gorm:"default:''"` CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 DeletedAt gorm.DeletedAt `gorm:"index"` @@ -270,27 +270,10 @@ func GetTokenById(id int) (*Token, error) { token := Token{Id: id} var err error = nil err = DB.First(&token, "id = ?", id).Error - if shouldUpdateRedis(true, err) { - gopool.Go(func() { - if err := cacheSetToken(token); err != nil { - common.SysLog("failed to update user status cache: " + err.Error()) - } - }) - } return &token, err } func GetTokenByKey(key string, fromDB bool) (token *Token, err error) { - defer func() { - // Update Redis cache asynchronously on successful DB read - if shouldUpdateRedis(fromDB, err) && token != nil { - gopool.Go(func() { - if err := cacheSetToken(*token); err != nil { - common.SysLog("failed to update user status cache: " + err.Error()) - } - }) - } - }() if !fromDB && common.RedisEnabled { // Try Redis first token, err := cacheGetTokenByKey(key) @@ -299,8 +282,21 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) { } // Don't return error - fall through to DB } - fromDB = true + var cacheVersion int64 + cacheVersionValid := false + if common.RedisEnabled { + cacheVersion, err = common.RedisGetCacheVersion(getTokenCacheVersionKey(key)) + cacheVersionValid = err == nil + } err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error + if err == nil && token != nil && cacheVersionValid { + tokenSnapshot := *token + gopool.Go(func() { + if err := cacheSetTokenIfVersion(tokenSnapshot, cacheVersion); err != nil { + common.SysLog("failed to update token cache: " + err.Error()) + } + }) + } return token, err } @@ -312,48 +308,34 @@ func (token *Token) Insert() error { // Update Make sure your token's fields is completed, because this will update non-zero values func (token *Token) Update() (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()) - } - }) - } - }() err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error + if err == nil && common.RedisEnabled { + if cacheErr := invalidateTokenCache(token.Key); cacheErr != nil { + common.SysLog("failed to invalidate token cache: " + cacheErr.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 + err = DB.Model(token).Select("accessed_time", "status").Updates(token).Error + if err == nil && common.RedisEnabled { + if cacheErr := invalidateTokenCache(token.Key); cacheErr != nil { + common.SysLog("failed to invalidate token cache: " + cacheErr.Error()) + } + } + return err } func (token *Token) Delete() (err error) { - defer func() { - if shouldUpdateRedis(true, err) { - gopool.Go(func() { - err := cacheDeleteToken(token.Key) - if err != nil { - common.SysLog("failed to delete token cache: " + err.Error()) - } - }) - } - }() err = DB.Delete(token).Error + if err == nil && common.RedisEnabled { + if cacheErr := invalidateTokenCache(token.Key); cacheErr != nil { + common.SysLog("failed to invalidate token cache: " + cacheErr.Error()) + } + } return err } @@ -404,60 +386,79 @@ func IncreaseTokenQuota(tokenId int, key string, quota int) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } - if common.RedisEnabled { - gopool.Go(func() { - err := cacheIncrTokenQuota(key, int64(quota)) - if err != nil { - common.SysLog("failed to increase token quota: " + err.Error()) - } - }) - } - if common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeTokenQuota, tokenId, int64(quota)) + if quota == 0 { return nil } - return increaseTokenQuota(tokenId, quota) + if err := increaseTokenQuota(tokenId, int64(quota)); err != nil { + return err + } + invalidateTokenQuotaCache(key) + return nil } -func increaseTokenQuota(id int, quota int) (err error) { - err = DB.Model(&Token{}).Where("id = ?", id).Updates( +func increaseTokenQuota(id int, quota int64) (err error) { + if quota == 0 { + return nil + } + result := DB.Model(&Token{}).Where("id = ?", id).Updates( map[string]interface{}{ "remain_quota": gorm.Expr("remain_quota + ?", quota), "used_quota": gorm.Expr("used_quota - ?", quota), "accessed_time": common.GetTimestamp(), }, - ).Error - return err + ) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fmt.Errorf("%w: id=%d", ErrTokenNotFound, id) + } + return nil } func DecreaseTokenQuota(id int, key string, quota int) (err error) { if quota < 0 { return errors.New("quota 不能为负数!") } - if common.RedisEnabled { - gopool.Go(func() { - err := cacheDecrTokenQuota(key, int64(quota)) - if err != nil { - common.SysLog("failed to decrease token quota: " + err.Error()) - } - }) + if quota == 0 { + return nil } - if common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeTokenQuota, id, int64(-quota)) + if err := decreaseTokenQuota(id, int64(quota)); err != nil { + return err + } + invalidateTokenQuotaCache(key) + return nil +} + +func decreaseTokenQuota(id int, quota int64) (err error) { + if quota == 0 { return nil } - return decreaseTokenQuota(id, quota) + result := DB.Model(&Token{}). + Where("id = ? AND (unlimited_quota = ? OR remain_quota >= ?)", id, true, quota). + Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota - ?", quota), + "used_quota": gorm.Expr("used_quota + ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fmt.Errorf("%w: id=%d, need=%d", ErrTokenQuotaInsufficient, id, quota) + } + return nil } -func decreaseTokenQuota(id int, quota int) (err error) { - err = DB.Model(&Token{}).Where("id = ?", id).Updates( - map[string]interface{}{ - "remain_quota": gorm.Expr("remain_quota - ?", quota), - "used_quota": gorm.Expr("used_quota + ?", quota), - "accessed_time": common.GetTimestamp(), - }, - ).Error - return err +func invalidateTokenQuotaCache(key string) { + if !common.RedisEnabled || key == "" { + return + } + if err := invalidateTokenCache(key); err != nil { + common.SysLog("failed to invalidate token quota cache: " + err.Error()) + } } // CountUserTokens returns total number of tokens for the given user, used for pagination @@ -493,7 +494,7 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) { if common.RedisEnabled { gopool.Go(func() { for _, t := range tokens { - _ = cacheDeleteToken(t.Key) + _ = invalidateTokenCache(t.Key) } }) } @@ -531,7 +532,7 @@ func InvalidateUserTokensCache(userId int) error { if t.Key == "" { continue } - if err := cacheDeleteToken(t.Key); err != nil && firstErr == nil { + if err := invalidateTokenCache(t.Key); err != nil && firstErr == nil { firstErr = err } } diff --git a/model/token_cache.go b/model/token_cache.go index 50188a3..a88c08b 100644 --- a/model/token_cache.go +++ b/model/token_cache.go @@ -5,39 +5,31 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" - "github.com/MAX-API-Next/MAX-API/constant" ) -func cacheSetToken(token Token) error { - key := common.GenerateHMAC(token.Key) - token.Clean() - err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(common.RedisKeyCacheSeconds())*time.Second) - if err != nil { - return err - } - return nil +func getTokenCacheKey(key string) string { + return fmt.Sprintf("token:%s", common.GenerateHMAC(key)) } -func cacheDeleteToken(key string) error { - key = common.GenerateHMAC(key) - err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) - if err != nil { - return err - } - return nil +func getTokenCacheVersionKey(key string) string { + return fmt.Sprintf("cache-version:token:%s", common.GenerateHMAC(key)) } -func cacheIncrTokenQuota(key string, increment int64) error { - key = common.GenerateHMAC(key) - err := common.RedisHIncrBy(fmt.Sprintf("token:%s", key), constant.TokenFiledRemainQuota, increment) - if err != nil { - return err - } - return nil +func cacheSetTokenIfVersion(token Token, version int64) error { + key := token.Key + token.Clean() + _, err := common.RedisHSetObjIfVersion( + getTokenCacheKey(key), + getTokenCacheVersionKey(key), + version, + &token, + time.Duration(common.RedisKeyCacheSeconds())*time.Second, + ) + return err } -func cacheDecrTokenQuota(key string, decrement int64) error { - return cacheIncrTokenQuota(key, -decrement) +func invalidateTokenCache(key string) error { + return common.RedisInvalidateVersionedHash(getTokenCacheKey(key), getTokenCacheVersionKey(key)) } func cacheSetTokenField(key string, field string, value string) error { @@ -51,12 +43,11 @@ func cacheSetTokenField(key string, field string, value string) error { // CacheGetTokenByKey 从缓存中获取 token,如果缓存中不存在,则从数据库中获取 func cacheGetTokenByKey(key string) (*Token, error) { - hmacKey := common.GenerateHMAC(key) if !common.RedisEnabled { return nil, fmt.Errorf("redis is not enabled") } var token Token - err := common.RedisHGetObj(fmt.Sprintf("token:%s", hmacKey), &token) + err := common.RedisHGetObj(getTokenCacheKey(key), &token) if err != nil { return nil, err } diff --git a/model/usedata.go b/model/usedata.go index 7b534aa..a757c70 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -1,6 +1,7 @@ package model import ( + "errors" "fmt" "sync" "time" @@ -50,6 +51,7 @@ func UpdateQuotaData() { var CacheQuotaData = make(map[string]*QuotaData) var CacheQuotaDataLock = sync.Mutex{} +var cacheQuotaDataSaveLock sync.Mutex func logQuotaDataCache(quotaData *QuotaData) { key := fmt.Sprintf("%d\x00%s\x00%s\x00%d\x00%s\x00%d\x00%d\x00%s", @@ -98,34 +100,54 @@ func LogQuotaData(params QuotaDataLogParams) { } func SaveQuotaDataCache() { + cacheQuotaDataSaveLock.Lock() + defer cacheQuotaDataSaveLock.Unlock() + CacheQuotaDataLock.Lock() - defer CacheQuotaDataLock.Unlock() - size := len(CacheQuotaData) + snapshot := CacheQuotaData + CacheQuotaData = make(map[string]*QuotaData) + CacheQuotaDataLock.Unlock() + + size := len(snapshot) + failed := make([]*QuotaData, 0) // 如果缓存中有数据,就保存到数据库中 // 1. 先查询数据库中是否有数据 // 2. 如果有数据,就更新数据 // 3. 如果没有数据,就插入数据 - for _, quotaData := range CacheQuotaData { - quotaDataDB := &QuotaData{} - DB.Table("quota_data"). - Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", - quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). - First(quotaDataDB) - if quotaDataDB.Id > 0 { - //quotaDataDB.Count += quotaData.Count - //quotaDataDB.Quota += quotaData.Quota - //DB.Table("quota_data").Save(quotaDataDB) - increaseQuotaData(quotaData) - } else { - DB.Table("quota_data").Create(quotaData) + for _, quotaData := range snapshot { + if err := saveQuotaData(quotaData); err != nil { + common.SysLog(fmt.Sprintf("saveQuotaData error: %s", err)) + failed = append(failed, quotaData) } } - CacheQuotaData = make(map[string]*QuotaData) - common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) + + if len(failed) > 0 { + CacheQuotaDataLock.Lock() + for _, quotaData := range failed { + logQuotaDataCache(quotaData) + } + CacheQuotaDataLock.Unlock() + } + common.SysLog(fmt.Sprintf("保存数据看板数据完成,成功%d条,待重试%d条", size-len(failed), len(failed))) } -func increaseQuotaData(quotaData *QuotaData) { +func saveQuotaData(quotaData *QuotaData) error { + quotaDataDB := &QuotaData{} err := DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + First(quotaDataDB).Error + if err == nil { + return increaseQuotaData(quotaData) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return DB.Table("quota_data").Create(quotaData).Error +} + +func increaseQuotaData(quotaData *QuotaData) error { + return DB.Table("quota_data"). Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). Updates(map[string]interface{}{ @@ -133,9 +155,6 @@ func increaseQuotaData(quotaData *QuotaData) { "quota": gorm.Expr("quota + ?", quotaData.Quota), "token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed), }).Error - if err != nil { - common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) - } } func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { diff --git a/model/usedata_test.go b/model/usedata_test.go new file mode 100644 index 0000000..c19c5ce --- /dev/null +++ b/model/usedata_test.go @@ -0,0 +1,48 @@ +package model + +import ( + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestSaveQuotaDataCacheRequeuesFailedSnapshot(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + oldDB := DB + DB = db + CacheQuotaDataLock.Lock() + oldCache := CacheQuotaData + CacheQuotaData = make(map[string]*QuotaData) + CacheQuotaDataLock.Unlock() + t.Cleanup(func() { + DB = oldDB + CacheQuotaDataLock.Lock() + CacheQuotaData = oldCache + CacheQuotaDataLock.Unlock() + }) + + LogQuotaData(QuotaDataLogParams{ + UserID: 1, Username: "quota-user", ModelName: "test-model", Quota: 7, CreatedAt: 3601, TokenUsed: 3, + }) + SaveQuotaDataCache() + + CacheQuotaDataLock.Lock() + assert.Len(t, CacheQuotaData, 1) + CacheQuotaDataLock.Unlock() + + require.NoError(t, db.AutoMigrate(&QuotaData{})) + SaveQuotaDataCache() + + CacheQuotaDataLock.Lock() + assert.Empty(t, CacheQuotaData) + CacheQuotaDataLock.Unlock() + var got QuotaData + require.NoError(t, db.First(&got).Error) + assert.Equal(t, 1, got.Count) + assert.Equal(t, 7, got.Quota) + assert.Equal(t, 3, got.TokenUsed) +} diff --git a/model/user.go b/model/user.go index c3b7097..a735cca 100644 --- a/model/user.go +++ b/model/user.go @@ -579,14 +579,18 @@ func HardDeleteUserById(id int) error { } func inviteUser(inviterId int) (err error) { - user, err := GetUserById(inviterId, true) - if err != nil { - return err + result := DB.Model(&User{}).Where("id = ?", inviterId).Updates(map[string]interface{}{ + "aff_count": gorm.Expr("aff_count + 1"), + "aff_quota": gorm.Expr("aff_quota + ?", common.QuotaForInviter), + "aff_history": gorm.Expr("aff_history + ?", common.QuotaForInviter), + }) + if result.Error != nil { + return result.Error } - user.AffCount++ - user.AffQuota += int64(common.QuotaForInviter) - user.AffHistoryQuota += int64(common.QuotaForInviter) - return DB.Save(user).Error + if result.RowsAffected != 1 { + return fmt.Errorf("%w: id=%d", ErrUserNotFound, inviterId) + } + return nil } func (user *User) TransferAffQuotaToQuota(quota int64) error { @@ -1153,24 +1157,21 @@ func (user *User) FillUserById() error { if user.Id == 0 { return errors.New("id 为空!") } - DB.Where(User{Id: user.Id}).First(user) - return nil + return DB.Where(User{Id: user.Id}).First(user).Error } func (user *User) FillUserByEmail() error { if user.Email == "" { return errors.New("email 为空!") } - DB.Where(User{Email: user.Email}).First(user) - return nil + return DB.Where(User{Email: user.Email}).First(user).Error } func (user *User) FillUserByGitHubId() error { if user.GitHubId == "" { return errors.New("GitHub id 为空!") } - DB.Where(User{GitHubId: user.GitHubId}).First(user) - return nil + return fillOAuthUser(user, "github_id", user.GitHubId) } // UpdateGitHubId updates the user's GitHub ID (used for migration from login to numeric ID) @@ -1185,35 +1186,52 @@ func (user *User) FillUserByDiscordId() error { if user.DiscordId == "" { return errors.New("discord id 为空!") } - DB.Where(User{DiscordId: user.DiscordId}).First(user) - return nil + return fillOAuthUser(user, "discord_id", user.DiscordId) } func (user *User) FillUserByOidcId() error { if user.OidcId == "" { return errors.New("oidc id 为空!") } - DB.Where(User{OidcId: user.OidcId}).First(user) - return nil + return fillOAuthUser(user, "oidc_id", user.OidcId) } func (user *User) FillUserByWeChatId() error { if user.WeChatId == "" { return errors.New("WeChat id 为空!") } - DB.Where(User{WeChatId: user.WeChatId}).First(user) - return nil + return fillOAuthUser(user, "wechat_id", user.WeChatId) } func (user *User) FillUserByTelegramId() error { if user.TelegramId == "" { return errors.New("Telegram id 为空!") } - err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error + err := fillOAuthUser(user, "telegram_id", user.TelegramId) + if errors.Is(err, ErrUserDeleted) { + return err + } if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("该 Telegram 账户未绑定") } - return nil + return err +} + +func fillOAuthUser(user *User, field string, value interface{}) error { + err := DB.Where(field+" = ?", value).First(user).Error + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var deleted User + unscopedErr := DB.Unscoped().Where(field+" = ?", value).First(&deleted).Error + if unscopedErr == nil && deleted.DeletedAt.Valid { + return ErrUserDeleted + } + if unscopedErr != nil && !errors.Is(unscopedErr, gorm.ErrRecordNotFound) { + return unscopedErr + } + return err } func IsEmailAlreadyTaken(email string) bool { @@ -1253,7 +1271,7 @@ func IsDiscordIdAlreadyTaken(discordId string) bool { } func IsOidcIdAlreadyTaken(oidcId string) bool { - return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 + return DB.Unscoped().Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 } func IsTelegramIdAlreadyTaken(telegramId string) bool { @@ -1337,16 +1355,6 @@ func ValidateAccessToken(token string) (*User, error) { // GetUserQuota gets quota from Redis first, falls back to DB if needed func GetUserQuota(id int, fromDB bool) (quota int64, err error) { - defer func() { - // Update Redis cache asynchronously on successful DB read - if shouldUpdateRedis(fromDB, err) { - gopool.Go(func() { - if err := updateUserQuotaCache(id, quota); err != nil { - common.SysLog("failed to update user quota cache: " + err.Error()) - } - }) - } - }() if !fromDB && common.RedisEnabled { quota, err := getUserQuotaCache(id) if err == nil { @@ -1354,11 +1362,24 @@ func GetUserQuota(id int, fromDB bool) (quota int64, err error) { } // Don't return error - fall through to DB } - fromDB = true + var cacheVersion int64 + cacheVersionValid := false + if common.RedisEnabled { + cacheVersion, err = common.RedisGetCacheVersion(getUserCacheVersionKey(id)) + cacheVersionValid = err == nil + } err = DB.Model(&User{}).Where("id = ?", id).Select("quota").Find("a).Error if err != nil { return 0, err } + if cacheVersionValid { + quotaSnapshot := quota + gopool.Go(func() { + if err := updateUserQuotaCacheIfVersion(id, quotaSnapshot, cacheVersion); err != nil { + common.SysLog("failed to update user quota cache: " + err.Error()) + } + }) + } return quota, nil } @@ -1443,56 +1464,75 @@ type quotaDeltaInteger interface { ~int | ~int64 } -func IncreaseUserQuota[T quotaDeltaInteger](id int, quota T, db bool) (err error) { +// The final boolean is retained for caller compatibility. Accounting mutations +// always write the database directly so conditional deductions remain atomic. +func IncreaseUserQuota[T quotaDeltaInteger](id int, quota T, _ bool) (err error) { delta := int64(quota) if delta < 0 { return errors.New("quota 不能为负数!") } - gopool.Go(func() { - err := cacheIncrUserQuota(id, delta) - if err != nil { - common.SysLog("failed to increase user quota: " + err.Error()) - } - }) - if !db && common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeUserQuota, id, delta) + if delta == 0 { return nil } - return increaseUserQuota(id, delta) + if err := increaseUserQuota(id, delta); err != nil { + return err + } + invalidateUserQuotaCache(id) + return nil } func increaseUserQuota(id int, quota int64) (err error) { - err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error - if err != nil { - return err + if quota == 0 { + return nil } - return err + result := DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fmt.Errorf("%w: id=%d", ErrUserNotFound, id) + } + return nil } -func DecreaseUserQuota[T quotaDeltaInteger](id int, quota T, db bool) (err error) { +// See IncreaseUserQuota for why the compatibility flag is ignored. +func DecreaseUserQuota[T quotaDeltaInteger](id int, quota T, _ bool) (err error) { delta := int64(quota) if delta < 0 { return errors.New("quota 不能为负数!") } - gopool.Go(func() { - err := cacheDecrUserQuota(id, delta) - if err != nil { - common.SysLog("failed to decrease user quota: " + err.Error()) - } - }) - if !db && common.BatchUpdateEnabled { - addNewRecord(BatchUpdateTypeUserQuota, id, -delta) + if delta == 0 { return nil } - return decreaseUserQuota(id, delta) + if err := decreaseUserQuota(id, delta); err != nil { + return err + } + invalidateUserQuotaCache(id) + return nil } func decreaseUserQuota(id int, quota int64) (err error) { - err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error - if err != nil { - return err + if quota == 0 { + return nil + } + result := DB.Model(&User{}).Where("id = ? AND quota >= ?", id, quota). + Update("quota", gorm.Expr("quota - ?", quota)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return fmt.Errorf("%w: id=%d, need=%d", ErrUserQuotaInsufficient, id, quota) + } + return nil +} + +func invalidateUserQuotaCache(id int) { + if !common.RedisEnabled { + return + } + if err := invalidateUserCache(id); err != nil { + common.SysLog("failed to invalidate user quota cache: " + err.Error()) } - return err } func DeltaUpdateUserQuota[T quotaDeltaInteger](id int, delta T) (err error) { @@ -1622,8 +1662,7 @@ func (user *User) FillUserByLinuxDOId() error { if user.LinuxDOId == "" { return errors.New("linux do id is empty") } - err := DB.Where("linux_do_id = ?", user.LinuxDOId).First(user).Error - return err + return fillOAuthUser(user, "linux_do_id", user.LinuxDOId) } func RootUserExists() bool { diff --git a/model/user_cache.go b/model/user_cache.go index 9689a08..a550cb1 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -50,12 +50,16 @@ func getUserCacheKey(userId int) string { return fmt.Sprintf("user:%d", userId) } +func getUserCacheVersionKey(userId int) string { + return fmt.Sprintf("cache-version:user:%d", userId) +} + // invalidateUserCache clears user cache func invalidateUserCache(userId int) error { if !common.RedisEnabled { return nil } - return common.RedisDelKey(getUserCacheKey(userId)) + return common.RedisInvalidateVersionedHash(getUserCacheKey(userId), getUserCacheVersionKey(userId)) } // InvalidateUserCache is the exported version of invalidateUserCache. @@ -64,16 +68,18 @@ func InvalidateUserCache(userId int) error { return invalidateUserCache(userId) } -func populateUserCache(user User) error { +func populateUserCacheIfVersion(user User, version int64) error { if !common.RedisEnabled { return nil } - - return common.RedisHSetObj( + _, err := common.RedisHSetObjIfVersion( getUserCacheKey(user.Id), + getUserCacheVersionKey(user.Id), + version, user.ToBaseUser(), time.Duration(common.RedisKeyCacheSeconds())*time.Second, ) + return err } // updateUserCache refreshes non-quota user cache fields. @@ -103,19 +109,6 @@ func updateUserCache(user User) error { // GetUserCache gets complete user cache from hash func GetUserCache(userId int) (userCache *UserBase, err error) { - var user *User - var fromDB bool - defer func() { - // Update Redis cache asynchronously on successful DB read - if shouldUpdateRedis(fromDB, err) && user != nil { - gopool.Go(func() { - if err := populateUserCache(*user); err != nil { - common.SysLog("failed to update user status cache: " + err.Error()) - } - }) - } - }() - // Try getting from Redis first userCache, err = cacheGetUserBase(userId) if err == nil { @@ -123,8 +116,13 @@ func GetUserCache(userId int) (userCache *UserBase, err error) { } // If Redis fails, get from DB - fromDB = true - user, err = GetUserById(userId, false) + var cacheVersion int64 + cacheVersionValid := false + if common.RedisEnabled { + cacheVersion, err = common.RedisGetCacheVersion(getUserCacheVersionKey(userId)) + cacheVersionValid = err == nil + } + user, err := GetUserById(userId, false) if err != nil { return nil, err // Return nil and error if DB lookup fails } @@ -140,6 +138,14 @@ func GetUserCache(userId int) (userCache *UserBase, err error) { Setting: user.Setting, Email: user.Email, } + if cacheVersionValid { + userSnapshot := *user + gopool.Go(func() { + if err := populateUserCacheIfVersion(userSnapshot, cacheVersion); err != nil { + common.SysLog("failed to update user status cache: " + err.Error()) + } + }) + } return userCache, nil } @@ -163,18 +169,6 @@ func cacheGetUserBase(userId int) (*UserBase, error) { return &userCache, nil } -// Add atomic quota operations using hash fields -func cacheIncrUserQuota(userId int, delta int64) error { - if !common.RedisEnabled { - return nil - } - return common.RedisHIncrBy(getUserCacheKey(userId), "Quota", delta) -} - -func cacheDecrUserQuota(userId int, delta int64) error { - return cacheIncrUserQuota(userId, -delta) -} - // Helper functions to get individual fields if needed func getUserGroupCache(userId int) (string, error) { cache, err := GetUserCache(userId) @@ -228,11 +222,19 @@ func updateUserStatusCache(userId int, status bool) error { return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt)) } -func updateUserQuotaCache(userId int, quota int64) error { +func updateUserQuotaCacheIfVersion(userId int, quota int64, version int64) error { if !common.RedisEnabled { return nil } - return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota)) + _, err := common.RedisHSetFieldIfVersion( + getUserCacheKey(userId), + getUserCacheVersionKey(userId), + version, + "Quota", + quota, + time.Duration(common.RedisKeyCacheSeconds())*time.Second, + ) + return err } func updateUserGroupCache(userId int, group string) error { diff --git a/model/user_oauth_binding.go b/model/user_oauth_binding.go index 4054b04..d833639 100644 --- a/model/user_oauth_binding.go +++ b/model/user_oauth_binding.go @@ -48,6 +48,16 @@ func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) var user User err = DB.First(&user, binding.UserId).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + var deleted User + unscopedErr := DB.Unscoped().First(&deleted, binding.UserId).Error + if unscopedErr == nil && deleted.DeletedAt.Valid { + return nil, ErrUserDeleted + } + if unscopedErr != nil && !errors.Is(unscopedErr, gorm.ErrRecordNotFound) { + return nil, unscopedErr + } + } return nil, err } return &user, nil diff --git a/model/user_update_test.go b/model/user_update_test.go index 7ad3b39..bfe8416 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -9,10 +9,14 @@ import ( "net/url" "os" "strings" + "sync" + "sync/atomic" "testing" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" + miniredis "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/go-redis/redis/v8" "github.com/stretchr/testify/assert" @@ -21,6 +25,305 @@ import ( "gorm.io/gorm" ) +func TestConcurrentUserQuotaDeductionCannotOverdraw(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{Id: 31, Username: "quota-guard-user", Quota: 10, Status: common.UserStatusEnabled} + require.NoError(t, DB.Create(&user).Error) + + start := make(chan struct{}) + var wg sync.WaitGroup + var successes atomic.Int32 + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if DecreaseUserQuota(user.Id, 7, false) == nil { + successes.Add(1) + } + }() + } + close(start) + wg.Wait() + + assert.EqualValues(t, 1, successes.Load()) + var got User + require.NoError(t, DB.First(&got, user.Id).Error) + assert.EqualValues(t, 3, got.Quota) +} + +func TestConcurrentTokenQuotaDeductionCannotOverdraw(t *testing.T) { + setupUserUpdateTestState(t) + + token := Token{Id: 32, UserId: 31, Key: "quota-guard-token", RemainQuota: 10} + require.NoError(t, DB.Create(&token).Error) + + start := make(chan struct{}) + var wg sync.WaitGroup + var successes atomic.Int32 + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if DecreaseTokenQuota(token.Id, token.Key, 7) == nil { + successes.Add(1) + } + }() + } + close(start) + wg.Wait() + + assert.EqualValues(t, 1, successes.Load()) + var got Token + require.NoError(t, DB.First(&got, token.Id).Error) + assert.EqualValues(t, 3, got.RemainQuota) + assert.EqualValues(t, 7, got.UsedQuota) +} + +func TestUnlimitedTokenQuotaDeductionRemainsUnbounded(t *testing.T) { + setupUserUpdateTestState(t) + + token := Token{Id: 34, UserId: 31, Key: "unlimited-token", RemainQuota: 0, UnlimitedQuota: true} + require.NoError(t, DB.Create(&token).Error) + require.NoError(t, DecreaseTokenQuota(token.Id, token.Key, 7)) + + var got Token + require.NoError(t, DB.First(&got, token.Id).Error) + assert.EqualValues(t, -7, got.RemainQuota) + assert.EqualValues(t, 7, got.UsedQuota) +} + +func TestQuotaMutationDoesNotTouchCacheAfterDatabaseFailure(t *testing.T) { + setupUserUpdateTestState(t) + + oldRDB := common.RDB + var dialAttempts atomic.Int32 + client := redis.NewClient(&redis.Options{ + Addr: "cache-unavailable", + MaxRetries: 0, + Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialAttempts.Add(1) + return nil, errors.New("cache unavailable") + }, + }) + common.RedisEnabled = true + common.RDB = client + t.Cleanup(func() { + _ = client.Close() + common.RDB = oldRDB + }) + + require.Error(t, DecreaseUserQuota(999999, 1, false)) + require.Error(t, DecreaseTokenQuota(999999, "missing-token", 1)) + assert.Zero(t, dialAttempts.Load()) +} + +func TestInviteUserUpdatesOnlyAffiliateCounters(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{ + Id: 33, + Username: "inviter", + Quota: 100, + Status: common.UserStatusEnabled, + AffCount: 2, + AffQuota: 10, + AffHistoryQuota: 20, + } + require.NoError(t, DB.Create(&user).Error) + + require.NoError(t, inviteUser(user.Id)) + + var got User + require.NoError(t, DB.First(&got, user.Id).Error) + assert.Equal(t, 3, got.AffCount) + assert.EqualValues(t, 10+common.QuotaForInviter, got.AffQuota) + assert.EqualValues(t, 20+common.QuotaForInviter, got.AffHistoryQuota) + assert.EqualValues(t, 100, got.Quota) + assert.Equal(t, common.UserStatusEnabled, got.Status) +} + +func TestFillUserMethodsReturnDatabaseErrors(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + oldDB := DB + DB = db + t.Cleanup(func() { DB = oldDB }) + + tests := []struct { + name string + user User + call func(*User) error + }{ + {name: "id", user: User{Id: 1}, call: (*User).FillUserById}, + {name: "email", user: User{Email: "user@example.com"}, call: (*User).FillUserByEmail}, + {name: "github", user: User{GitHubId: "github-id"}, call: (*User).FillUserByGitHubId}, + {name: "discord", user: User{DiscordId: "discord-id"}, call: (*User).FillUserByDiscordId}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := tt.user + require.Error(t, tt.call(&user)) + }) + } +} + +func TestFillOAuthUserMethodsDistinguishSoftDeletedUsers(t *testing.T) { + setupUserUpdateTestState(t) + user := User{ + Id: 35, Username: "deleted-oauth-user", GitHubId: "deleted-github", + DiscordId: "deleted-discord", OidcId: "deleted-oidc", WeChatId: "deleted-wechat", + TelegramId: "deleted-telegram", LinuxDOId: "deleted-linuxdo", + } + require.NoError(t, DB.Create(&user).Error) + require.NoError(t, DB.Delete(&user).Error) + + tests := []struct { + name string + user User + call func(*User) error + }{ + {name: "github", user: User{GitHubId: user.GitHubId}, call: (*User).FillUserByGitHubId}, + {name: "discord", user: User{DiscordId: user.DiscordId}, call: (*User).FillUserByDiscordId}, + {name: "oidc", user: User{OidcId: user.OidcId}, call: (*User).FillUserByOidcId}, + {name: "wechat", user: User{WeChatId: user.WeChatId}, call: (*User).FillUserByWeChatId}, + {name: "telegram", user: User{TelegramId: user.TelegramId}, call: (*User).FillUserByTelegramId}, + {name: "linuxdo", user: User{LinuxDOId: user.LinuxDOId}, call: (*User).FillUserByLinuxDOId}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lookup := tt.user + require.ErrorIs(t, tt.call(&lookup), ErrUserDeleted) + }) + } +} + +type blockingCacheFillHook struct { + started chan struct{} + release chan struct{} + finished chan struct{} + beforeOnce sync.Once + afterOnce sync.Once +} + +func newBlockingCacheFillHook() *blockingCacheFillHook { + return &blockingCacheFillHook{ + started: make(chan struct{}), + release: make(chan struct{}), + finished: make(chan struct{}), + } +} + +func (h *blockingCacheFillHook) BeforeProcess(ctx context.Context, _ redis.Cmder) (context.Context, error) { + return ctx, nil +} + +func (h *blockingCacheFillHook) AfterProcess(context.Context, redis.Cmder) error { return nil } + +func (h *blockingCacheFillHook) BeforeProcessPipeline(ctx context.Context, cmds []redis.Cmder) (context.Context, error) { + if cacheFillPipeline(cmds) { + h.beforeOnce.Do(func() { + close(h.started) + <-h.release + }) + } + return ctx, nil +} + +func (h *blockingCacheFillHook) AfterProcessPipeline(_ context.Context, cmds []redis.Cmder) error { + if cacheFillPipeline(cmds) { + h.afterOnce.Do(func() { close(h.finished) }) + } + return nil +} + +func cacheFillPipeline(cmds []redis.Cmder) bool { + for _, cmd := range cmds { + if strings.EqualFold(cmd.Name(), "hset") { + return true + } + } + return false +} + +func useBlockingCacheFillRedis(t *testing.T) (*blockingCacheFillHook, *redis.Client) { + t.Helper() + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + hook := newBlockingCacheFillHook() + client.AddHook(hook) + + oldRDB := common.RDB + common.RDB = client + common.RedisEnabled = true + t.Cleanup(func() { + _ = client.Close() + common.RDB = oldRDB + }) + return hook, client +} + +func waitForCacheHook(t *testing.T, ch <-chan struct{}, stage string) { + t.Helper() + select { + case <-ch: + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for cache %s", stage) + } +} + +func TestUserQuotaInvalidationRejectsOlderAsyncRefill(t *testing.T) { + setupUserUpdateTestState(t) + hook, client := useBlockingCacheFillRedis(t) + user := User{Id: 36, Username: "user-cache-fence", Quota: 100, Status: common.UserStatusEnabled} + require.NoError(t, DB.Create(&user).Error) + + quota, err := GetUserQuota(user.Id, true) + require.NoError(t, err) + assert.EqualValues(t, 100, quota) + waitForCacheHook(t, hook.started, "fill start") + + require.NoError(t, DecreaseUserQuota(user.Id, 10, false)) + close(hook.release) + waitForCacheHook(t, hook.finished, "fill completion") + + quota, err = GetUserQuota(user.Id, false) + require.NoError(t, err) + assert.EqualValues(t, 90, quota) + require.Eventually(t, func() bool { + cached, err := client.HGet(context.Background(), getUserCacheKey(user.Id), "Quota").Int64() + return err == nil && cached == 90 + }, 3*time.Second, 10*time.Millisecond) +} + +func TestTokenQuotaInvalidationRejectsOlderAsyncRefill(t *testing.T) { + setupUserUpdateTestState(t) + hook, client := useBlockingCacheFillRedis(t) + token := Token{Id: 37, UserId: 36, Key: "token-cache-fence", RemainQuota: 100, Status: common.TokenStatusEnabled} + require.NoError(t, DB.Create(&token).Error) + + got, err := GetTokenByKey(token.Key, true) + require.NoError(t, err) + assert.EqualValues(t, 100, got.RemainQuota) + waitForCacheHook(t, hook.started, "fill start") + + require.NoError(t, DecreaseTokenQuota(token.Id, token.Key, 10)) + close(hook.release) + waitForCacheHook(t, hook.finished, "fill completion") + + got, err = GetTokenByKey(token.Key, false) + require.NoError(t, err) + assert.EqualValues(t, 90, got.RemainQuota) + require.Eventually(t, func() bool { + cached, err := client.HGet(context.Background(), getTokenCacheKey(token.Key), "RemainQuota").Int64() + return err == nil && cached == 90 + }, 3*time.Second, 10*time.Millisecond) +} + func setupUserUpdateTestState(t *testing.T) { t.Helper() truncateTables(t) diff --git a/model/utils.go b/model/utils.go index 7f93198..e405cad 100644 --- a/model/utils.go +++ b/model/utils.go @@ -82,7 +82,7 @@ func batchUpdate() { for key, value := range store { switch i { case BatchUpdateTypeTokenQuota: - err := increaseTokenQuota(key, int(value)) + err := increaseTokenQuota(key, value) if err != nil { common.SysLog("failed to batch update token quota: " + err.Error()) } diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go index a6c7b8f..7a2ae5d 100644 --- a/pkg/billingexpr/compile.go +++ b/pkg/billingexpr/compile.go @@ -33,8 +33,9 @@ type cachedEntry struct { } var ( - cacheMu sync.RWMutex - cache = make(map[string]*cachedEntry, 64) + cacheMu sync.RWMutex + cache = make(map[string]*cachedEntry, 64) + cacheOrder []string ) // compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time. @@ -101,10 +102,17 @@ func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { vars := extractUsedVars(prog) cacheMu.Lock() + if entry, ok := cache[hash]; ok { + cacheMu.Unlock() + return entry.prog, nil + } if len(cache) >= maxCacheSize { - cache = make(map[string]*cachedEntry, 64) + oldest := cacheOrder[0] + cacheOrder = cacheOrder[1:] + delete(cache, oldest) } cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version} + cacheOrder = append(cacheOrder, hash) cacheMu.Unlock() return prog, nil @@ -171,5 +179,6 @@ func UsedVars(exprStr string) map[string]bool { func InvalidateCache() { cacheMu.Lock() cache = make(map[string]*cachedEntry, 64) + cacheOrder = nil cacheMu.Unlock() } diff --git a/pkg/billingexpr/expr.md b/pkg/billingexpr/expr.md index 89894ab..091be57 100644 --- a/pkg/billingexpr/expr.md +++ b/pkg/billingexpr/expr.md @@ -76,8 +76,8 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are | Function | Signature | Purpose | |----------|-----------|---------| | `tier` | `tier(name, value) → float64` | Records which pricing tier matched; must wrap the cost expression | -| `param` | `param(path) → any` | Reads a JSON path from the request body (uses gjson) | -| `header` | `header(key) → string` | Reads a request header value | +| `param` | `param(path) → any` | Reads an approved pricing-metadata path from the request body | +| `header` | `header(key) → string` | Reads an approved provider feature header | | `has` | `has(source, substr) → bool` | Substring check | | `hour` | `hour(tz) → int` | Current hour in timezone (0-23) | | `minute` | `minute(tz) → int` | Current minute (0-59) | @@ -90,6 +90,19 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are | `ceil` | `ceil(x) → float64` | Ceiling | | `floor` | `floor(x) → float64` | Floor | +For request-aware pricing, `header()` and `param()` use a positive allowlist. +`header()` currently exposes only `anthropic-beta` and `openai-beta`. +`param()` supports the established pricing metadata paths `service_tier`, +`stream`, `fast`, `stream_options.fast_mode`, `messages.#`, `input.#`, +`tools.#`, `modalities.#`, `response_format.type`, `reasoning.effort`, +`thinking.type`, `thinking.budget_tokens`, and indexed `role` / `type` fields +under `messages`, `input`, or `tools`. + +All other headers and body paths return an empty string or `nil`. GJSON +modifiers, pipelines, queries, wildcards, whole-document selectors, prompt or +message content, tool definitions, credentials, user metadata, image, audio, +and other raw payload fields are not available to pricing expressions. + ### Expression Examples ``` diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go index 7c0f2ec..04ff445 100644 --- a/pkg/billingexpr/run.go +++ b/pkg/billingexpr/run.go @@ -69,11 +69,15 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo return value }, "header": func(key string) string { - return headers[strings.ToLower(strings.TrimSpace(key))] + key = strings.ToLower(strings.TrimSpace(key)) + if !isAllowedHeaderName(key) { + return "" + } + return headers[key] }, "param": func(path string) interface{} { path = strings.TrimSpace(path) - if path == "" || len(request.Body) == 0 { + if len(request.Body) == 0 || !isAllowedParamPath(path) { return nil } result := gjson.GetBytes(request.Body, path) @@ -111,6 +115,47 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo return f, trace, nil } +func isAllowedHeaderName(key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + switch key { + case "anthropic-beta", "openai-beta": + return true + default: + return false + } +} + +func isAllowedParamPath(path string) bool { + normalized := strings.ToLower(strings.TrimSpace(path)) + if normalized == "" || strings.ContainsAny(normalized, `@*?[]{}|\\"' `) { + return false + } + + switch normalized { + case "service_tier", "stream", "fast", "stream_options.fast_mode", + "messages.#", "input.#", "tools.#", "modalities.#", + "response_format.type", "reasoning.effort", "thinking.type", "thinking.budget_tokens": + return true + } + + segments := strings.Split(normalized, ".") + if len(segments) != 3 { + return false + } + switch segments[0] { + case "messages", "input", "tools": + if segments[1] != "#" { + for _, r := range segments[1] { + if r < '0' || r > '9' { + return false + } + } + } + return segments[1] != "" && (segments[2] == "role" || segments[2] == "type") + } + return false +} + func timeInZone(tz string) time.Time { tz = strings.TrimSpace(tz) if tz == "" { diff --git a/pkg/billingexpr/security_cache_test.go b/pkg/billingexpr/security_cache_test.go new file mode 100644 index 0000000..41b31c9 --- /dev/null +++ b/pkg/billingexpr/security_cache_test.go @@ -0,0 +1,59 @@ +package billingexpr + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunExprWithRequestBlocksSensitiveValues(t *testing.T) { + request := RequestInput{ + Headers: map[string]string{ + "Authorization": "Bearer secret-token", + "Anthropic-Beta": "fast-mode", + }, + Body: []byte(`{"messages":[{"role":"user","content":"private prompt"}],"service_tier":"fast"}`), + } + + tests := []struct { + name string + expr string + }{ + {name: "authorization header", expr: `header("authorization") == "" ? 1 : 999`}, + {name: "unknown auth-like header", expr: `header("x-auth") == "" ? 1 : 999`}, + {name: "message content", expr: `param("messages.0.content") == nil ? 1 : 999`}, + {name: "whole message", expr: `param("messages.0") == nil ? 1 : 999`}, + {name: "whole request", expr: `param("@this") == nil ? 1 : 999`}, + {name: "unknown body root", expr: `param("contents.*") == nil ? 1 : 999`}, + {name: "gjson query", expr: `param("messages.#(role==\"user\")") == nil ? 1 : 999`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cost, _, err := RunExprWithRequest(tt.expr, TokenParams{}, request) + require.NoError(t, err) + assert.Equal(t, float64(1), cost) + }) + } + + cost, _, err := RunExprWithRequest( + `has(header("anthropic-beta"), "fast-mode") && param("messages.#") == 1 && param("service_tier") == "fast" ? 1 : 999`, + TokenParams{}, + request, + ) + require.NoError(t, err) + assert.Equal(t, float64(1), cost) +} + +func TestCompileCacheEvictsIncrementallyAtCapacity(t *testing.T) { + InvalidateCache() + t.Cleanup(InvalidateCache) + + for i := 0; i < maxCacheSize+10; i++ { + _, err := CompileFromCache(fmt.Sprintf("p + %d", i)) + require.NoError(t, err) + } + + assert.Len(t, cache, maxCacheSize) +} diff --git a/service/billing_session.go b/service/billing_session.go index 409cc39..43d8d38 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/logger" @@ -23,24 +24,42 @@ import ( // BillingSession 封装单次请求的预扣费/结算/退款生命周期。 // 实现 relaycommon.BillingSettler 接口。 type BillingSession struct { - relayInfo *relaycommon.RelayInfo - funding FundingSource - preConsumedQuota int // 实际预扣额度(信任用户可能为 0) - tokenConsumed int // 令牌额度实际扣减量 - extraReserved int // 发送前补充预扣的额度(订阅退款时需要单独回滚) - trusted bool // 是否命中信任额度旁路 - fundingSettled bool // funding.Settle 已成功,资金来源已提交 - settled bool // Settle 全部完成(资金 + 令牌) - refunded bool // Refund 已调用 - mu sync.Mutex + relayInfo *relaycommon.RelayInfo + funding FundingSource + preConsumedQuota int // 实际预扣额度(信任用户可能为 0) + tokenConsumed int // 令牌额度实际扣减量 + extraReserved int // 发送前补充预扣的额度(订阅退款时需要单独回滚) + trusted bool // 是否命中信任额度旁路 + fundingSettled bool // funding.Settle 已成功,资金来源已提交 + appliedFundingDelta int64 // 已提交、尚未完成令牌对账的资金差额 + compensationFailed bool // 非幂等资金补偿失败后只重试令牌,不重复补偿 + settled bool // Settle 全部完成(资金 + 令牌) + refunded bool // Refund 已调用 + mu sync.Mutex } // Settle 根据实际消耗额度进行结算。 -// 资金来源和令牌额度分两步提交:若资金来源已提交但令牌调整失败, -// 会标记 fundingSettled 防止 Refund 对已提交的资金来源执行退款。 +// 资金来源和令牌额度分两步提交。若令牌调整失败,会优先反向补偿已提交的 +// 资金差额;补偿失败时保留 fundingSettled,使后续重试只补令牌步骤。 func (s *BillingSession) Settle(actualQuota int) error { s.mu.Lock() defer s.mu.Unlock() + var lastErr error + const maxAttempts = 3 + for attempt := 0; attempt < maxAttempts; attempt++ { + if err := s.settleLocked(actualQuota); err == nil { + return nil + } else { + lastErr = err + } + if attempt < maxAttempts-1 { + time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) + } + } + return fmt.Errorf("billing settlement failed after %d attempts: %w", maxAttempts, lastErr) +} + +func (s *BillingSession) settleLocked(actualQuota int) error { if s.settled { return nil } @@ -50,14 +69,14 @@ func (s *BillingSession) Settle(actualQuota int) error { return nil } // 1) 调整资金来源(仅在尚未提交时执行,防止重复调用) - appliedFundingDelta := int64(delta) if !s.fundingSettled { applied, err := s.funding.Settle(delta) if err != nil { return err } - appliedFundingDelta = applied + s.appliedFundingDelta = applied s.fundingSettled = true + s.compensationFailed = false } // 2) 调整令牌额度 var tokenErr error @@ -68,17 +87,34 @@ func (s *BillingSession) Settle(actualQuota int) error { tokenErr = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta) } if tokenErr != nil { - // 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金 common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s", s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error())) + if s.compensationFailed { + return tokenErr + } + if s.appliedFundingDelta == 0 { + s.fundingSettled = false + } else { + compensated, compensationErr := s.funding.Settle(-int(s.appliedFundingDelta)) + if compensationErr != nil || compensated != -s.appliedFundingDelta { + s.compensationFailed = true + common.SysLog(fmt.Sprintf("error compensating funding after token settlement failure (userId=%d, tokenId=%d, delta=%d, applied=%d, compensated=%d): %v", + s.relayInfo.UserId, s.relayInfo.TokenId, delta, s.appliedFundingDelta, compensated, compensationErr)) + } else { + s.fundingSettled = false + s.appliedFundingDelta = 0 + s.compensationFailed = false + } + } + return tokenErr } } // 3) 更新 relayInfo 上的订阅 PostDelta(用于日志) if s.funding.Source() == BillingSourceSubscription { - s.relayInfo.SubscriptionPostDelta += appliedFundingDelta + s.relayInfo.SubscriptionPostDelta += s.appliedFundingDelta } s.settled = true - return tokenErr + return nil } // Refund 退还所有预扣费,幂等安全,异步执行。 @@ -296,8 +332,8 @@ func (s *BillingSession) shouldTrust(c *gin.Context) bool { // 检查令牌是否充足 tokenTrusted := s.relayInfo.TokenUnlimited if !tokenTrusted { - tokenQuota := c.GetInt("token_quota") - tokenTrusted = tokenQuota > trustQuota + tokenQuota := c.GetInt64("token_quota") + tokenTrusted = tokenQuota > int64(trustQuota) } if !tokenTrusted { return false @@ -309,8 +345,7 @@ func (s *BillingSession) shouldTrust(c *gin.Context) bool { case BillingSourceSubscription: // 订阅不能启用信任旁路。原因: // 1. PreConsumeUserSubscription 要求 amount>0 来创建预扣记录并锁定订阅 - // 2. SubscriptionFunding.PreConsume 忽略参数,始终用 s.amount 预扣 - // 3. 若信任旁路将 effectiveQuota 设为 0,会导致 preConsumedQuota 与实际订阅预扣不一致 + // 2. 若信任旁路将 effectiveQuota 设为 0,订阅无法创建有效预扣记录 return false default: return false @@ -390,11 +425,9 @@ func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preCons requestId: relayInfo.RequestId, userId: relayInfo.UserId, modelName: relayInfo.OriginModelName, - amount: subConsume, }, } - // 必须传 subConsume 而非 preConsumedQuota,保证 SubscriptionFunding.amount、 - // preConsume 参数和 FinalPreConsumedQuota 三者一致,避免订阅多扣费。 + // 必须传 subConsume 而非 preConsumedQuota,保证订阅至少创建 1 额度预扣记录。 if apiErr := session.preConsume(c, int(subConsume)); apiErr != nil { return nil, apiErr } diff --git a/service/billing_session_test.go b/service/billing_session_test.go new file mode 100644 index 0000000..e15c60a --- /dev/null +++ b/service/billing_session_test.go @@ -0,0 +1,166 @@ +package service + +import ( + "errors" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordingFundingSource struct { + deltas []int +} + +func (f *recordingFundingSource) Source() string { return BillingSourceWallet } +func (f *recordingFundingSource) PreConsume(int) error { return nil } +func (f *recordingFundingSource) Refund() error { return nil } +func (f *recordingFundingSource) Settle(delta int) (int64, error) { + f.deltas = append(f.deltas, delta) + return int64(delta), nil +} + +func TestBillingSessionCompensatesFundingWhenTokenSettlementFails(t *testing.T) { + truncate(t) + funding := &recordingFundingSource{} + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{ + UserId: 41, + TokenId: 42, + TokenKey: "settlement-token", + }, + funding: funding, + preConsumedQuota: 10, + tokenConsumed: 10, + } + + err := session.Settle(15) + require.Error(t, err) + assert.Equal(t, []int{5, -5, 5, -5, 5, -5}, funding.deltas) + assert.False(t, session.settled) + assert.False(t, session.fundingSettled) + + require.NoError(t, model.DB.Create(&model.Token{ + Id: 42, UserId: 41, Key: "settlement-token", Status: common.TokenStatusEnabled, RemainQuota: 20, + }).Error) + require.NoError(t, session.Settle(15)) + assert.Equal(t, []int{5, -5, 5, -5, 5, -5, 5}, funding.deltas) + assert.True(t, session.settled) + + var token model.Token + require.NoError(t, model.DB.First(&token, 42).Error) + assert.EqualValues(t, 15, token.RemainQuota) + assert.EqualValues(t, 5, token.UsedQuota) +} + +type compensationFailingFundingSource struct { + deltas []int +} + +func (f *compensationFailingFundingSource) Source() string { return BillingSourceWallet } +func (f *compensationFailingFundingSource) PreConsume(int) error { return nil } +func (f *compensationFailingFundingSource) Refund() error { return nil } +func (f *compensationFailingFundingSource) Settle(delta int) (int64, error) { + f.deltas = append(f.deltas, delta) + if delta < 0 { + return 0, errors.New("compensation failed") + } + return int64(delta), nil +} + +func TestBillingSessionKeepsCommittedFundingRetryableWhenCompensationFails(t *testing.T) { + truncate(t) + funding := &compensationFailingFundingSource{} + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{UserId: 51, TokenId: 52, TokenKey: "retry-token"}, + funding: funding, preConsumedQuota: 10, + } + + require.Error(t, session.Settle(15)) + assert.Equal(t, []int{5, -5}, funding.deltas) + assert.True(t, session.fundingSettled) + assert.True(t, session.compensationFailed) + assert.False(t, session.settled) + + require.NoError(t, model.DB.Create(&model.Token{ + Id: 52, UserId: 51, Key: "retry-token", Status: common.TokenStatusEnabled, RemainQuota: 20, + }).Error) + require.NoError(t, session.Settle(15)) + assert.Equal(t, []int{5, -5}, funding.deltas) + assert.True(t, session.settled) +} + +type tokenRecoveringFundingSource struct { + t *testing.T + deltas []int +} + +func (f *tokenRecoveringFundingSource) Source() string { return BillingSourceWallet } +func (f *tokenRecoveringFundingSource) PreConsume(int) error { return nil } +func (f *tokenRecoveringFundingSource) Refund() error { return nil } +func (f *tokenRecoveringFundingSource) Settle(delta int) (int64, error) { + f.deltas = append(f.deltas, delta) + if delta < 0 { + require.NoError(f.t, model.DB.Create(&model.Token{ + Id: 62, UserId: 61, Key: "recovering-token", Status: common.TokenStatusEnabled, RemainQuota: 20, + }).Error) + } + return int64(delta), nil +} + +func TestBillingSessionRetriesTransientTokenSettlementWithinSingleCall(t *testing.T) { + truncate(t) + funding := &tokenRecoveringFundingSource{t: t} + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{UserId: 61, TokenId: 62, TokenKey: "recovering-token"}, + funding: funding, preConsumedQuota: 10, + } + + require.NoError(t, session.Settle(15)) + assert.Equal(t, []int{5, -5, 5}, funding.deltas) + assert.True(t, session.settled) + + var token model.Token + require.NoError(t, model.DB.First(&token, 62).Error) + assert.EqualValues(t, 15, token.RemainQuota) + assert.EqualValues(t, 5, token.UsedQuota) +} + +func TestBillingSessionTrustsFiniteInt64TokenQuota(t *testing.T) { + ctx, _ := gin.CreateTestContext(nil) + trustQuota := common.GetTrustQuota() + ctx.Set("token_quota", int64(trustQuota+1)) + session := &BillingSession{ + relayInfo: &relaycommon.RelayInfo{UserQuota: int64(trustQuota + 1)}, + funding: &recordingFundingSource{}, + } + + assert.True(t, session.shouldTrust(ctx)) +} + +func TestPreConsumeQuotaTrustsFiniteInt64TokenQuota(t *testing.T) { + truncate(t) + trustQuota := common.GetTrustQuota() + seedUser(t, 71, trustQuota+100) + seedToken(t, 72, 71, "finite-int64-token", trustQuota+100) + + ctx, _ := gin.CreateTestContext(nil) + ctx.Set("token_quota", int64(trustQuota+100)) + relayInfo := &relaycommon.RelayInfo{ + UserId: 71, TokenId: 72, TokenKey: "finite-int64-token", + } + + require.Nil(t, PreConsumeQuota(ctx, 10, relayInfo)) + assert.Zero(t, relayInfo.FinalPreConsumedQuota) + + var user model.User + require.NoError(t, model.DB.First(&user, 71).Error) + assert.EqualValues(t, trustQuota+100, user.Quota) + var token model.Token + require.NoError(t, model.DB.First(&token, 72).Error) + assert.EqualValues(t, trustQuota+100, token.RemainQuota) +} diff --git a/service/funding_source.go b/service/funding_source.go index 9803ae2..e6228d9 100644 --- a/service/funding_source.go +++ b/service/funding_source.go @@ -71,7 +71,6 @@ type SubscriptionFunding struct { requestId string userId int modelName string - amount int64 // 预扣的订阅额度(subConsume) subscriptionId int preConsumed int64 // 以下字段在 PreConsume 成功后填充,供 RelayInfo 同步使用 @@ -83,9 +82,11 @@ type SubscriptionFunding struct { func (s *SubscriptionFunding) Source() string { return BillingSourceSubscription } -func (s *SubscriptionFunding) PreConsume(_ int) error { - // amount 参数被忽略,使用内部 s.amount(已在构造时根据 preConsumedQuota 计算) - res, err := model.PreConsumeUserSubscription(s.requestId, s.userId, s.modelName, 0, s.amount) +func (s *SubscriptionFunding) PreConsume(amount int) error { + if amount <= 0 { + return nil + } + res, err := model.PreConsumeUserSubscription(s.requestId, s.userId, s.modelName, 0, int64(amount)) if err != nil { return err } diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go index 448fac0..0a2aebe 100644 --- a/service/pre_consume_quota.go +++ b/service/pre_consume_quota.go @@ -52,8 +52,8 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo // 用户额度充足,判断令牌额度是否充足 if !relayInfo.TokenUnlimited { // 非无限令牌,判断令牌额度是否充足 - tokenQuota := c.GetInt("token_quota") - if tokenQuota > trustQuota { + tokenQuota := c.GetInt64("token_quota") + if tokenQuota > int64(trustQuota) { // 令牌额度充足,信任令牌 preConsumedQuota = 0 logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota)) @@ -73,6 +73,10 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo } err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota, false) if err != nil { + if rollbackErr := model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota); rollbackErr != nil { + common.SysLog(fmt.Sprintf("error rolling back token quota after user pre-consume failure (userId=%d, tokenId=%d, amount=%d): %s", + relayInfo.UserId, relayInfo.TokenId, preConsumedQuota, rollbackErr.Error())) + } return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) } logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-int64(preConsumedQuota)))) diff --git a/service/quota.go b/service/quota.go index c32d330..5085507 100644 --- a/service/quota.go +++ b/service/quota.go @@ -143,7 +143,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota)) } - if !token.UnlimitedQuota && token.RemainQuota < quota { + if !token.UnlimitedQuota && token.RemainQuota < int64(quota) { return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota)) } @@ -414,7 +414,7 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { if err != nil { return err } - if !relayInfo.TokenUnlimited && token.RemainQuota < quota { + if !relayInfo.TokenUnlimited && token.RemainQuota < int64(quota) { return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota)) } err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota) diff --git a/service/task_billing_test.go b/service/task_billing_test.go index c7ae51a..ef0e768 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -97,7 +97,7 @@ func seedToken(t *testing.T, id int, userId int, key string, remainQuota int) { Key: key, Name: "test_token", Status: common.TokenStatusEnabled, - RemainQuota: remainQuota, + RemainQuota: int64(remainQuota), UsedQuota: 0, } require.NoError(t, model.DB.Create(token).Error) @@ -165,14 +165,14 @@ func getTokenRemainQuota(t *testing.T, id int) int { t.Helper() var token model.Token require.NoError(t, model.DB.Select("remain_quota").Where("id = ?", id).First(&token).Error) - return token.RemainQuota + return int(token.RemainQuota) } func getTokenUsedQuota(t *testing.T, id int) int { t.Helper() var token model.Token require.NoError(t, model.DB.Select("used_quota").Where("id = ?", id).First(&token).Error) - return token.UsedQuota + return int(token.UsedQuota) } func getSubscriptionUsed(t *testing.T, id int) int64 { diff --git a/setting/config/config.go b/setting/config/config.go index ff4258f..daa26e6 100644 --- a/setting/config/config.go +++ b/setting/config/config.go @@ -1,7 +1,7 @@ package config import ( - "encoding/json" + "fmt" "reflect" "strconv" "strings" @@ -59,7 +59,7 @@ func (cm *ConfigManager) LoadFromDB(options map[string]string) error { if len(configMap) > 0 { if err := updateConfigFromMap(config, configMap); err != nil { common.SysError("failed to update config " + name + ": " + err.Error()) - continue + return fmt.Errorf("failed to update config %s: %w", name, err) } } } @@ -134,7 +134,7 @@ func configToMap(config interface{}) (map[string]string, error) { case reflect.Ptr: // 处理指针类型:如果非 nil,序列化指向的值 if !field.IsNil() { - bytes, err := json.Marshal(field.Interface()) + bytes, err := common.Marshal(field.Interface()) if err != nil { return nil, err } @@ -145,7 +145,7 @@ func configToMap(config interface{}) (map[string]string, error) { } case reflect.Map, reflect.Slice, reflect.Struct: // 复杂类型使用JSON序列化 - bytes, err := json.Marshal(field.Interface()) + bytes, err := common.Marshal(field.Interface()) if err != nil { return nil, err } @@ -165,12 +165,12 @@ func configToMap(config interface{}) (map[string]string, error) { func updateConfigFromMap(config interface{}, configMap map[string]string) error { val := reflect.ValueOf(config) if val.Kind() != reflect.Ptr { - return nil + return fmt.Errorf("config must be a pointer, got %s", val.Kind()) } val = val.Elem() if val.Kind() != reflect.Struct { - return nil + return fmt.Errorf("config must point to a struct, got %s", val.Kind()) } typ := val.Type() @@ -206,7 +206,7 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error case reflect.Bool: boolValue, err := strconv.ParseBool(strValue) if err != nil { - continue + return fmt.Errorf("invalid value for %s: %w", key, err) } field.SetBool(boolValue) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: @@ -215,7 +215,7 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error // 兼容 float 格式的字符串(如 "2.000000") floatValue, fErr := strconv.ParseFloat(strValue, 64) if fErr != nil { - continue + return fmt.Errorf("invalid value for %s: %w", key, err) } intValue = int64(floatValue) } @@ -226,7 +226,7 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error // 兼容 float 格式的字符串 floatValue, fErr := strconv.ParseFloat(strValue, 64) if fErr != nil || floatValue < 0 { - continue + return fmt.Errorf("invalid value for %s: %q", key, strValue) } uintValue = uint64(floatValue) } @@ -234,7 +234,7 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error case reflect.Float32, reflect.Float64: floatValue, err := strconv.ParseFloat(strValue, 64) if err != nil { - continue + return fmt.Errorf("invalid value for %s: %w", key, err) } field.SetFloat(floatValue) case reflect.Ptr: @@ -242,30 +242,31 @@ func updateConfigFromMap(config interface{}, configMap map[string]string) error if strValue == "null" { field.Set(reflect.Zero(field.Type())) } else { - // 如果指针是 nil,需要先初始化 - if field.IsNil() { - field.Set(reflect.New(field.Type().Elem())) + fresh := reflect.New(field.Type().Elem()) + if !field.IsNil() { + fresh.Elem().Set(field.Elem()) } - // 反序列化到指针指向的值 - err := json.Unmarshal([]byte(strValue), field.Interface()) - if err != nil { - continue + if err := common.Unmarshal([]byte(strValue), fresh.Interface()); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) } + field.Set(fresh) } case reflect.Map: // json.Unmarshal merges into existing maps (keeps old keys that are // absent from the new JSON). Allocate a fresh map so removed keys // are properly cleared. fresh := reflect.New(field.Type()) - if err := json.Unmarshal([]byte(strValue), fresh.Interface()); err != nil { - continue + if err := common.Unmarshal([]byte(strValue), fresh.Interface()); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) } field.Set(fresh.Elem()) case reflect.Slice, reflect.Struct: - err := json.Unmarshal([]byte(strValue), field.Addr().Interface()) - if err != nil { - continue + fresh := reflect.New(field.Type()) + fresh.Elem().Set(field) + if err := common.Unmarshal([]byte(strValue), fresh.Interface()); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) } + field.Set(fresh.Elem()) } } diff --git a/setting/config/config_test.go b/setting/config/config_test.go index 8c70a5a..7b7817f 100644 --- a/setting/config/config_test.go +++ b/setting/config/config_test.go @@ -2,6 +2,9 @@ package config import ( "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type testConfigWithMap struct { @@ -94,3 +97,17 @@ func TestUpdateConfigFromMap_ScalarFieldsUnchanged(t *testing.T) { t.Errorf("Modes should be unchanged, got %v", cfg.Modes) } } + +func TestLoadFromDBReturnsInvalidSubConfigError(t *testing.T) { + manager := NewConfigManager() + cfg := &testConfigWithMap{Modes: map[string]string{"existing": "tiered_expr"}} + manager.Register("billing", cfg) + + err := manager.LoadFromDB(map[string]string{ + "billing.modes": `{not-json}`, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "billing") + assert.Equal(t, map[string]string{"existing": "tiered_expr"}, cfg.Modes) +} diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index 8dcbd1c..c214a60 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -66,7 +66,6 @@ dto/values.go|*BoolValue.UnmarshalJSON|Unmarshal|696c84522bd5ca3e97cd2054e281a03 dto/values.go|*BoolValue.UnmarshalJSON|Unmarshal|20093b52db26f80edb520049d20008b7c1a1ec58019c1671c2b1ccb20ef7c7b6 dto/values.go|BoolValue.MarshalJSON|Marshal|bc53c95a352b7d8ffde9f9b4d44968a905dfc0a762fdb9659c059feef11ed2a4 middleware/jimeng_adapter.go|JimengRequestConvert|Marshal|418a4a72b6b9c61eb7c62b58bb0f4bb1e23e251bda4f5a8f3514d66ebfd8d962 -middleware/turnstile-check.go|TurnstileCheck|NewDecoder|e8a040e082cc6bbbd68d40e2f8f6e472154b4912b1f05a6cbbf591d797e8e071 model/channel.go|*Channel.SetOtherInfo|Marshal|18d7bc9d32c6ec57f9b016303592cb5e327ffbe1abfc9b66cb7bd8033d1d36b0 model/passkey.go|*PasskeyCredential.TransportList|Unmarshal|8174884feacc1fdfef084adb2900b1a97565b6c6c5f072b2d390e1c1dbadaa7f model/passkey.go|*PasskeyCredential.SetTransports|Marshal|891aca816b90a7b0706fdd24581e477bee4335c8f916733130b54141eb6448c7 @@ -186,11 +185,6 @@ service/passkey/session.go|PopSessionData|Unmarshal|f2cd8d698a8b3baee9426fd333d1 service/user_notify.go|sendGotifyNotify|Marshal|2af017bb4f8b2b336f7069e3a6f219c0fb04cddbcb36aea984cee19b0af05e40 setting/chat.go|UpdateChatsByJsonString|Unmarshal|9b652f37646cc5b0c6883ad42cfa81aa57bf3ae9ed3f64bf59c0ca4e9024983e setting/chat.go|Chats2JsonString|Marshal|6d15cc12f47b577c984fd31bbc9ca3e9bbb3702d28b79ac76d58e9aa32e79fdc -setting/config/config.go|configToMap|Marshal|5461dd0fa565aa884b3d901cec1ce345f2ea8820f326326fa375f95a12ff5294 -setting/config/config.go|configToMap|Marshal|5461dd0fa565aa884b3d901cec1ce345f2ea8820f326326fa375f95a12ff5294 -setting/config/config.go|updateConfigFromMap|Unmarshal|faeda06c9e13ea103f4e66a53b06665365cabb7dd4324ba04f22b6b1b95c5734 -setting/config/config.go|updateConfigFromMap|Unmarshal|5e0b3b940e88a7d30749aefc577d85fcb5e97ed232686b6eea49553731a09979 -setting/config/config.go|updateConfigFromMap|Unmarshal|557d7dfd47d378b1b77baa70070c6c2ab4b7276c41acaa5170bfb66d77abcc3e setting/console_setting/validation.go|parseJSONArray|Unmarshal|403028caafc69490779d75b66909a63579743ae6b77481ab45fba2b65aa34805 setting/console_setting/validation.go|getJSONList|Unmarshal|403028caafc69490779d75b66909a63579743ae6b77481ab45fba2b65aa34805 setting/rate_limit.go|ModelRequestRateLimitGroup2JSONString|Marshal|c72783cb7874ede43c0e880006216b4e7a02605d568d41c3360f5da2674d41dc diff --git a/web/bun.lock b/web/bun.lock index 2326d41..57ec35c 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -32,6 +32,7 @@ "date-fns": "^4.3.0", "dayjs": "catalog:", "dompurify": "3.4.11", + "expr-eval": "^2.0.2", "i18next": "^26.2.0", "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", @@ -1354,6 +1355,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expr-eval": ["expr-eval@2.0.2", "", {}, "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], diff --git a/web/default/package.json b/web/default/package.json index 430b27e..dee04d3 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -41,6 +41,7 @@ "date-fns": "^4.3.0", "dayjs": "catalog:", "dompurify": "3.4.11", + "expr-eval": "^2.0.2", "i18next": "^26.2.0", "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", diff --git a/web/default/src/components/layout/components/chat-presets-item.tsx b/web/default/src/components/layout/components/chat-presets-item.tsx index e691dc4..531189f 100644 --- a/web/default/src/components/layout/components/chat-presets-item.tsx +++ b/web/default/src/components/layout/components/chat-presets-item.tsx @@ -210,7 +210,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { if (typeof window === 'undefined') return - window.open(url, '_blank', 'noopener') + window.open(url, '_blank', 'noopener,noreferrer') setOpenMobile(false) }, [serverAddress, setOpenMobile, t] diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx index eae25bf..fa67bec 100644 --- a/web/default/src/components/layout/components/footer.tsx +++ b/web/default/src/components/layout/components/footer.tsx @@ -23,6 +23,7 @@ import { DEFAULT_LOGO } from '@/lib/constants' import { cn } from '@/lib/utils' import { useStatus } from '@/hooks/use-status' import { useSystemConfig } from '@/hooks/use-system-config' +import { HtmlContent } from '@/components/html-content' interface FooterLink { text: string @@ -232,9 +233,9 @@ export function Footer(props: FooterProps) { >
-
diff --git a/web/default/src/features/auth/api.ts b/web/default/src/features/auth/api.ts index 01a7295..2055707 100644 --- a/web/default/src/features/auth/api.ts +++ b/web/default/src/features/auth/api.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { api } from '@/lib/api' +import { getTurnstileHeaders } from './lib/turnstile-request' import type { LoginPayload, LoginResponse, @@ -36,13 +37,13 @@ import type { // User login with username and password export async function login(payload: LoginPayload) { - const turnstile = payload.turnstile ?? '' const res = await api.post( - `/api/user/login?turnstile=${turnstile}`, + '/api/user/login', { username: payload.username, password: payload.password, - } + }, + { headers: getTurnstileHeaders(payload.turnstile) } ) return res.data } @@ -69,7 +70,8 @@ export async function sendPasswordResetEmail( turnstile?: string ): Promise { const res = await api.get('/api/reset_password', { - params: { email, turnstile }, + params: { email }, + headers: getTurnstileHeaders(turnstile), }) return res.data } @@ -81,7 +83,7 @@ export async function sendPasswordResetEmail( // Start GitHub OAuth flow export async function githubOAuthStart(clientId: string, state: string) { const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email` - window.open(url) + window.open(url, '_self') } // Get OAuth state for CSRF protection @@ -105,8 +107,10 @@ export async function wechatLoginByCode(code: string): Promise { // User registration export async function register(payload: RegisterPayload): Promise { - const res = await api.post(`/api/user/register`, payload, { - params: { turnstile: payload.turnstile ?? '' }, + const body = { ...payload } + delete body.turnstile + const res = await api.post('/api/user/register', body, { + headers: getTurnstileHeaders(payload.turnstile), }) return res.data } @@ -117,7 +121,8 @@ export async function sendEmailVerification( turnstile?: string ): Promise { const res = await api.get('/api/verification', { - params: { email, turnstile }, + params: { email }, + headers: getTurnstileHeaders(turnstile), }) return res.data } diff --git a/web/default/src/features/auth/lib/turnstile-request.test.ts b/web/default/src/features/auth/lib/turnstile-request.test.ts new file mode 100644 index 0000000..50e7dc5 --- /dev/null +++ b/web/default/src/features/auth/lib/turnstile-request.test.ts @@ -0,0 +1,36 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + getTurnstileHeaders, + TURNSTILE_TOKEN_HEADER, +} from './turnstile-request' + +describe('getTurnstileHeaders', () => { + test('moves a non-empty token into the dedicated header', () => { + assert.deepEqual(getTurnstileHeaders(' token-value '), { + [TURNSTILE_TOKEN_HEADER]: 'token-value', + }) + }) + + test('omits an empty token', () => { + assert.deepEqual(getTurnstileHeaders(''), {}) + }) +}) diff --git a/web/default/src/features/auth/lib/turnstile-request.ts b/web/default/src/features/auth/lib/turnstile-request.ts new file mode 100644 index 0000000..b8b5faf --- /dev/null +++ b/web/default/src/features/auth/lib/turnstile-request.ts @@ -0,0 +1,25 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +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 https://github.com/MAX-API-Next/MAX-API/issues +*/ + +export const TURNSTILE_TOKEN_HEADER = 'X-Turnstile-Token' + +export function getTurnstileHeaders(token?: string): Record { + const value = token?.trim() + return value ? { [TURNSTILE_TOKEN_HEADER]: value } : {} +} diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index ebed084..079494e 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -660,7 +660,11 @@ export function useChannelsColumns(): ColumnDef[] { e.stopPropagation() if (!deploymentId) return const targetUrl = `/models/deployments?dFilter=${encodeURIComponent(String(deploymentId))}` - window.open(targetUrl, '_blank', 'noopener') + window.open( + targetUrl, + '_blank', + 'noopener,noreferrer' + ) }} /> } diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx index 58f037e..ed32f0a 100644 --- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx @@ -992,7 +992,11 @@ function FailureStatusContent({ size='sm' className='h-7 w-fit px-2 text-xs' onClick={() => - window.open('/system-settings/billing/model-pricing', '_blank') + window.open( + '/system-settings/billing/model-pricing', + '_blank', + 'noopener,noreferrer' + ) } > diff --git a/web/default/src/features/keys/components/data-table-row-actions.tsx b/web/default/src/features/keys/components/data-table-row-actions.tsx index d75704a..4eca628 100644 --- a/web/default/src/features/keys/components/data-table-row-actions.tsx +++ b/web/default/src/features/keys/components/data-table-row-actions.tsx @@ -147,7 +147,7 @@ export function DataTableRowActions({ if (typeof window === 'undefined') return try { - window.open(resolvedUrl, '_blank', 'noopener') + window.open(resolvedUrl, '_blank', 'noopener,noreferrer') } catch { window.location.href = resolvedUrl } diff --git a/web/default/src/features/keys/components/dialogs/cc-switch-dialog.tsx b/web/default/src/features/keys/components/dialogs/cc-switch-dialog.tsx index 97b8e2e..83eba7d 100644 --- a/web/default/src/features/keys/components/dialogs/cc-switch-dialog.tsx +++ b/web/default/src/features/keys/components/dialogs/cc-switch-dialog.tsx @@ -146,7 +146,7 @@ export function CCSwitchDialog(props: Props) { ? props.tokenKey : `sk-${props.tokenKey}` const url = buildCCSwitchURL(app, name, models, key) - window.open(url, '_blank') + window.open(url, '_blank', 'noopener,noreferrer') props.onOpenChange(false) } diff --git a/web/default/src/features/models/components/dialogs/view-details-dialog.tsx b/web/default/src/features/models/components/dialogs/view-details-dialog.tsx index 081ee34..acd6344 100644 --- a/web/default/src/features/models/components/dialogs/view-details-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/view-details-dialog.tsx @@ -242,7 +242,13 @@ export function ViewDetailsDialog({