From 1a188ebd99f3b427b37cd8dcea9e5cbb14498a81 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 9 Jul 2026 21:13:52 +0800 Subject: [PATCH 1/4] v1.0.4 --- .../pricing/components/model-details.tsx | 56 +++++------- .../pricing/components/pricing-sidebar.tsx | 77 +++++++++++++++- .../pricing/components/pricing-toolbar.tsx | 5 ++ web/default/src/features/pricing/index.tsx | 4 + .../features/pricing/lib/auto-route-view.ts | 90 +++++++++++++++++++ web/default/src/i18n/locales/en.json | 3 + web/default/src/i18n/locales/fr.json | 3 + web/default/src/i18n/locales/ja.json | 3 + web/default/src/i18n/locales/ru.json | 3 + web/default/src/i18n/locales/vi.json | 3 + web/default/src/i18n/locales/zh.json | 3 + 11 files changed, 215 insertions(+), 35 deletions(-) create mode 100644 web/default/src/features/pricing/lib/auto-route-view.ts diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 3262fda..1d2b7d2 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -21,7 +21,7 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { DEFAULT_AUTO_ROUTE_KEY, type AutoGroupRoute } from '@/lib/auto-routes' +import type { AutoGroupRoute } from '@/lib/auto-routes' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' @@ -54,6 +54,10 @@ import { } from '@/features/performance-metrics/lib/format' import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants' import { usePricingData } from '../hooks/use-pricing-data' +import { + getAutoRouteLabelOverride, + getConfiguredAutoRouteChains, +} from '../lib/auto-route-view' import { getDynamicPriceEntries, getDynamicPricingSummary, @@ -748,46 +752,32 @@ function AutoGroupChain(props: { const modelEnableGroups = Array.isArray(props.model.enable_groups) ? props.model.enable_groups : [] - const routes = - props.autoRoutes && props.autoRoutes.length > 0 - ? props.autoRoutes - : [ - { - key: DEFAULT_AUTO_ROUTE_KEY, - enabled: true, - user_selectable: true, - groups: props.autoGroups, - }, - ] - const routeChains = routes - .filter((route) => route.enabled) - .map((route) => ({ - route, - groups: route.groups.filter((g) => modelEnableGroups.includes(g)), - })) - .filter((item) => item.groups.length > 0) + const routeChains = getConfiguredAutoRouteChains({ + autoGroups: props.autoGroups, + autoRoutes: props.autoRoutes, + groupFilter: (group) => modelEnableGroups.includes(group), + }) if (routeChains.length === 0) return null - const getRouteLabelOverride = (route: AutoGroupRoute) => { - const name = route.name?.trim() - if ( - route.key === DEFAULT_AUTO_ROUTE_KEY && - (!name || name === 'Auto' || name === DEFAULT_AUTO_ROUTE_KEY) - ) { - return undefined - } - return name || route.key - } - return ( -
- {t('Auto Route Chains')} +
+
+ {t('Auto Route Chains')} +

+ {t( + 'When a token uses an auto route, the system tries the route groups from top to bottom until it finds an available group.' + )}{' '} + {t( + 'Billing and group ratios are calculated from the real group that handles the request.' + )} +

+
{routeChains.map(({ route, groups }) => (
diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 62fe96a..5a199cc 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -17,10 +17,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import type { ReactNode } from 'react' -import { ChevronDown, RotateCcw } from 'lucide-react' +import { ChevronDown, Info, RotateCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' +import type { AutoGroupRoute } from '@/lib/auto-routes' import { getLobeIcon } from '@/lib/lobe-icon' import { cn } from '@/lib/utils' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -28,6 +30,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' +import { GroupBadge } from '@/components/group-badge' import { ENDPOINT_TYPES, FILTER_ALL, @@ -35,6 +38,10 @@ import { getEndpointTypeLabels, getQuotaTypeLabels, } from '../constants' +import { + getAutoRouteLabelOverride, + getConfiguredAutoRouteChains, +} from '../lib/auto-route-view' import { parseTags } from '../lib/filters' import type { PricingModel, PricingVendor } from '../types' @@ -51,6 +58,7 @@ type FilterSectionProps = { value: string options: FilterOption[] onChange: (value: string) => void + children?: ReactNode } export interface PricingSidebarProps { @@ -67,6 +75,8 @@ export interface PricingSidebarProps { vendors: PricingVendor[] groups: string[] groupRatios?: Record + autoGroups?: string[] + autoRoutes?: AutoGroupRoute[] tags: string[] models: PricingModel[] hasActiveFilters: boolean @@ -149,11 +159,69 @@ function FilterSection(props: FilterSectionProps) { /> ))}
+ {props.children} ) } +function AutoRouteGroupIntro(props: { + autoGroups?: string[] + autoRoutes?: AutoGroupRoute[] +}) { + const { t } = useTranslation() + const routeChains = getConfiguredAutoRouteChains({ + autoGroups: props.autoGroups ?? [], + autoRoutes: props.autoRoutes, + }) + + if (routeChains.length === 0) return null + + return ( + + + + {t('Configured auto route groups')} + + +

+ {t( + 'Auto route groups are selectable aliases that point to administrator-configured billing groups.' + )} +

+
+ {routeChains.map(({ route, groups }) => ( +
+ + + {groups.map((group, index) => ( + + + {index < groups.length - 1 && ( + + )} + + ))} +
+ ))} +
+

+ {t( + 'Billing and group ratios are calculated from the real group that handles the request.' + )} +

+
+
+ ) +} + export function PricingSidebar(props: PricingSidebarProps) { const { t } = useTranslation() const quotaTypeLabels = getQuotaTypeLabels(t) @@ -277,7 +345,12 @@ export function PricingSidebar(props: PricingSidebarProps) { value={props.groupFilter} options={groupOptions} onChange={props.onGroupChange} - /> + > + + + autoGroups?: string[] + autoRoutes?: AutoGroupRoute[] tags: string[] models: PricingModel[] hasActiveFilters: boolean @@ -297,6 +300,8 @@ export function PricingToolbar(props: PricingToolbarProps) { vendors={props.vendors} groups={props.groups} groupRatios={props.groupRatios} + autoGroups={props.autoGroups} + autoRoutes={props.autoRoutes} tags={props.tags} models={props.models} hasActiveFilters={props.hasActiveFilters} diff --git a/web/default/src/features/pricing/index.tsx b/web/default/src/features/pricing/index.tsx index 6fb3f00..7f2744a 100644 --- a/web/default/src/features/pricing/index.tsx +++ b/web/default/src/features/pricing/index.tsx @@ -218,6 +218,8 @@ export function Pricing() { vendors={vendors || []} groups={availableGroups} groupRatios={groupRatio} + autoGroups={autoGroups || []} + autoRoutes={autoRoutes || []} tags={availableTags} models={models || []} hasActiveFilters={hasActiveFilters} @@ -250,6 +252,8 @@ export function Pricing() { vendors={vendors || []} groups={availableGroups} groupRatios={groupRatio} + autoGroups={autoGroups || []} + autoRoutes={autoRoutes || []} tags={availableTags} models={models || []} hasActiveFilters={hasActiveFilters} diff --git a/web/default/src/features/pricing/lib/auto-route-view.ts b/web/default/src/features/pricing/lib/auto-route-view.ts new file mode 100644 index 0000000..e95a5b4 --- /dev/null +++ b/web/default/src/features/pricing/lib/auto-route-view.ts @@ -0,0 +1,90 @@ +/* +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 { DEFAULT_AUTO_ROUTE_KEY, type AutoGroupRoute } from '@/lib/auto-routes' + +export type PricingAutoRouteChain = { + route: AutoGroupRoute + groups: string[] +} + +function normalizeDisplayGroups( + groups: string[], + groupFilter?: (group: string) => boolean +) { + const seen = new Set() + const normalized: string[] = [] + + for (const rawGroup of groups) { + const group = rawGroup.trim() + if (!group || seen.has(group)) continue + if (groupFilter && !groupFilter(group)) continue + seen.add(group) + normalized.push(group) + } + + return normalized +} + +function getFallbackAutoRoute(autoGroups: string[]): AutoGroupRoute | null { + const groups = normalizeDisplayGroups(autoGroups) + if (groups.length === 0) return null + + return { + key: DEFAULT_AUTO_ROUTE_KEY, + enabled: true, + user_selectable: true, + groups, + } +} + +export function getAutoRouteLabelOverride(route: AutoGroupRoute) { + const name = route.name?.trim() + if ( + route.key === DEFAULT_AUTO_ROUTE_KEY && + (!name || name === 'Auto' || name === DEFAULT_AUTO_ROUTE_KEY) + ) { + return undefined + } + return name || route.key +} + +export function getConfiguredAutoRouteChains(options: { + autoGroups: string[] + autoRoutes?: AutoGroupRoute[] + groupFilter?: (group: string) => boolean +}): PricingAutoRouteChain[] { + const fallbackRoute = getFallbackAutoRoute(options.autoGroups) + const routes = + options.autoRoutes && options.autoRoutes.length > 0 + ? options.autoRoutes + : fallbackRoute + ? [fallbackRoute] + : [] + + return routes + .filter((route) => route.enabled) + .map((route) => ({ + route, + groups: normalizeDisplayGroups( + Array.isArray(route.groups) ? route.groups : [], + options.groupFilter + ), + })) + .filter((item) => item.groups.length > 0) +} diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index fb872b5..591590c 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "auto route {{key}} name must not exceed 64 characters", "Auto route chains": "Auto route chains", "Auto Route Chains": "Auto Route Chains", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "Auto route groups are selectable aliases that point to administrator-configured billing groups.", "Auto route key already exists": "Auto route key already exists", "auto route must be an object": "auto route must be an object", "Auto Sync Upstream Models": "Auto Sync Upstream Models", @@ -666,6 +667,7 @@ "Billing Mode": "Billing Mode", "Billing Process": "Billing Process", "Billing Source": "Billing Source", + "Billing and group ratios are calculated from the real group that handles the request.": "Billing and group ratios are calculated from the real group that handles the request.", "billing-aware model rules": "billing-aware model rules", "billing-aware models": "billing-aware models", "Bind": "Bind", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "Configure Waffo payment aggregation platform integration", "Configure your account behavior preferences": "Configure your account behavior preferences", "Configure your account preferences and integrations": "Configure your account preferences and integrations", + "Configured auto route groups": "Configured auto route groups", "Configured routes and latency checks": "Configured routes and latency checks", "Confirm": "Confirm", "Confirm Action": "Confirm Action", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 8491af3..0a7d14b 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "Le nom de la route auto {{key}} ne doit pas dépasser 64 caractères", "Auto route chains": "Chaînes de routes auto", "Auto Route Chains": "Chaînes de routes auto", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "Les groupes de route auto sont des alias sélectionnables qui pointent vers des groupes de facturation configurés par l'administrateur.", "Auto route key already exists": "La clé de route auto existe déjà", "auto route must be an object": "La route auto doit être un objet", "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", @@ -666,6 +667,7 @@ "Billing Mode": "Mode de facturation", "Billing Process": "Processus de facturation", "Billing Source": "Source de facturation", + "Billing and group ratios are calculated from the real group that handles the request.": "La facturation et les ratios de groupe sont calculés à partir du groupe réel qui traite la requête.", "billing-aware model rules": "règles de modèles compatibles avec la facturation", "billing-aware models": "modèles compatibles avec la facturation", "Bind": "Lier", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "Configurer l'intégration de la plateforme d'agrégation de paiement Waffo", "Configure your account behavior preferences": "Configurer les préférences de comportement de votre compte", "Configure your account preferences and integrations": "Configurer les préférences et les intégrations de votre compte", + "Configured auto route groups": "Groupes de routes auto configurés", "Configured routes and latency checks": "Routes configurées et contrôles de latence", "Confirm": "Confirmer", "Confirm Action": "Confirmer l'action", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index cc335c7..c2d714e 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "自動ルート {{key}} の名前は64文字以下にしてください", "Auto route chains": "自動ルートチェーン", "Auto Route Chains": "自動ルートチェーン", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "自動ルートグループは、管理者が設定した実際の課金グループを指す選択可能なエイリアスです。", "Auto route key already exists": "自動ルートキーはすでに存在します", "auto route must be an object": "自動ルートはオブジェクトである必要があります", "Auto Sync Upstream Models": "アップストリームモデルの自動同期", @@ -666,6 +667,7 @@ "Billing Mode": "課金モード", "Billing Process": "課金プロセス", "Billing Source": "課金ソース", + "Billing and group ratios are calculated from the real group that handles the request.": "課金とグループ倍率は、リクエストを処理した実際のグループに基づいて計算されます。", "billing-aware model rules": "課金対応モデルルール", "billing-aware models": "課金対応モデル", "Bind": "バインド", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "Waffo決済アグリゲーションプラットフォームの連携を設定", "Configure your account behavior preferences": "アカウントの動作設定を設定します。", "Configure your account preferences and integrations": "アカウントの設定と統合を設定します。", + "Configured auto route groups": "設定済みの自動ルートグループ", "Configured routes and latency checks": "設定済みルートとレイテンシ確認", "Confirm": "確認", "Confirm Action": "アクションの確認", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 9e01340..9aed47b 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "Имя автомаршрута {{key}} не должно превышать 64 символа", "Auto route chains": "Цепочки автомаршрутов", "Auto Route Chains": "Цепочки автомаршрутов", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "Группы автомаршрутов — это выбираемые псевдонимы, указывающие на реальные группы тарификации, настроенные администратором.", "Auto route key already exists": "Ключ автомаршрута уже существует", "auto route must be an object": "Автомаршрут должен быть объектом", "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера", @@ -666,6 +667,7 @@ "Billing Mode": "Режим биллинга", "Billing Process": "Процесс тарификации", "Billing Source": "Источник биллинга", + "Billing and group ratios are calculated from the real group that handles the request.": "Тарификация и коэффициенты групп рассчитываются по реальной группе, обработавшей запрос.", "billing-aware model rules": "правила моделей с учетом биллинга", "billing-aware models": "модели с учетом биллинга", "Bind": "Привязать", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "Настроить интеграцию платёжной платформы Waffo", "Configure your account behavior preferences": "Настроить предпочтения поведения вашей учетной записи", "Configure your account preferences and integrations": "Настроить параметры и интеграции вашей учетной записи", + "Configured auto route groups": "Настроенные группы автомаршрутов", "Configured routes and latency checks": "Настроенные маршруты и проверки задержки", "Confirm": "Подтверждение", "Confirm Action": "Подтвердить действие", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index f0bd46f..fe86a05 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "Tên tuyến tự động {{key}} không được vượt quá 64 ký tự", "Auto route chains": "Chuỗi tuyến tự động", "Auto Route Chains": "Chuỗi tuyến tự động", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "Các nhóm tuyến tự động là bí danh có thể chọn, trỏ tới các nhóm tính phí thật do quản trị viên cấu hình.", "Auto route key already exists": "Khóa tuyến tự động đã tồn tại", "auto route must be an object": "Tuyến tự động phải là một đối tượng", "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", @@ -666,6 +667,7 @@ "Billing Mode": "Chế độ thanh toán", "Billing Process": "Quá trình tính phí", "Billing Source": "Nguồn thanh toán", + "Billing and group ratios are calculated from the real group that handles the request.": "Phí và tỷ lệ nhóm được tính theo nhóm thật xử lý yêu cầu.", "billing-aware model rules": "quy tắc mô hình có nhận thức tính phí", "billing-aware models": "mô hình có nhận thức tính phí", "Bind": "Buộc", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "Cấu hình tích hợp nền tảng tổng hợp thanh toán Waffo", "Configure your account behavior preferences": "Cấu hình tùy chọn hành vi tài khoản của bạn", "Configure your account preferences and integrations": "Cấu hình các tùy chọn và tích hợp tài khoản của bạn", + "Configured auto route groups": "Các nhóm tuyến tự động đã cấu hình", "Configured routes and latency checks": "Tuyến đã cấu hình và kiểm tra độ trễ", "Confirm": "Xác nhận", "Confirm Action": "Xác nhận hành động", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 988fb96..cef2175 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -557,6 +557,7 @@ "auto route {{key}} name must not exceed 64 characters": "自动链路 {{key}} 的名称不能超过 64 个字符", "Auto route chains": "自动链路", "Auto Route Chains": "自动链路", + "Auto route groups are selectable aliases that point to administrator-configured billing groups.": "自动链路分组是可选择的分组别名,指向管理员配置的真实计费分组。", "Auto route key already exists": "自动链路键已存在", "auto route must be an object": "自动链路必须是对象", "Auto Sync Upstream Models": "自动同步上游模型", @@ -666,6 +667,7 @@ "Billing Mode": "计费模式", "Billing Process": "计费过程", "Billing Source": "计费来源", + "Billing and group ratios are calculated from the real group that handles the request.": "计费和分组倍率按实际处理请求的真实分组计算。", "billing-aware model rules": "支持计费的模型规则", "billing-aware models": "支持计费的模型", "Bind": "绑定", @@ -1019,6 +1021,7 @@ "Configure Waffo payment aggregation platform integration": "配置 Waffo 支付聚合平台集成", "Configure your account behavior preferences": "配置您的账户行为偏好", "Configure your account preferences and integrations": "配置您的账户偏好和集成", + "Configured auto route groups": "已配置的自动链路分组", "Configured routes and latency checks": "已配置路由和延迟检测", "Confirm": "确认", "Confirm Action": "确认操作", From c2e5e8c8ce5d3ec806628b9c2c751718c434d18a Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 9 Jul 2026 23:23:57 +0800 Subject: [PATCH 2/4] v1.0.4 --- .../src/features/pricing/components/model-details.tsx | 2 +- .../src/features/pricing/components/pricing-sidebar.tsx | 4 ++++ .../src/features/pricing/hooks/use-pricing-data.ts | 2 +- web/default/src/features/pricing/index.tsx | 6 +++--- web/default/src/features/pricing/lib/auto-route-view.ts | 8 +++++--- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/default/src/features/pricing/components/model-details.tsx b/web/default/src/features/pricing/components/model-details.tsx index 1d2b7d2..f359216 100644 --- a/web/default/src/features/pricing/components/model-details.tsx +++ b/web/default/src/features/pricing/components/model-details.tsx @@ -1409,7 +1409,7 @@ export function ModelDetails() { groupRatio={groupRatio || {}} usableGroup={usableGroup || {}} autoGroups={autoGroups || []} - autoRoutes={autoRoutes || []} + autoRoutes={autoRoutes} priceRate={priceRate ?? 1} usdExchangeRate={usdExchangeRate ?? 1} tokenUnit={tokenUnit} diff --git a/web/default/src/features/pricing/components/pricing-sidebar.tsx b/web/default/src/features/pricing/components/pricing-sidebar.tsx index 5a199cc..6f49273 100644 --- a/web/default/src/features/pricing/components/pricing-sidebar.tsx +++ b/web/default/src/features/pricing/components/pricing-sidebar.tsx @@ -168,11 +168,14 @@ function FilterSection(props: FilterSectionProps) { function AutoRouteGroupIntro(props: { autoGroups?: string[] autoRoutes?: AutoGroupRoute[] + visibleGroups: string[] }) { const { t } = useTranslation() + const visibleGroupSet = new Set(props.visibleGroups) const routeChains = getConfiguredAutoRouteChains({ autoGroups: props.autoGroups ?? [], autoRoutes: props.autoRoutes, + groupFilter: (group) => visibleGroupSet.has(group), }) if (routeChains.length === 0) return null @@ -349,6 +352,7 @@ export function PricingSidebar(props: PricingSidebarProps) { ) || {} } autoGroups={autoGroups || []} - autoRoutes={autoRoutes || []} + autoRoutes={autoRoutes} priceRate={priceRate ?? 1} usdExchangeRate={usdExchangeRate ?? 1} tokenUnit={tokenUnit} diff --git a/web/default/src/features/pricing/lib/auto-route-view.ts b/web/default/src/features/pricing/lib/auto-route-view.ts index e95a5b4..8363bd4 100644 --- a/web/default/src/features/pricing/lib/auto-route-view.ts +++ b/web/default/src/features/pricing/lib/auto-route-view.ts @@ -26,7 +26,7 @@ export type PricingAutoRouteChain = { function normalizeDisplayGroups( groups: string[], groupFilter?: (group: string) => boolean -) { +): string[] { const seen = new Set() const normalized: string[] = [] @@ -53,7 +53,9 @@ function getFallbackAutoRoute(autoGroups: string[]): AutoGroupRoute | null { } } -export function getAutoRouteLabelOverride(route: AutoGroupRoute) { +export function getAutoRouteLabelOverride( + route: AutoGroupRoute +): string | undefined { const name = route.name?.trim() if ( route.key === DEFAULT_AUTO_ROUTE_KEY && @@ -71,7 +73,7 @@ export function getConfiguredAutoRouteChains(options: { }): PricingAutoRouteChain[] { const fallbackRoute = getFallbackAutoRoute(options.autoGroups) const routes = - options.autoRoutes && options.autoRoutes.length > 0 + options.autoRoutes !== undefined ? options.autoRoutes : fallbackRoute ? [fallbackRoute] From 0e937d8ef082cc4d0171dc5b070439a1e5f07457 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 10 Jul 2026 23:10:27 +0800 Subject: [PATCH 3/4] v1.0.4 --- model/user.go | 35 +++++++++++++++++++++++++---------- model/user_update_test.go | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/model/user.go b/model/user.go index b364c02..cc51536 100644 --- a/model/user.go +++ b/model/user.go @@ -311,11 +311,11 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err return fn(tx) case common.UsingMySQL: lockName := normalizedEmailLockName(email) - var acquired sql.NullInt64 - if err := tx.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Scan(&acquired).Error; err != nil { + acquired, err := acquireMySQLNamedLock(tx, lockName) + if err != nil { return err } - if !acquired.Valid || acquired.Int64 != 1 { + if !acquired { return errors.New("failed to acquire user email lock") } released := false @@ -324,7 +324,7 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err _ = releaseMySQLNamedLock(tx, lockName) } }() - err := fn(tx) + err = fn(tx) releaseErr := releaseMySQLNamedLock(tx, lockName) released = true if releaseErr != nil && err == nil { @@ -353,11 +353,11 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error lockName := normalizedEmailLockName(email) return DB.Connection(func(conn *gorm.DB) error { - var acquired sql.NullInt64 - if err := conn.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Scan(&acquired).Error; err != nil { + acquired, err := acquireMySQLNamedLock(conn, lockName) + if err != nil { return err } - if !acquired.Valid || acquired.Int64 != 1 { + if !acquired { return errors.New("failed to acquire user email lock") } @@ -368,7 +368,7 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error } }() - err := conn.Transaction(fn) + err = conn.Transaction(fn) releaseErr := releaseMySQLNamedLock(conn, lockName) released = true if err != nil { @@ -378,9 +378,24 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error }) } +// Use database/sql row scanning so sql.NullInt64 is not parsed as a GORM model. +func scanNullableInt64(row *sql.Row) (sql.NullInt64, error) { + var value sql.NullInt64 + err := row.Scan(&value) + return value, err +} + +func acquireMySQLNamedLock(tx *gorm.DB, lockName string) (bool, error) { + acquired, err := scanNullableInt64(tx.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Row()) + if err != nil { + return false, err + } + return acquired.Valid && acquired.Int64 == 1, nil +} + func releaseMySQLNamedLock(tx *gorm.DB, lockName string) error { - var released sql.NullInt64 - return tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Scan(&released).Error + _, err := scanNullableInt64(tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Row()) + return err } func GetMaxUserId() int { diff --git a/model/user_update_test.go b/model/user_update_test.go index 4bc68c5..60c249c 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -12,6 +12,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" + "github.com/glebarez/sqlite" "github.com/go-redis/redis/v8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,6 +81,25 @@ func TestNormalizedEmailLockNameIsStableAndShort(t *testing.T) { assert.Contains(t, lockName, "maxapi:user-email:") } +func TestScanNullableInt64UsesSQLRow(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + if sqlDB, err := db.DB(); err == nil { + t.Cleanup(func() { + _ = sqlDB.Close() + }) + } + + got, err := scanNullableInt64(db.Raw("SELECT ?", 1).Row()) + require.NoError(t, err) + require.True(t, got.Valid) + assert.EqualValues(t, 1, got.Int64) + + nullValue, err := scanNullableInt64(db.Raw("SELECT NULL").Row()) + require.NoError(t, err) + assert.False(t, nullValue.Valid) +} + func mysqlDSNWithClientFoundRowsFalse(dsn string) string { base, rawQuery, ok := strings.Cut(dsn, "?") values, err := url.ParseQuery(rawQuery) From 09b18e74b3f784a59308da434b435399db1c9025 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 10 Jul 2026 23:44:14 +0800 Subject: [PATCH 4/4] v1.0.4 --- model/user.go | 16 +++++++++++++--- model/user_update_test.go | 7 +++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/model/user.go b/model/user.go index cc51536..fb9fc76 100644 --- a/model/user.go +++ b/model/user.go @@ -390,12 +390,22 @@ func acquireMySQLNamedLock(tx *gorm.DB, lockName string) (bool, error) { if err != nil { return false, err } - return acquired.Valid && acquired.Int64 == 1, nil + return isMySQLNamedLockSuccess(acquired), nil } func releaseMySQLNamedLock(tx *gorm.DB, lockName string) error { - _, err := scanNullableInt64(tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Row()) - return err + released, err := scanNullableInt64(tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Row()) + if err != nil { + return err + } + if !isMySQLNamedLockSuccess(released) { + return errors.New("failed to release user email lock") + } + return nil +} + +func isMySQLNamedLockSuccess(value sql.NullInt64) bool { + return value.Valid && value.Int64 == 1 } func GetMaxUserId() int { diff --git a/model/user_update_test.go b/model/user_update_test.go index 60c249c..a0fab97 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -2,6 +2,7 @@ package model import ( "context" + "database/sql" "errors" "fmt" "net" @@ -100,6 +101,12 @@ func TestScanNullableInt64UsesSQLRow(t *testing.T) { assert.False(t, nullValue.Valid) } +func TestMySQLNamedLockResultSuccessRequiresOne(t *testing.T) { + assert.True(t, isMySQLNamedLockSuccess(sql.NullInt64{Int64: 1, Valid: true})) + assert.False(t, isMySQLNamedLockSuccess(sql.NullInt64{Int64: 0, Valid: true})) + assert.False(t, isMySQLNamedLockSuccess(sql.NullInt64{Valid: false})) +} + func mysqlDSNWithClientFoundRowsFalse(dsn string) string { base, rawQuery, ok := strings.Cut(dsn, "?") values, err := url.ParseQuery(rawQuery)