From 5ae4f22599865ee3d69b5fbe2c6af1adf82aae6a Mon Sep 17 00:00:00 2001 From: OnePlus_X <13660828666@139.com> Date: Sat, 17 Oct 2026 17:21:08 +0800 Subject: [PATCH 1/4] feat(home): render live model pricing in Landing zones --- .../__tests__/landing-model-zones.test.tsx | 256 ++++++++++++++++++ .../home/components/landing-model-zones.tsx | 197 ++++++++++++++ web/src/features/home/index.tsx | 19 +- .../features/home/lib/landing-model-zones.ts | 50 ++++ web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/zh.json | 4 + 6 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 web/src/features/home/components/__tests__/landing-model-zones.test.tsx create mode 100644 web/src/features/home/components/landing-model-zones.tsx create mode 100644 web/src/features/home/lib/landing-model-zones.ts diff --git a/web/src/features/home/components/__tests__/landing-model-zones.test.tsx b/web/src/features/home/components/__tests__/landing-model-zones.test.tsx new file mode 100644 index 000000000000..6daa3659976a --- /dev/null +++ b/web/src/features/home/components/__tests__/landing-model-zones.test.tsx @@ -0,0 +1,256 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +*/ +import assert from 'node:assert/strict' +import { after, afterEach, test } from 'node:test' + +import { Window } from 'happy-dom' + +const domWindow = new Window({ url: 'https://token.iflink.tech/' }) +for (const key of [ + 'window', + 'document', + 'navigator', + 'HTMLElement', + 'Node', + 'Element', + 'Event', + 'MouseEvent', + 'MutationObserver', +] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + value: domWindow[key], + }) +} +Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, +}) +const { QueryClient, QueryClientProvider } = + await import('@tanstack/react-query') +const { act, createRef } = await import('react') +const { createRoot } = await import('react-dom/client') +const { createInstance } = await import('i18next') +const { I18nextProvider, initReactI18next } = await import('react-i18next') +const { HtmlContent } = await import('@/components/html-content') +const { useAuthStore } = await import('@/stores/auth-store') +const { api } = await import('@/lib/api') +const { getPricing } = await import('@/features/pricing/api') +const { formatPrice } = await import('@/features/pricing/lib/price') +const { LandingModelZones } = await import('../landing-model-zones') +const { readLandingModelZones } = await import('../../lib/landing-model-zones') + +const i18n = createInstance() +await i18n + .use(initReactI18next) + .init({ lng: 'en', resources: { en: { translation: {} } } }) +const originalAdapter = api.defaults.adapter +const catalog = { + success: true, + vendors: [], + usable_group: { default: { desc: 'Wallet', ratio: 1 } }, + group_ratio: { default: 1 }, + data: [ + { + id: 1, + model_name: 'deepseek-v4-pro-0813', + quota_type: 0, + model_ratio: 1, + completion_ratio: 2, + cache_ratio: 0.1, + enable_groups: ['default'], + }, + ], +} +const manifest = [ + { + model_id: 'deepseek-v4-pro-0813', + name: 'DeepSeek V4 Pro', + scenario: 'Analysis', + description: 'Try an analysis task.', + }, + { + model_id: 'disabled-model', + name: 'Unavailable model', + scenario: 'Disabled', + description: 'Must be filtered by server catalog.', + }, +] +const content = `

Static pricing link

` +let root: ReturnType | undefined +let queryClient: InstanceType +let host: HTMLDivElement +let requests: string[] + +async function mount() { + host = document.createElement('div') + document.body.append(host) + const ref = createRef() + root = createRoot(host) + await act(async () => + root?.render( + + +
+ + +
+
+
+ ) + ) + const shadow = host.querySelector('.custom-home-content')?.shadowRoot + assert.ok(shadow) + return shadow +} + +function setUp() { + useAuthStore.getState().auth.reset() + requests = [] + api.defaults.adapter = async (config) => { + requests.push(config.url || '') + assert.equal(config.method, 'get') + assert.equal(config.url, '/api/pricing') + return { config, status: 200, statusText: 'OK', headers: {}, data: catalog } + } + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) +} + +function signIn() { + useAuthStore.getState().auth.setBundle({ + access_token: 'local-test-access-token', + token_type: 'Bearer', + access_expires_at: Date.now() / 1000 + 3600, + user: { id: 7, username: 'local-test', role: 1, group: 'default' }, + session: { + sid: 'landing-test-session', + current: true, + login_method: 'password', + ip: '', + user_agent: '', + created_at: 0, + last_active_at: 0, + expires_at: Date.now() / 1000 + 3600, + }, + }) +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()) + root = undefined + queryClient?.clear() + api.defaults.adapter = originalAdapter + useAuthStore.getState().auth.reset() + document.body.replaceChildren() +}) +after(() => domWindow.close()) + +test('guest sees pricing links without an authenticated API request', async () => { + setUp() + const shadow = await mount() + assert.equal(requests.length, 0) + assert.match(shadow.textContent || '', /Sign in to view current prices/) + assert.equal( + shadow.querySelector('a')?.getAttribute('href'), + '/pricing?search=deepseek-v4-pro-0813' + ) + assert.equal(shadow.querySelector('.zone-model-prices'), null) +}) + +test('signing in shows server prices and filters unavailable models; signing out removes prices', async () => { + setUp() + const shadow = await mount() + await queryClient.prefetchQuery({ + queryKey: ['pricing', 'landing-model-zones', 7, 'landing-test-session'], + queryFn: getPricing, + }) + await act(async () => signIn()) + assert.deepEqual(requests, ['/api/pricing']) + assert.equal(shadow.querySelectorAll('.zone-model-card').length, 1) + assert.equal(shadow.querySelector('h4')?.textContent, 'DeepSeek V4 Pro') + const values = [...shadow.querySelectorAll('dd')].map( + (entry) => entry.textContent + ) + assert.deepEqual( + values, + ['input', 'output', 'cache'].map((type) => + formatPrice( + { ...catalog.data[0], group_ratio: catalog.group_ratio }, + type as 'input' | 'output' | 'cache', + 'M', + false, + 1, + 1, + 'default' + ) + ) + ) + await act(async () => useAuthStore.getState().auth.reset()) + assert.equal(shadow.querySelector('.zone-model-prices'), null) + assert.match(shadow.textContent || '', /Sign in to view current prices/) +}) + +test('a model outside usable groups is omitted even when present in the server catalog', async () => { + setUp() + queryClient.setQueryData( + ['pricing', 'landing-model-zones', 7, 'landing-test-session'], + { + ...catalog, + usable_group: { other: { desc: 'Other group', ratio: 1 } }, + } + ) + signIn() + const shadow = await mount() + assert.equal(shadow.querySelectorAll('.zone-model-card').length, 0) + assert.match(shadow.textContent || '', /No models available for this account/) +}) + +test('complex dynamic prices link to the full rules without inventing a numeric quote', async () => { + setUp() + queryClient.setQueryData( + ['pricing', 'landing-model-zones', 7, 'landing-test-session'], + { + ...catalog, + data: [ + { + ...catalog.data[0], + billing_mode: 'tiered_expr', + billing_expr: 'unsupported_special_expression(p)', + }, + ], + } + ) + signIn() + const shadow = await mount() + assert.equal(shadow.querySelectorAll('.zone-model-card').length, 1) + assert.equal(shadow.querySelector('.zone-model-prices'), null) + assert.match( + shadow.querySelector('.zone-model-card')?.textContent || '', + /Dynamic Pricing/ + ) + assert.equal( + shadow.querySelector('a')?.getAttribute('href'), + '/pricing?search=deepseek-v4-pro-0813&group=default' + ) +}) + +test('invalid custom manifests retain their static fallback instead of being hydrated', () => { + setUp() + const container = document.createElement('div') + container.innerHTML = + '
Pricing
' + assert.deepEqual(readLandingModelZones(container), []) + assert.equal(container.querySelector('a')?.getAttribute('href'), '/pricing') +}) diff --git a/web/src/features/home/components/landing-model-zones.tsx b/web/src/features/home/components/landing-model-zones.tsx new file mode 100644 index 000000000000..778b64ed26f0 --- /dev/null +++ b/web/src/features/home/components/landing-model-zones.tsx @@ -0,0 +1,197 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +*/ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState, type RefObject } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' + +import { getPricing } from '@/features/pricing/api' +import { + getDynamicPricingSummary, + getDynamicDisplayGroupRatio, + isDynamicPricingModel, +} from '@/features/pricing/lib/dynamic-price' +import { getAvailableGroups } from '@/features/pricing/lib/model-helpers' +import { formatPrice, formatRequestPrice } from '@/features/pricing/lib/price' +import type { PricingModel } from '@/features/pricing/types' +import { useAuthStore } from '@/stores/auth-store' + +import { + readLandingModelZones, + type ZoneTarget, +} from '../lib/landing-model-zones' + +function LandingModelPrice(props: { model: PricingModel; group: string }) { + const { t } = useTranslation() + const model = props.model + if (isDynamicPricingModel(model)) { + const summary = getDynamicPricingSummary(model, { + tokenUnit: 'M', + groupRatioMultiplier: getDynamicDisplayGroupRatio(model, props.group), + }) + if ( + !summary || + summary.isSpecialExpression || + summary.hasRequestRules || + summary.tierCount !== 1 + ) { + return

{t('Dynamic Pricing')}

+ } + return ( +
+ {summary.entries.map((entry) => ( +
+
{t(entry.shortLabel)}
+
{entry.formatted}
+
+ ))} +
+ ) + } + if (model.quota_type === 1) { + return ( +

+ {formatRequestPrice(model, false, 1, 1, props.group)} / {t('request')} +

+ ) + } + const types = ['input', 'output'] as const + return ( +
+ {types.map((type) => ( +
+
{type === 'input' ? t('Input') : t('Output')}
+
{formatPrice(model, type, 'M', false, 1, 1, props.group)}
+
+ ))} + {model.cache_ratio != null && ( +
+
{t('Cached')}
+
{formatPrice(model, 'cache', 'M', false, 1, 1, props.group)}
+
+ )} +
+ ) +} + +export function LandingModelZones(props: { + containerRef: RefObject + content: string +}) { + const { t } = useTranslation() + const { auth } = useAuthStore() + const authenticated = Boolean(auth.user && auth.accessToken) + const [targets, setTargets] = useState([]) + + useEffect(() => { + const shadow = props.containerRef.current?.querySelector( + '.custom-home-content' + )?.shadowRoot + const next = shadow ? readLandingModelZones(shadow) : [] + for (const target of next) target.element.replaceChildren() + setTargets(next) + }, [props.containerRef, props.content]) + + const pricing = useQuery({ + queryKey: [ + 'pricing', + 'landing-model-zones', + auth.user?.id, + auth.session?.sid, + ], + queryFn: async () => { + const response = await getPricing() + if (!response.success) throw new Error('Pricing unavailable') + return response + }, + enabled: authenticated && targets.length > 0, + retry: false, + staleTime: 60_000, + }) + + return targets.map((target) => + createPortal( + <> + {target.models.map((item) => { + const model = + authenticated && !pricing.isError + ? pricing.data?.data.find( + (entry) => entry.model_name === item.model_id + ) + : undefined + const groups = model + ? getAvailableGroups(model, pricing.data?.usable_group || {}) + : [] + if ( + authenticated && + pricing.isSuccess && + (!model || groups.length === 0) + ) { + return null + } + const userGroup = auth.user?.group + const group = + userGroup && groups.includes(userGroup) ? userGroup : groups[0] + const pricingSearch = new URLSearchParams({ search: item.model_id }) + if (group) pricingSearch.set('group', group) + const pricingHref = `/pricing?${pricingSearch}` + let status = t('Sign in to view current prices') + if (authenticated) { + status = pricing.isError + ? t('Current prices unavailable') + : t('Loading...') + } + return ( + + ) + })} + {authenticated && + pricing.isSuccess && + !target.models.some((item) => { + const model = pricing.data.data.find( + (entry) => entry.model_name === item.model_id + ) + return ( + model && + getAvailableGroups(model, pricing.data.usable_group).length > 0 + ) + }) && ( +

+ {t('No models available for this account')} +

+ )} + , + target.element, + target.element.closest('section')?.id + ) + ) +} diff --git a/web/src/features/home/index.tsx b/web/src/features/home/index.tsx index 6e97f75a5085..4567e84324f7 100644 --- a/web/src/features/home/index.tsx +++ b/web/src/features/home/index.tsx @@ -37,12 +37,14 @@ import { isLikelyHtml } from '@/lib/content-format' import { useAuthStore } from '@/stores/auth-store' import { CTA, Features, Hero, HowItWorks, Stats } from './components' +import { LandingModelZones } from './components/landing-model-zones' import { useHomePageContent } from './hooks' export function Home() { const { i18n, t } = useTranslation() const navigate = useNavigate() const iframeRef = useRef(null) + const customHomeRef = useRef(null) const { resolvedTheme } = useTheme() const { auth } = useAuthStore() const isAuthenticated = !!auth.user && !!auth.accessToken @@ -135,13 +137,16 @@ export function Home() { if (contentIsHtml) { return ( - +
+ + +
{ diff --git a/web/src/features/home/lib/landing-model-zones.ts b/web/src/features/home/lib/landing-model-zones.ts new file mode 100644 index 000000000000..67b0d59ba6d1 --- /dev/null +++ b/web/src/features/home/lib/landing-model-zones.ts @@ -0,0 +1,50 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +*/ +interface ZoneModel { + model_id: string + name: string + scenario: string + description: string +} + +export interface ZoneTarget { + element: HTMLElement + models: ZoneModel[] +} + +/** Read only the presentation manifest embedded by the Landing renderer. */ +export function readLandingModelZones(root: ParentNode): ZoneTarget[] { + const targets: ZoneTarget[] = [] + for (const element of root.querySelectorAll( + '[data-landing-models]' + )) { + try { + const models: unknown = JSON.parse(element.dataset.landingModels || '') + if ( + !Array.isArray(models) || + models.length === 0 || + models.length > 8 || + !models.every((model: unknown) => { + if (typeof model !== 'object' || model === null) return false + return ['model_id', 'name', 'scenario', 'description'].every( + (field) => + field in model && + typeof model[field as keyof typeof model] === 'string' + ) + }) + ) { + continue + } + targets.push({ element, models: models as ZoneModel[] }) + } catch { + // Invalid or older custom home content retains its ordinary pricing links. + } + } + return targets +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 763c50814ae2..f012ef0ed383 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,5 +1,9 @@ { "translation": { + "Sign in to view current prices": "Sign in to view current prices", + "Current prices unavailable": "Current prices unavailable. Please retry in the model catalog.", + "View current prices and access": "View current prices and access", + "No models available for this account": "No models available for this account", "360": "360", "1000": "1000", "10000": "10000", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 52e887b21e64..3f1b99b4347d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,5 +1,9 @@ { "translation": { + "Sign in to view current prices": "登录后查看实时价格", + "Current prices unavailable": "暂时无法读取价格,请到模型广场重试", + "View current prices and access": "查看实时价格与接入", + "No models available for this account": "当前账户暂无可用的专区模型", "360": "360", "1000": "1000", "10000": "10000", From d0fa04ac1f83b93ea67ba4d069f558c6adb03291 Mon Sep 17 00:00:00 2001 From: OnePlus_X <13660828666@139.com> Date: Sat, 17 Oct 2026 17:30:07 +0800 Subject: [PATCH 2/4] fix(home): refresh model prices when currency settings arrive --- .../__tests__/landing-model-zones.test.tsx | 26 +++++++++++++++++++ .../home/components/landing-model-zones.tsx | 3 +++ 2 files changed, 29 insertions(+) diff --git a/web/src/features/home/components/__tests__/landing-model-zones.test.tsx b/web/src/features/home/components/__tests__/landing-model-zones.test.tsx index 6daa3659976a..b8712ef5bacc 100644 --- a/web/src/features/home/components/__tests__/landing-model-zones.test.tsx +++ b/web/src/features/home/components/__tests__/landing-model-zones.test.tsx @@ -40,6 +40,8 @@ const { createInstance } = await import('i18next') const { I18nextProvider, initReactI18next } = await import('react-i18next') const { HtmlContent } = await import('@/components/html-content') const { useAuthStore } = await import('@/stores/auth-store') +const { useSystemConfigStore, DEFAULT_CURRENCY_CONFIG } = + await import('@/stores/system-config-store') const { api } = await import('@/lib/api') const { getPricing } = await import('@/features/pricing/api') const { formatPrice } = await import('@/features/pricing/lib/price') @@ -115,6 +117,9 @@ async function mount() { } function setUp() { + useSystemConfigStore + .getState() + .setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } }) useAuthStore.getState().auth.reset() requests = [] api.defaults.adapter = async (config) => { @@ -202,6 +207,27 @@ test('signing in shows server prices and filters unavailable models; signing out assert.match(shadow.textContent || '', /Sign in to view current prices/) }) +test('late currency settings refresh already visible model prices', async () => { + setUp() + queryClient.setQueryData( + ['pricing', 'landing-model-zones', 7, 'landing-test-session'], + catalog + ) + signIn() + const shadow = await mount() + assert.match(shadow.querySelector('dd')?.textContent || '', /^\$/) + await act(async () => + useSystemConfigStore.getState().setConfig({ + currency: { + ...DEFAULT_CURRENCY_CONFIG, + quotaDisplayType: 'CNY', + usdExchangeRate: 7, + }, + }) + ) + assert.match(shadow.querySelector('dd')?.textContent || '', /^¥14/) +}) + test('a model outside usable groups is omitted even when present in the server catalog', async () => { setUp() queryClient.setQueryData( diff --git a/web/src/features/home/components/landing-model-zones.tsx b/web/src/features/home/components/landing-model-zones.tsx index 778b64ed26f0..1f9ab0a2d1cf 100644 --- a/web/src/features/home/components/landing-model-zones.tsx +++ b/web/src/features/home/components/landing-model-zones.tsx @@ -21,6 +21,7 @@ import { getAvailableGroups } from '@/features/pricing/lib/model-helpers' import { formatPrice, formatRequestPrice } from '@/features/pricing/lib/price' import type { PricingModel } from '@/features/pricing/types' import { useAuthStore } from '@/stores/auth-store' +import { useSystemConfigStore } from '@/stores/system-config-store' import { readLandingModelZones, @@ -29,6 +30,8 @@ import { function LandingModelPrice(props: { model: PricingModel; group: string }) { const { t } = useTranslation() + // Existing formatters read this store; subscribe to late configuration updates. + useSystemConfigStore((state) => state.config.currency) const model = props.model if (isDynamicPricingModel(model)) { const summary = getDynamicPricingSummary(model, { From 13a6b688b70d1c106deeacefd61d4c07dbf0b410 Mon Sep 17 00:00:00 2001 From: OnePlus_X <13660828666@139.com> Date: Sat, 17 Oct 2026 19:02:41 +0800 Subject: [PATCH 3/4] feat: prepare isolated 15-day Bigfish packs and purchase guards --- .../subscription_bigfish_iflinkpay_test.go | 100 +++++++++ controller/subscription_payment_iflinkpay.go | 2 + model/bigfish_product.go | 64 ++++++ model/bigfish_product_test.go | 198 ++++++++++++++++++ model/cilai_contract.go | 19 +- model/subscription.go | 43 ++++ model/testdata/bigfish-draft.json | 137 ++++++++++++ .../checkout/__tests__/checkout.test.tsx | 4 +- .../components/model-id-list.test.tsx | 6 +- .../components/model-id-list.tsx | 5 +- web/src/features/traffic-packs/index.tsx | 4 +- .../wallet/components/recharge-form-card.tsx | 8 +- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 2 +- 14 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 controller/subscription_bigfish_iflinkpay_test.go create mode 100644 model/bigfish_product.go create mode 100644 model/bigfish_product_test.go create mode 100644 model/testdata/bigfish-draft.json diff --git a/controller/subscription_bigfish_iflinkpay_test.go b/controller/subscription_bigfish_iflinkpay_test.go new file mode 100644 index 000000000000..a20afb2458d9 --- /dev/null +++ b/controller/subscription_bigfish_iflinkpay_test.go @@ -0,0 +1,100 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestIFLinkPayBigfishRetryKeepsSignedOrderAndCannotPaySecondTier(t *testing.T) { + user := setupIFLinkPayHTTPTest(t) + require.NoError(t, model.DB.AutoMigrate(&model.SubscriptionPlan{}, &model.UserSubscription{}, &model.ProductPrice{})) + db := model.DB + t.Cleanup(func() { + require.NoError(t, db.Where("product_type = ?", model.ProductTypePackDeepSeekBigfish).Delete(&model.SubscriptionOrder{}).Error) + require.NoError(t, db.Where("product_type = ?", model.ProductTypePackDeepSeekBigfish).Delete(&model.SubscriptionPlan{}).Error) + require.NoError(t, db.Where("product_type = ?", model.ProductTypePackDeepSeekBigfish).Delete(&model.ProductPrice{}).Error) + }) + data, err := os.ReadFile("../model/testdata/bigfish-draft.json") + require.NoError(t, err) + var catalog struct { + Prices []struct { + Price model.ProductPrice `json:"price"` + } `json:"price_requests"` + Plans []struct { + Plan model.SubscriptionPlan `json:"plan"` + } `json:"plan_requests"` + } + require.NoError(t, common.Unmarshal(data, &catalog)) + for _, item := range catalog.Prices { + item.Price.Enabled = true + require.NoError(t, model.DB.Create(&item.Price).Error) + } + for index := range catalog.Plans { + plan := &catalog.Plans[index].Plan + require.NoError(t, model.DB.Create(plan).Error) + require.False(t, plan.Enabled) + require.False(t, plan.Listed) + require.False(t, plan.RedemptionEnabled) + require.NoError(t, model.DB.Model(plan).Update("enabled", true).Error) + model.InvalidateSubscriptionPlanCache(plan.Id) + planId := plan.Id + t.Cleanup(func() { model.InvalidateSubscriptionPlanCache(planId) }) + } + router := gin.New() + router.POST("/api/subscription/iflinkpay/pay", func(c *gin.Context) { c.Set("id", user.Id); c.Next() }, SubscriptionRequestIFLinkPay) + firstTrade := "" + for range 2 { + body := `{"plan_id":` + strconv.Itoa(catalog.Plans[0].Plan.Id) + `}` + request := httptest.NewRequest(http.MethodPost, "/api/subscription/iflinkpay/pay", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + var result struct { + Success bool `json:"success"` + Data struct { + PayURL string `json:"pay_url"` + TradeNo string `json:"trade_no"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &result)) + require.True(t, result.Success, recorder.Body.String()) + if firstTrade == "" { + firstTrade = result.Data.TradeNo + } + require.Equal(t, firstTrade, result.Data.TradeNo) + target, err := url.Parse(result.Data.PayURL) + require.NoError(t, err) + query := target.Query() + require.Equal(t, firstTrade, query.Get("order")) + require.Equal(t, firstTrade, query.Get("uid")) + require.Equal(t, "990", query.Get("amount")) + ts, err := strconv.ParseInt(query.Get("ts"), 10, 64) + require.NoError(t, err) + require.Equal(t, ifLinkPaySign(firstTrade, 990, firstTrade, ts, operation_setting.GetIFLinkPaySecret()), query.Get("sign")) + } + request := httptest.NewRequest(http.MethodPost, "/api/subscription/iflinkpay/pay", strings.NewReader(`{"plan_id":`+strconv.Itoa(catalog.Plans[1].Plan.Id)+`}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + var rejected struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &rejected)) + require.False(t, rejected.Success) + require.Contains(t, rejected.Message, "待支付") + var count int64 + require.NoError(t, model.DB.Model(&model.SubscriptionOrder{}).Where("user_id = ? AND product_type = ?", user.Id, model.ProductTypePackDeepSeekBigfish).Count(&count).Error) + require.Equal(t, int64(1), count) +} diff --git a/controller/subscription_payment_iflinkpay.go b/controller/subscription_payment_iflinkpay.go index 043eeeda81f1..3a95ac15a4f5 100644 --- a/controller/subscription_payment_iflinkpay.go +++ b/controller/subscription_payment_iflinkpay.go @@ -75,6 +75,8 @@ func SubscriptionRequestIFLinkPay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } + // A Bigfish checkout retry resumes its existing pending order. + tradeNo = order.TradeNo amountFen := int64(math.Round(order.Money * 100)) if amountFen <= 0 { _ = model.ExpireSubscriptionOrder(tradeNo, model.PaymentProviderIFLinkPay) diff --git a/model/bigfish_product.go b/model/bigfish_product.go new file mode 100644 index 000000000000..2b6a2e715338 --- /dev/null +++ b/model/bigfish_product.go @@ -0,0 +1,64 @@ +package model + +import ( + "errors" + "math" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +const ( + BigfishProductFamily = "deepseek-bigfish-promo-20261017" + BigfishPriceVersion = "deepseek-bigfish-20261017-v1" +) + +// Bigfish is a separate, one-time promotion. It cannot inherit the classroom +// SKU's duration or the monthly tier/upgrade contract, despite similar names. +func validateBigfishPlan(plan *SubscriptionPlan, policy *ProductRoutePolicy) error { + prices := map[string]float64{ + "deepseek-bigfish-lite-20261017": 9.9, + "deepseek-bigfish-pro-20261017": 19.9, + "deepseek-bigfish-max-20261017": 39.9, + } + price, ok := prices[plan.ProductCode] + if !ok || math.Abs(plan.PriceAmount-price) > 0.000001 { + return errors.New("bigfish requires a distinct Lite/Pro/Max product code and price") + } + if plan.ProductFamily != BigfishProductFamily || plan.PriceVersion != BigfishPriceVersion || plan.ProductTier != 0 { + return errors.New("bigfish requires an independent promotion family and price version; no monthly tier") + } + if plan.MaxPurchasePerUser != 1 || plan.RedemptionEnabled || plan.AllowBalancePay == nil || *plan.AllowBalancePay || plan.UpgradeGroup != "" || plan.DowngradeGroup != "" { + return errors.New("bigfish is purchase-once, without redemption, balance payment or group changes") + } + if !sameStringSet(explicitModelIds(plan.ModelScope), []string{ClassroomModelV4GA, ClassroomModelV41}) || len(policy.AllowedChannels) != 2 || len(policy.MaxUpstreamCost) != 2 { + return errors.New("all bigfish tiers must share exactly the two audited Flash models") + } + for _, modelName := range []string{ClassroomModelV4GA, ClassroomModelV41} { + channels := policy.AllowedChannels[modelName] + if len(channels) != 1 || channels[0] != 15 { + return errors.New("bigfish Flash models must be restricted to channel 15") + } + } + return nil +} + +// Callers hold the user row lock, serializing issuance across all three SKUs. +// Expired/cancelled/spent entitlements still count; buying again never renews +// the promotion. Successful order history also survives entitlement deletion. +func checkBigfishPurchaseHistoryTx(tx *gorm.DB, userId int) error { + var count int64 + if err := tx.Model(&UserSubscription{}).Where("user_id = ? AND product_type = ? AND product_family = ?", userId, ProductTypePackDeepSeekBigfish, BigfishProductFamily).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("大肥鱼活动三档合计每账号限购一份,不叠加、不续期") + } + if err := tx.Model(&SubscriptionOrder{}).Where("user_id = ? AND product_type = ? AND product_family = ? AND status = ?", userId, ProductTypePackDeepSeekBigfish, BigfishProductFamily, common.TopUpStatusSuccess).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("该账号已完成大肥鱼活动购买,不可重复购买其他档位") + } + return nil +} diff --git a/model/bigfish_product_test.go b/model/bigfish_product_test.go new file mode 100644 index 000000000000..9ff107c88b62 --- /dev/null +++ b/model/bigfish_product_test.go @@ -0,0 +1,198 @@ +package model + +import ( + "os" + "sync" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/pkg/billingexpr" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This fixture is generated by the parent repo's offline bigfish_products.py. +// Only the test database enables it; the committed payloads are disabled/hidden. +func setupBigfishProductsTest(t *testing.T) []SubscriptionPlan { + t.Helper() + setupMonthlyProductsTest(t) + data, err := os.ReadFile("testdata/bigfish-draft.json") + require.NoError(t, err) + var catalog struct { + Prices []struct { + Price ProductPrice `json:"price"` + } `json:"price_requests"` + Plans []struct { + Plan SubscriptionPlan `json:"plan"` + } `json:"plan_requests"` + } + require.NoError(t, common.Unmarshal(data, &catalog)) + require.Len(t, catalog.Prices, 2) + require.Len(t, catalog.Plans, 3) + for _, item := range catalog.Prices { + require.False(t, item.Price.Enabled) + item.Price.Enabled = true + require.NoError(t, DB.Create(&item.Price).Error) + } + plans := make([]SubscriptionPlan, 0, 3) + for _, item := range catalog.Plans { + require.False(t, item.Plan.Enabled) + require.False(t, item.Plan.Listed) + require.NoError(t, DB.Create(&item.Plan).Error) + require.False(t, item.Plan.Enabled) + require.False(t, item.Plan.Listed) + require.False(t, item.Plan.RedemptionEnabled) + require.NoError(t, DB.Model(&item.Plan).Update("enabled", true).Error) + item.Plan.Enabled = true + plans = append(plans, item.Plan) + } + return plans +} + +func TestBigfishDraftCatalogIsSeparateFromMonthlyAndClassroom(t *testing.T) { + plans := setupBigfishProductsTest(t) + wantQuota := []int64{4_950_000, 9_950_000, 19_950_000} + wantPrice := []float64{9.9, 19.9, 39.9} + for index, plan := range plans { + assert.Equal(t, wantPrice[index], plan.PriceAmount) + assert.Equal(t, wantQuota[index], plan.TotalAmount) + assert.Equal(t, SubscriptionCategoryPackage, plan.Category) + assert.Equal(t, 15, plan.DurationValue) + assert.Equal(t, SubscriptionResetNever, plan.QuotaResetPeriod) + assert.Equal(t, plans[0].ModelScope, plan.ModelScope) + assert.False(t, *plan.AllowWalletOverflow) + assert.False(t, plan.CourseEligible) + broken := plan + broken.DurationValue = 30 + require.Error(t, broken.ValidateV1Contract()) + broken = plan + broken.ProductCode = "pro" + require.Error(t, broken.ValidateV1Contract()) + broken = plan + broken.MaxPurchasePerUser = 2 + require.Error(t, broken.ValidateV1Contract()) + broken = plan + broken.ModelScope += ",deepseek-v4-pro-0813" + require.Error(t, broken.ValidateV1Contract()) + } + var monthly []SubscriptionPlan + require.NoError(t, DB.Where("product_type = ?", ProductTypeMonthlySubscription).Order("product_tier").Find(&monthly).Error) + require.Len(t, monthly, 3) + for index, plan := range monthly { + assert.Equal(t, 30, plan.DurationValue) + assert.Equal(t, []float64{19.9, 49.9, 99.9}[index], plan.PriceAmount) + assert.Contains(t, plan.ModelScope, MonthlyModelQwen) + } + classroom, err := classroomDesiredPlan(false, false, 2_500_000) + require.NoError(t, err) + assert.Equal(t, 10, classroom.DurationValue) + assert.Equal(t, 5.0, classroom.PriceAmount) + assert.Contains(t, classroom.ModelScope, ClassroomModelQwen) +} + +func TestBigfishExpressionAppliesMultiplierOnceAcrossAllTokenLegsAndWindows(t *testing.T) { + setupBigfishProductsTest(t) + zone, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, test := range []struct { + name string + model string + time time.Time + params billingexpr.TokenParams + quota int + }{ + {"ga-input", ClassroomModelV4GA, time.Time{}, billingexpr.TokenParams{P: 1_000_000}, 900_000}, + {"ga-cache", ClassroomModelV4GA, time.Time{}, billingexpr.TokenParams{CR: 1_000_000}, 30_000}, + {"ga-output", ClassroomModelV4GA, time.Time{}, billingexpr.TokenParams{C: 1_000_000}, 2_700_000}, + {"peak-input", ClassroomModelV41, time.Date(2026, 10, 12, 9, 0, 0, 0, zone), billingexpr.TokenParams{P: 1_000_000}, 600_000}, + {"peak-cache", ClassroomModelV41, time.Date(2026, 10, 12, 9, 0, 0, 0, zone), billingexpr.TokenParams{CR: 1_000_000}, 12_000}, + {"peak-output", ClassroomModelV41, time.Date(2026, 10, 12, 9, 0, 0, 0, zone), billingexpr.TokenParams{C: 1_000_000}, 2_400_000}, + {"offpeak-input", ClassroomModelV41, time.Date(2026, 10, 12, 12, 0, 0, 0, zone), billingexpr.TokenParams{P: 1_000_000}, 300_000}, + {"offpeak-cache", ClassroomModelV41, time.Date(2026, 10, 12, 12, 0, 0, 0, zone), billingexpr.TokenParams{CR: 1_000_000}, 6_000}, + {"weekend-output", ClassroomModelV41, time.Date(2026, 10, 17, 10, 0, 0, 0, zone), billingexpr.TokenParams{C: 1_000_000}, 1_200_000}, + } { + t.Run(test.name, func(t *testing.T) { + price, err := GetProductPriceSnapshot(ProductTypePackDeepSeekBigfish, test.model, BigfishPriceVersion) + require.NoError(t, err) + snapshot := &billingexpr.BillingSnapshot{BillingMode: "tiered_expr", ExprString: price.BillingExpression, ExprHash: billingexpr.ExprHashString(price.BillingExpression), GroupRatio: 1, QuotaPerUnit: common.QuotaPerUnit, ProductType: price.ProductType, ProductMultiplier: price.BillingMultiplier, PriceVersion: price.PriceVersion} + result, err := billingexpr.ComputeTieredQuotaWithRequest(snapshot, test.params, billingexpr.RequestInput{EvaluationUnix: test.time.Unix()}) + require.NoError(t, err) + assert.Equal(t, test.quota, result.ActualQuotaAfterGroup) + }) + } +} + +func TestBigfishIFLinkPaySharedPoolKeyExpiryAndCrossTierLimit(t *testing.T) { + for index := range 3 { + t.Run([]string{"lite", "pro", "max"}[index], func(t *testing.T) { + plans := setupBigfishProductsTest(t) + plan := plans[index] + other := plans[(index+1)%3] + user := createV1ContractUser(t, "bigfish-purchase-user") + order, _, err := CreateSubscriptionOrder(user.Id, plan.Id, "bigfish-first", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.NoError(t, err) + retry, _, err := CreateSubscriptionOrder(user.Id, plan.Id, "bigfish-retry", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.NoError(t, err) + assert.Equal(t, order.TradeNo, retry.TradeNo) + _, _, err = CreateSubscriptionOrder(user.Id, other.Id, "bigfish-other", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.ErrorContains(t, err, "待支付") + receipt := PaymentReceipt{Amount: decimal.NewFromFloat(plan.PriceAmount), Currency: "CNY", ProviderTradeNo: "bigfish-provider-receipt"} + require.NoError(t, CompleteSubscriptionOrderWithReceipt(order.TradeNo, PaymentProviderIFLinkPay, PaymentMethodIFLinkPay, receipt)) + require.NoError(t, CompleteSubscriptionOrderWithReceipt(order.TradeNo, PaymentProviderIFLinkPay, PaymentMethodIFLinkPay, receipt)) + completed := GetSubscriptionOrderByTradeNo(order.TradeNo) + require.NotNil(t, completed.UserSubscriptionId) + var sub UserSubscription + require.NoError(t, DB.First(&sub, *completed.UserSubscriptionId).Error) + assert.Equal(t, plan.TotalAmount, sub.AmountTotal) + assert.Equal(t, int64(15*24*3600), sub.EndTime-sub.StartTime) + var bindings []SubscriptionKeyBinding + require.NoError(t, DB.Where("user_subscription_id = ?", sub.Id).Find(&bindings).Error) + require.Len(t, bindings, 1) + var token Token + require.NoError(t, DB.First(&token, bindings[0].TokenId).Error) + assert.Equal(t, sub.EndTime, token.ExpiredTime) + assert.Equal(t, ProductTypePackDeepSeekBigfish, token.ProductType) + assert.False(t, sub.AllowWalletOverflow) + _, err = PreConsumeUserSubscription("bigfish-flash-a", user.Id, ClassroomModelV4GA, 0, 100, sub.Id) + require.NoError(t, err) + _, err = PreConsumeUserSubscription("bigfish-flash-b", user.Id, ClassroomModelV41, 0, 200, sub.Id) + require.NoError(t, err) + _, err = PreConsumeUserSubscription("bigfish-disallowed", user.Id, "deepseek-v4-pro-0813", 0, 100, sub.Id) + require.Error(t, err) + require.NoError(t, DB.First(&sub, sub.Id).Error) + assert.Equal(t, int64(300), sub.AmountUsed) + _, err = PreConsumeUserSubscription("bigfish-drain", user.Id, ClassroomModelV4GA, 0, sub.AmountTotal-300, sub.Id) + require.NoError(t, err) + _, err = PreConsumeUserSubscription("bigfish-exhausted", user.Id, ClassroomModelV41, 0, 1, sub.Id) + require.Error(t, err) + require.NoError(t, DB.Model(&sub).Updates(map[string]interface{}{"end_time": common.GetTimestamp() - 1, "status": "expired"}).Error) + _, err = PreConsumeUserSubscription("bigfish-expired", user.Id, ClassroomModelV4GA, 0, 1, sub.Id) + require.Error(t, err) + _, _, err = CreateSubscriptionOrder(user.Id, other.Id, "bigfish-after-expiry", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.ErrorContains(t, err, "限购一份") + }) + } +} + +func TestBigfishConcurrentOrdersCannotReserveDifferentTiers(t *testing.T) { + plans := setupBigfishProductsTest(t) + user := createV1ContractUser(t, "bigfish-concurrent-user") + var start sync.WaitGroup + start.Add(1) + results := make(chan error, 2) + for index := range 2 { + go func(index int) { + start.Wait() + _, _, err := CreateSubscriptionOrder(user.Id, plans[index].Id, []string{"bigfish-race-a", "bigfish-race-b"}[index], PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + results <- err + }(index) + } + start.Done() + first, second := <-results, <-results + assert.True(t, (first == nil) != (second == nil), "exactly one tier may reserve the promotion") + var count int64 + require.NoError(t, DB.Model(&SubscriptionOrder{}).Where("user_id = ? AND product_family = ?", user.Id, BigfishProductFamily).Count(&count).Error) + assert.Equal(t, int64(1), count) +} diff --git a/model/cilai_contract.go b/model/cilai_contract.go index 3948fb21ec12..caabb07a84cd 100644 --- a/model/cilai_contract.go +++ b/model/cilai_contract.go @@ -19,6 +19,7 @@ const ( ProductTypePackQwen = "pack_qwen" ProductTypePackK3 = "pack_k3" ProductTypePackDeepSeekClassroom = "pack_deepseek_classroom" + ProductTypePackDeepSeekBigfish = "pack_deepseek_bigfish" ProductTypeActivationCredit = "activation_credit" ProductTypeLegacySubscription = "legacy_subscription" @@ -52,6 +53,7 @@ var frozenV1BillingMultipliers = map[string]float64{ ProductTypePackQwen: 0.75, ProductTypePackK3: 0.95, ProductTypePackDeepSeekClassroom: 0.60, + ProductTypePackDeepSeekBigfish: 0.60, } type PriceComponents struct { @@ -119,7 +121,7 @@ func IsSubscriptionFundedProduct(productType string) bool { func RequiresStrictV1RoutePolicy(productType string) bool { switch strings.TrimSpace(productType) { - case ProductTypeMonthlySubscription, ProductTypePackDSGLM, ProductTypePackQwen, ProductTypePackK3, ProductTypePackDeepSeekClassroom: + case ProductTypeMonthlySubscription, ProductTypePackDSGLM, ProductTypePackQwen, ProductTypePackK3, ProductTypePackDeepSeekClassroom, ProductTypePackDeepSeekBigfish: return true default: return false @@ -192,6 +194,9 @@ func (p *SubscriptionPlan) ValidateV1Contract() error { if p.ProductType == ProductTypePackDeepSeekClassroom { expectedDurationDays = 10 } + if p.ProductType == ProductTypePackDeepSeekBigfish { + expectedDurationDays = 15 + } if p.DurationUnit != SubscriptionDurationDay || p.DurationValue != expectedDurationDays { return fmt.Errorf("%s must have a fixed %d-day duration", p.ProductType, expectedDurationDays) } @@ -312,8 +317,13 @@ func (p *SubscriptionPlan) ValidateV1Contract() error { } } } + if p.ProductType == ProductTypePackDeepSeekBigfish { + if err := validateBigfishPlan(p, policy); err != nil { + return err + } + } if NormalizeResetPeriod(p.QuotaResetPeriod) != SubscriptionResetNever { - return errors.New("V1 products cannot reset quota inside their 30-day entitlement") + return errors.New("V1 products cannot reset quota inside their entitlement") } expectedQuota, err := common.QuotaFromFloatStrict(p.PriceAmount * common.QuotaPerUnit) if err != nil { @@ -427,6 +437,11 @@ func (p *ProductPrice) ValidateV1() error { return fmt.Errorf("classroom product price for %s must be restricted to channel %d", p.Model, expectedChannel) } } + if p.ProductType == ProductTypePackDeepSeekBigfish { + if p.PriceVersion != BigfishPriceVersion || (p.Model != ClassroomModelV4GA && p.Model != ClassroomModelV41) || len(channelIds) != 1 || channelIds[0] != 15 { + return errors.New("bigfish prices require the audited Flash IDs, channel 15 and independent price version") + } + } var cost PriceComponents if err := common.UnmarshalJsonStr(strings.TrimSpace(p.MaxUpstreamCost), &cost); err != nil || !validPositivePriceComponents(cost) { return errors.New("max_upstream_cost must contain valid price components") diff --git a/model/subscription.go b/model/subscription.go index 0df586f8335b..673ca2528257 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -243,9 +243,26 @@ func (p *SubscriptionPlan) BeforeCreate(tx *gorm.DB) error { now := common.GetTimestamp() p.CreatedAt = now p.UpdatedAt = now + if p.ProductType == ProductTypePackDeepSeekBigfish { + p.NormalizeDefaults() + return p.ValidateV1Contract() + } return nil } +func (p *SubscriptionPlan) AfterCreate(tx *gorm.DB) error { + if p.ProductType != ProductTypePackDeepSeekBigfish { + return nil + } + // GORM's legacy default:true tags would otherwise turn explicit false into + // a live/listed/redeemable SKU. New Bigfish products always start as drafts; + // reviewed activation must be an explicit, separate admin update. + p.Enabled, p.Listed, p.RedemptionEnabled = false, false, false + return tx.Model(p).UpdateColumns(map[string]interface{}{ + "enabled": false, "listed": false, "redemption_enabled": false, + }).Error +} + func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error { p.UpdatedAt = common.GetTimestamp() return nil @@ -493,6 +510,23 @@ func createSubscriptionOrderTx(tx *gorm.DB, userId int, planId int, tradeNo stri if err := lockForUpdate(tx).Select("id").Where("id = ?", userId).First(&lockedUser).Error; err != nil { return nil, nil, err } + if plan.ProductType == ProductTypePackDeepSeekBigfish { + if err := checkBigfishPurchaseHistoryTx(tx, userId); err != nil { + return nil, nil, err + } + var pending SubscriptionOrder + result := tx.Where("user_id = ? AND product_type = ? AND product_family = ? AND status = ?", userId, ProductTypePackDeepSeekBigfish, BigfishProductFamily, common.TopUpStatusPending).Order("id").Limit(1).Find(&pending) + if result.Error != nil { + return nil, nil, result.Error + } + if result.RowsAffected > 0 { + if pending.PlanId == plan.Id && pending.PaymentProvider == PaymentProviderIFLinkPay && paymentProvider == PaymentProviderIFLinkPay && pending.PaymentMethod == paymentMethod { + frozen, err := provisionPlanFromOrder(&pending, plan) + return &pending, frozen, err + } + return nil, nil, errors.New("已有待支付的大肥鱼活动订单,请完成原订单或联系管理员取消后再选档") + } + } order := &SubscriptionOrder{ UserId: userId, @@ -852,6 +886,15 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio return nil, err } nowUnix := getDBTimestampTx(tx) + if plan.ProductType == ProductTypePackDeepSeekBigfish { + var lockedUser User + if err := lockForUpdate(tx).Select("id").Where("id = ?", userId).First(&lockedUser).Error; err != nil { + return nil, err + } + if err := checkBigfishPurchaseHistoryTx(tx, userId); err != nil { + return nil, err + } + } var monthlyCycle *MonthlySubscriptionCycle periodId := "" if plan.ProductType == ProductTypeMonthlySubscription { diff --git a/model/testdata/bigfish-draft.json b/model/testdata/bigfish-draft.json new file mode 100644 index 000000000000..b05faf0b8727 --- /dev/null +++ b/model/testdata/bigfish-draft.json @@ -0,0 +1,137 @@ +{ + "stage": "draft", + "launch_blockers": [ + "确认 V4.1 Flash 闲时上游实际结算是否减半,不能把静态峰时成本当作已核实的闲时成本", + "核实存量精确模型版本与官方现售版本的对应关系及中国法定节假日计费口径;暂不宣传六折", + "确认三档合计每账号限购一份、不续期不叠加的活动规则,以及实际开售和停售时间", + "确认用户提供的三张美术素材的商业使用授权" + ], + "price_requests": [ + { + "price": { + "product_type": "pack_deepseek_bigfish", + "model": "deepseek-v4-flash-ga-260731", + "price_version": "deepseek-bigfish-20261017-v1", + "effective_at": 0, + "billing_expression": "tier(\"base\", p * 3 + cr * 0.1 + c * 9)", + "billing_multiplier": 0.6, + "official_reference_price": "{\"input\":3.0,\"cached_input\":0.1,\"output\":9.0}", + "user_selling_price": "{\"input\":1.8,\"cached_input\":0.06,\"output\":5.4}", + "upstream_settlement_price": "{\"input\":1.5,\"cached_input\":0.05,\"output\":4.5}", + "allowed_channels": "[15]", + "max_upstream_cost": "{\"input\":1.5,\"cached_input\":0.05,\"output\":4.5}", + "fallback_allowed": false, + "enabled": false + } + }, + { + "price": { + "product_type": "pack_deepseek_bigfish", + "model": "deepseek-v4-1-flash-260910", + "price_version": "deepseek-bigfish-20261017-v1", + "effective_at": 0, + "billing_expression": "weekday(\"Asia/Shanghai\") >= 1 && weekday(\"Asia/Shanghai\") <= 5 && ((hour(\"Asia/Shanghai\") >= 9 && hour(\"Asia/Shanghai\") < 12) || (hour(\"Asia/Shanghai\") >= 14 && hour(\"Asia/Shanghai\") < 18)) ? tier(\"peak\", p * 2 + cr * 0.04 + c * 8) : tier(\"offpeak\", p * 1 + cr * 0.02 + c * 4)", + "billing_multiplier": 0.6, + "official_reference_price": "{\"input\":2.0,\"cached_input\":0.04,\"output\":8.0}", + "user_selling_price": "{\"input\":1.2,\"cached_input\":0.024,\"output\":4.8}", + "upstream_settlement_price": "{\"input\":1.0,\"cached_input\":0.02,\"output\":4.0}", + "allowed_channels": "[15]", + "max_upstream_cost": "{\"input\":1.0,\"cached_input\":0.02,\"output\":4.0}", + "fallback_allowed": false, + "enabled": false + } + } + ], + "plan_requests": [ + { + "plan": { + "title": "DeepSeek 大肥鱼 Lite", + "subtitle": "19,800 算力点 · DeepSeek 专属 · 支付成功后 15 天有效 · 三档合计每账号限购一份", + "product_type": "pack_deepseek_bigfish", + "product_code": "deepseek-bigfish-lite-20261017", + "product_family": "deepseek-bigfish-promo-20261017", + "product_tier": 0, + "price_amount": 9.9, + "currency": "CNY", + "total_amount": 4950000, + "duration_unit": "day", + "duration_value": 15, + "quota_reset_period": "never", + "category": "package", + "billing_multiplier": 0.6, + "price_version": "deepseek-bigfish-20261017-v1", + "model_scope": "deepseek-v4-flash-ga-260731,deepseek-v4-1-flash-260910", + "allowed_channels": "{\"deepseek-v4-flash-ga-260731\":[15],\"deepseek-v4-1-flash-260910\":[15]}", + "max_upstream_cost": "{\"deepseek-v4-flash-ga-260731\":{\"input\":1.5,\"cached_input\":0.05,\"output\":4.5},\"deepseek-v4-1-flash-260910\":{\"input\":1.0,\"cached_input\":0.02,\"output\":4.0}}", + "fallback_allowed": false, + "allow_wallet_overflow": false, + "allow_balance_pay": false, + "max_purchase_per_user": 1, + "course_eligible": false, + "enabled": false, + "listed": false, + "redemption_enabled": false + } + }, + { + "plan": { + "title": "DeepSeek 大肥鱼 Pro", + "subtitle": "39,800 算力点 · DeepSeek 专属 · 支付成功后 15 天有效 · 三档合计每账号限购一份", + "product_type": "pack_deepseek_bigfish", + "product_code": "deepseek-bigfish-pro-20261017", + "product_family": "deepseek-bigfish-promo-20261017", + "product_tier": 0, + "price_amount": 19.9, + "currency": "CNY", + "total_amount": 9950000, + "duration_unit": "day", + "duration_value": 15, + "quota_reset_period": "never", + "category": "package", + "billing_multiplier": 0.6, + "price_version": "deepseek-bigfish-20261017-v1", + "model_scope": "deepseek-v4-flash-ga-260731,deepseek-v4-1-flash-260910", + "allowed_channels": "{\"deepseek-v4-flash-ga-260731\":[15],\"deepseek-v4-1-flash-260910\":[15]}", + "max_upstream_cost": "{\"deepseek-v4-flash-ga-260731\":{\"input\":1.5,\"cached_input\":0.05,\"output\":4.5},\"deepseek-v4-1-flash-260910\":{\"input\":1.0,\"cached_input\":0.02,\"output\":4.0}}", + "fallback_allowed": false, + "allow_wallet_overflow": false, + "allow_balance_pay": false, + "max_purchase_per_user": 1, + "course_eligible": false, + "enabled": false, + "listed": false, + "redemption_enabled": false + } + }, + { + "plan": { + "title": "DeepSeek 大肥鱼 Max", + "subtitle": "79,800 算力点 · DeepSeek 专属 · 支付成功后 15 天有效 · 三档合计每账号限购一份", + "product_type": "pack_deepseek_bigfish", + "product_code": "deepseek-bigfish-max-20261017", + "product_family": "deepseek-bigfish-promo-20261017", + "product_tier": 0, + "price_amount": 39.9, + "currency": "CNY", + "total_amount": 19950000, + "duration_unit": "day", + "duration_value": 15, + "quota_reset_period": "never", + "category": "package", + "billing_multiplier": 0.6, + "price_version": "deepseek-bigfish-20261017-v1", + "model_scope": "deepseek-v4-flash-ga-260731,deepseek-v4-1-flash-260910", + "allowed_channels": "{\"deepseek-v4-flash-ga-260731\":[15],\"deepseek-v4-1-flash-260910\":[15]}", + "max_upstream_cost": "{\"deepseek-v4-flash-ga-260731\":{\"input\":1.5,\"cached_input\":0.05,\"output\":4.5},\"deepseek-v4-1-flash-260910\":{\"input\":1.0,\"cached_input\":0.02,\"output\":4.0}}", + "fallback_allowed": false, + "allow_wallet_overflow": false, + "allow_balance_pay": false, + "max_purchase_per_user": 1, + "course_eligible": false, + "enabled": false, + "listed": false, + "redemption_enabled": false + } + } + ] +} diff --git a/web/src/features/checkout/__tests__/checkout.test.tsx b/web/src/features/checkout/__tests__/checkout.test.tsx index b4b7cd00f8a8..5239e81a01b7 100644 --- a/web/src/features/checkout/__tests__/checkout.test.tsx +++ b/web/src/features/checkout/__tests__/checkout.test.tsx @@ -181,8 +181,8 @@ test('renders server-owned product details and starts direct IFLinkPay checkout' assert.equal(text.includes('¥5.00'), true) assert.equal(text.includes('10,000 算力点'), true) assert.equal(text.includes('qwen3.8-flash'), true) - assert.equal(text.includes('官网价 ×1.00'), true) - assert.equal(text.includes('官网价 ×0.60'), true) + assert.equal(text.includes('Versioned base price ×1.00'), true) + assert.equal(text.includes('Versioned base price ×0.60'), true) assert.equal(text.includes('使用 IFLinkPay 支付'), true) assert.equal(text.includes('支付宝'), false) diff --git a/web/src/features/traffic-packs/components/model-id-list.test.tsx b/web/src/features/traffic-packs/components/model-id-list.test.tsx index 504c8ae056eb..bd8c2e06523e 100644 --- a/web/src/features/traffic-packs/components/model-id-list.test.tsx +++ b/web/src/features/traffic-packs/components/model-id-list.test.tsx @@ -52,6 +52,7 @@ await i18n.use(initReactI18next).init({ zh: { translation: { Copied: '已复制', + 'Versioned base price': '版本基准价', }, }, }, @@ -110,8 +111,9 @@ test('copies the exact API model ID and renders model-level pricing', async () = }) await renderList() - assert.match(host?.textContent || '', /官网价 ×1\.00/) - assert.match(host?.textContent || '', /官网价 ×0\.60/) + assert.match(host?.textContent || '', /版本基准价 ×1\.00/) + assert.match(host?.textContent || '', /版本基准价 ×0\.60/) + assert.equal(host?.textContent?.includes('官网价'), false) const button = host?.querySelector( '[aria-label="复制模型 ID qwen3.8-flash"]' ) diff --git a/web/src/features/traffic-packs/components/model-id-list.tsx b/web/src/features/traffic-packs/components/model-id-list.tsx index 6df8e45a8221..48bfa2e540e1 100644 --- a/web/src/features/traffic-packs/components/model-id-list.tsx +++ b/web/src/features/traffic-packs/components/model-id-list.tsx @@ -6,6 +6,8 @@ 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. */ +import { useTranslation } from 'react-i18next' + import { CopyButton } from '@/components/copy-button' interface ModelIdListProps { @@ -19,6 +21,7 @@ export function ModelIdList({ billingMultipliers = {}, title = '可用模型', }: ModelIdListProps) { + const { t } = useTranslation() if (models.length === 0) return null return ( @@ -37,7 +40,7 @@ export function ModelIdList({ {typeof multiplier === 'number' && Number.isFinite(multiplier) ? ( - 官网价 ×{multiplier.toFixed(2)} + {t('Versioned base price')} ×{multiplier.toFixed(2)} ) : null} - 官网价 × + {t('Versioned base price')} × {Number(plan.billing_multiplier || 1).toFixed( 2 )}{' '} @@ -707,7 +707,7 @@ export function TrafficPacks({

{Object.keys(billingMultipliers).length > 0 ? '按模型计价' - : `官网价 ×${Number( + : `${t('Versioned base price')} ×${Number( sub.billing_multiplier || 1 ).toFixed(2)}`}

diff --git a/web/src/features/wallet/components/recharge-form-card.tsx b/web/src/features/wallet/components/recharge-form-card.tsx index 684c6b4820ad..980b6277d27e 100644 --- a/web/src/features/wallet/components/recharge-form-card.tsx +++ b/web/src/features/wallet/components/recharge-form-card.tsx @@ -34,10 +34,7 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' -import { - cnyToComputePoints, - formatComputePoints, -} from '@/lib/compute-points' +import { cnyToComputePoints, formatComputePoints } from '@/lib/compute-points' import { formatNumber } from '@/lib/format' import { cn } from '@/lib/utils' @@ -232,9 +229,6 @@ export function RechargeFormCard({ <>
-
- {t('1 CNY = 2,000 compute points')} -

{t( 'Top-ups remain wallet cash; compute points are a display conversion only.' diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f012ef0ed383..3f1fc864027d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,5 +1,6 @@ { "translation": { + "Versioned base price": "Versioned base price", "Sign in to view current prices": "Sign in to view current prices", "Current prices unavailable": "Current prices unavailable. Please retry in the model catalog.", "View current prices and access": "View current prices and access", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3f1b99b4347d..5259a6826b8d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,5 +1,6 @@ { "translation": { + "Versioned base price": "版本基准价", "Sign in to view current prices": "登录后查看实时价格", "Current prices unavailable": "暂时无法读取价格,请到模型广场重试", "View current prices and access": "查看实时价格与接入", @@ -5281,7 +5282,6 @@ "DeepSeek call credit": "DeepSeek 调用额度", "Classroom model shared credit": "课堂模型共享额度", "Compute points": "算力点", - "1 CNY = 2,000 compute points": "1 元 = 2,000 算力点", "Top-ups remain wallet cash; compute points are a display conversion only.": "充值仍记入按量钱包;算力点仅用于清晰展示额度,不改变后台真实账本。", "Estimated credit": "预计到账", "A valid code grants the product directly. No payment is required before redemption.": "有效兑换码可直接领取商品,无需先完成付款。", From 29a2ccbadbfbc6518fba3960f5fadf9aca007f3d Mon Sep 17 00:00:00 2001 From: OnePlus_X <13660828666@139.com> Date: Sat, 17 Oct 2026 19:23:59 +0800 Subject: [PATCH 4/4] test: include Bigfish purchase guards in PostgreSQL accounting CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 396d4108d97c..ea0499927399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,11 +140,11 @@ jobs: matrix: include: - package: model - tests: 'TestPostgres|TestSubscriptionReservation|TestTopUp|Test.*PaymentReceipt|TestMonthlyTiersShareOne|TestMonthlyUpgradeKeeps|TestRepeatedPackPurchases|TestValidateV1ContractRejectsFrozenRuleDrift' + tests: 'TestPostgres|TestSubscriptionReservation|TestTopUp|Test.*PaymentReceipt|TestMonthlyTiersShareOne|TestMonthlyUpgradeKeeps|TestRepeatedPackPurchases|TestValidateV1ContractRejectsFrozenRuleDrift|TestBigfish' - package: service tests: 'TestSubscriptionBillingSession|TestBillingSessionRefundIsTerminal' - package: controller - tests: 'TestIFLinkPayHTTP' + tests: 'TestIFLinkPayHTTP|TestIFLinkPayBigfish' services: postgres: image: postgres:16