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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions controller/subscription_bigfish_iflinkpay_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions controller/subscription_payment_iflinkpay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions model/bigfish_product.go
Original file line number Diff line number Diff line change
@@ -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
}
198 changes: 198 additions & 0 deletions model/bigfish_product_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading