From eb60d82a804cedc93106c910d0b328cc7b32e72c Mon Sep 17 00:00:00 2001 From: yunfan <213232889@seu.edu.cn> Date: Wed, 21 Oct 2026 11:30:43 +0800 Subject: [PATCH 1/4] fix: close storefront and subscription purchase blockers --- common/page_info.go | 12 ++---- common/page_info_test.go | 39 +++++++++++++++++ controller/subscription.go | 22 ++++++++-- controller/subscription_catalog.go | 28 ++++++++++-- .../subscription_storefront_catalog_test.go | 43 ++++++++++++++++++- middleware/header_nav.go | 16 +++++++ middleware/header_nav_test.go | 8 ++++ model/cilai_contract_test.go | 19 ++++++++ model/subscription.go | 27 +++++++++++- router/api-router.go | 2 +- .../lib/__tests__/plan-form.test.ts | 6 +++ .../features/subscriptions/lib/plan-form.ts | 5 ++- 12 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 common/page_info_test.go diff --git a/common/page_info.go b/common/page_info.go index 2378a5d81fdb..00bcfe46b402 100644 --- a/common/page_info.go +++ b/common/page_info.go @@ -48,13 +48,7 @@ func GetPageQuery(c *gin.Context) *PageInfo { pageInfo.PageSize = pageSize } if pageInfo.Page < 1 { - // 兼容 - page, _ := strconv.Atoi(c.Query("p")) - if page != 0 { - pageInfo.Page = page - } else { - pageInfo.Page = 1 - } + pageInfo.Page = 1 } if pageInfo.PageSize == 0 { @@ -74,7 +68,9 @@ func GetPageQuery(c *gin.Context) *PageInfo { } } - if pageInfo.PageSize > 100 { + if pageInfo.PageSize < 1 { + pageInfo.PageSize = ItemsPerPage + } else if pageInfo.PageSize > 100 { pageInfo.PageSize = 100 } diff --git a/common/page_info_test.go b/common/page_info_test.go new file mode 100644 index 000000000000..ada245328381 --- /dev/null +++ b/common/page_info_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGetPageQueryClampsNegativeValues(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, target := range []string{ + "/list?p=-1&page_size=-1", + "/list?p=0&ps=-1", + "/list?p=1&size=-1", + } { + request := httptest.NewRequest("GET", target, nil) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + page := GetPageQuery(context) + + require.Equal(t, 1, page.Page, target) + require.Equal(t, ItemsPerPage, page.PageSize, target) + } +} + +func TestGetPageQueryCapsLargePageSize(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest("GET", "/list?p=2&page_size=1000", nil) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + page := GetPageQuery(context) + + require.Equal(t, 2, page.Page) + require.Equal(t, 100, page.PageSize) +} diff --git a/controller/subscription.go b/controller/subscription.go index 9b5a03a5fc5d..6326b8aa13db 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -462,6 +462,21 @@ func normalizeAdminSubscriptionPlan(plan *model.SubscriptionPlan) error { return plan.ValidateV1Contract() } +func preserveUnsubmittedSubscriptionPlanFields(plan *model.SubscriptionPlan, existing model.SubscriptionPlan) { + if plan.Currency == "" { + plan.Currency = existing.Currency + } + if plan.AllowedChannels == "" { + plan.AllowedChannels = existing.AllowedChannels + } + if plan.MaxUpstreamCost == "" { + plan.MaxUpstreamCost = existing.MaxUpstreamCost + } + if plan.DisplayMetadata == "" { + plan.DisplayMetadata = existing.DisplayMetadata + } +} + func ensureUniqueProductCode(planId int, productCode string) error { productCode = strings.TrimSpace(productCode) if productCode == "" { @@ -601,6 +616,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { return } } + preserveUnsubmittedSubscriptionPlanFields(&req.Plan, existingPlan) if err := normalizeAdminSubscriptionPlan(&req.Plan); err != nil { common.ApiError(c, err) return @@ -647,14 +663,14 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { "product_tier": req.Plan.ProductTier, "billing_multiplier": req.Plan.BillingMultiplier, "price_version": req.Plan.PriceVersion, - "allowed_channels": req.Plan.AllowedChannels, - "max_upstream_cost": req.Plan.MaxUpstreamCost, "fallback_allowed": req.Plan.FallbackAllowed, "course_eligible": req.Plan.CourseEligible, "template_id": req.Plan.TemplateId, - "display_metadata": req.Plan.DisplayMetadata, "updated_at": common.GetTimestamp(), } + updateMap["allowed_channels"] = req.Plan.AllowedChannels + updateMap["max_upstream_cost"] = req.Plan.MaxUpstreamCost + updateMap["display_metadata"] = req.Plan.DisplayMetadata if req.Plan.AllowBalancePay != nil { updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay } diff --git a/controller/subscription_catalog.go b/controller/subscription_catalog.go index 5515d7fde75b..7353c90f55cb 100644 --- a/controller/subscription_catalog.go +++ b/controller/subscription_catalog.go @@ -42,10 +42,7 @@ func GetSubscriptionPublicCatalog(c *gin.Context) { result := make([]StorefrontCatalogItemDTO, 0, len(items)) for _, item := range items { plan := item.Plan - metadata := json.RawMessage("{}") - if trimmed := strings.TrimSpace(plan.DisplayMetadata); trimmed != "" { - metadata = json.RawMessage(trimmed) - } + metadata := storefrontDisplayMetadata(plan.DisplayMetadata) result = append(result, StorefrontCatalogItemDTO{ ProductID: subscriptionPlanProductID(plan), Title: plan.Title, @@ -65,3 +62,26 @@ func GetSubscriptionPublicCatalog(c *gin.Context) { } common.ApiSuccess(c, result) } + +func storefrontDisplayMetadata(raw string) json.RawMessage { + var input map[string]any + if err := common.Unmarshal([]byte(strings.TrimSpace(raw)), &input); err != nil { + return json.RawMessage("{}") + } + allowed := map[string]struct{}{ + "badges": {}, "benefits": {}, "button_label": {}, "recommended": {}, + "featured": {}, "reference_price": {}, "models_display": {}, "zone": {}, + "image": {}, "image_alt": {}, "preview": {}, + } + filtered := make(map[string]any, len(input)) + for key, value := range input { + if _, ok := allowed[key]; ok { + filtered[key] = value + } + } + encoded, err := common.Marshal(filtered) + if err != nil { + return json.RawMessage("{}") + } + return json.RawMessage(encoded) +} diff --git a/controller/subscription_storefront_catalog_test.go b/controller/subscription_storefront_catalog_test.go index 9ef4fc3738c3..e5ddf14b5c44 100644 --- a/controller/subscription_storefront_catalog_test.go +++ b/controller/subscription_storefront_catalog_test.go @@ -69,7 +69,7 @@ func setupStorefrontCatalogTest(t *testing.T) *gin.Engine { model.InvalidateSubscriptionPlanCache(1) router := gin.New() - router.GET("/api/subscription/public/catalog", middleware.HeaderNavModuleAuth("pricing"), GetSubscriptionPublicCatalog) + router.GET("/api/subscription/public/catalog", middleware.HeaderNavModulePublic("pricing"), GetSubscriptionPublicCatalog) return router } @@ -114,6 +114,14 @@ func decodeStorefrontCatalog(t *testing.T, body []byte) []map[string]any { func TestStorefrontCatalogServesAnonymousRequestsWithoutCostFields(t *testing.T) { router := setupStorefrontCatalogTest(t) + func() { + common.OptionMapRWMutex.Lock() + defer common.OptionMapRWMutex.Unlock() + if common.OptionMap == nil { + common.OptionMap = make(map[string]string) + } + common.OptionMap["HeaderNavModules"] = `{"pricing":{"enabled":true,"requireAuth":true}}` + }() createStorefrontCatalogPlan(t, model.SubscriptionPlan{ Title: "Pro", Subtitle: "适合稳定使用与高频开发", @@ -129,7 +137,7 @@ func TestStorefrontCatalogServesAnonymousRequestsWithoutCostFields(t *testing.T) SortOrder: 10, AllowedChannels: `{"deepseek-v4-flash-ga-260731":[7]}`, MaxUpstreamCost: `{"deepseek-v4-flash-ga-260731":{"input":9.99}}`, - DisplayMetadata: `{"badges":[{"label":"主推","tone":"accent"}],"recommended":true}`, + DisplayMetadata: `{"badges":[{"label":"主推","tone":"accent"}],"recommended":true,"internal_note":"do not expose"}`, }) code, body := requestStorefrontCatalog(t, router) @@ -152,6 +160,7 @@ func TestStorefrontCatalogServesAnonymousRequestsWithoutCostFields(t *testing.T) metadata, ok := item["display_metadata"].(map[string]any) require.True(t, ok, "display_metadata must decode to an object: %v", item["display_metadata"]) assert.Equal(t, true, metadata["recommended"]) + assert.NotContains(t, metadata, "internal_note") for _, forbidden := range []string{ "allowed_channels", "max_upstream_cost", "fallback_allowed", @@ -164,6 +173,36 @@ func TestStorefrontCatalogServesAnonymousRequestsWithoutCostFields(t *testing.T) assert.NotContains(t, string(body), "9.99", "upstream settlement cost must never leak") } +func TestPreserveUnsubmittedSubscriptionPlanFields(t *testing.T) { + existing := model.SubscriptionPlan{ + Currency: "CNY", AllowedChannels: `{"model":[1]}`, + MaxUpstreamCost: `{"model":{"input":1}}`, DisplayMetadata: `{"recommended":true}`, + } + updated := model.SubscriptionPlan{Title: "Updated"} + preserveUnsubmittedSubscriptionPlanFields(&updated, existing) + + require.Equal(t, existing.Currency, updated.Currency) + require.Equal(t, existing.AllowedChannels, updated.AllowedChannels) + require.Equal(t, existing.MaxUpstreamCost, updated.MaxUpstreamCost) + require.Equal(t, existing.DisplayMetadata, updated.DisplayMetadata) +} + +func TestStorefrontCatalogRedactsUnknownDisplayMetadata(t *testing.T) { + router := setupStorefrontCatalogTest(t) + createStorefrontCatalogPlan(t, model.SubscriptionPlan{ + Title: "Pro", ProductCode: "pro", Listed: true, Enabled: true, + DisplayMetadata: `{"button_label":"Buy","internal_note":"secret","allowed_channels":{"model":[7]}}`, + }) + + _, body := requestStorefrontCatalog(t, router) + items := decodeStorefrontCatalog(t, body) + require.Len(t, items, 1) + metadata := items[0]["display_metadata"].(map[string]any) + require.Equal(t, "Buy", metadata["button_label"]) + assert.NotContains(t, metadata, "internal_note") + assert.NotContains(t, metadata, "allowed_channels") +} + func TestStorefrontCatalogIncludesPreviewDraftsAndHidesUnlistedDrafts(t *testing.T) { router := setupStorefrontCatalogTest(t) createStorefrontCatalogPlan(t, model.SubscriptionPlan{ diff --git a/middleware/header_nav.go b/middleware/header_nav.go index 70aa1869def6..1e7edb14d908 100644 --- a/middleware/header_nav.go +++ b/middleware/header_nav.go @@ -122,6 +122,22 @@ func HeaderNavModuleAuth(module string) gin.HandlerFunc { } } +// HeaderNavModulePublic keeps a public endpoint available regardless of the +// module's navigation login preference while still honoring its enabled flag. +func HeaderNavModulePublic(module string) gin.HandlerFunc { + return func(c *gin.Context) { + if !getHeaderNavAccess(module).Enabled { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": fmt.Sprintf("%s is disabled", module), + }) + c.Abort() + return + } + TryUserAuth()(c) + } +} + func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc { return func(c *gin.Context) { access := getHeaderNavAccess(module) diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go index 6ed0b0aa96d3..69e00fc6e33c 100644 --- a/middleware/header_nav_test.go +++ b/middleware/header_nav_test.go @@ -105,6 +105,14 @@ func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) { require.Equal(t, http.StatusUnauthorized, recorder.Code) } +func TestHeaderNavModulePublicIgnoresRequireAuthButHonorsEnabled(t *testing.T) { + withHeaderNavModules(t, `{"pricing":{"enabled":true,"requireAuth":true}}`) + require.Equal(t, http.StatusOK, performHeaderNavRequest(t, HeaderNavModulePublic("pricing"), false).Code) + + withHeaderNavModules(t, `{"pricing":{"enabled":false,"requireAuth":false}}`) + require.Equal(t, http.StatusForbidden, performHeaderNavRequest(t, HeaderNavModulePublic("pricing"), false).Code) +} + func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) { raw := `{"rankings":{"enabled":true,"requireAuth":true}}` withHeaderNavModules(t, raw) diff --git a/model/cilai_contract_test.go b/model/cilai_contract_test.go index a0e28b214239..0ec36fbcc813 100644 --- a/model/cilai_contract_test.go +++ b/model/cilai_contract_test.go @@ -141,6 +141,25 @@ func TestValidateV1ContractRejectsFrozenRuleDrift(t *testing.T) { require.ErrorContains(t, broken.ValidateV1Contract(), "fail closed") } +func TestLimitedPlanRejectsAnotherPendingPurchase(t *testing.T) { + truncateTables(t) + user := createV1ContractUser(t, "limited-pending-user") + plan := v1MonthlyPlanFixture(t, "monthly-limited", 2, 49.9) + plan.MaxPurchasePerUser = 1 + require.NoError(t, DB.Create(plan).Error) + InvalidateSubscriptionPlanCache(plan.Id) + + first, _, err := CreateSubscriptionOrder(user.Id, plan.Id, "limited-pending-first", PaymentMethodAlipay, PaymentProviderAlipay, true) + require.NoError(t, err) + require.Equal(t, common.TopUpStatusPending, first.Status) + _, _, err = CreateSubscriptionOrder(user.Id, plan.Id, "limited-pending-second", PaymentMethodAlipay, PaymentProviderAlipay, true) + require.ErrorContains(t, err, "待支付") + + var count int64 + require.NoError(t, DB.Model(&SubscriptionOrder{}).Where("user_id = ? AND plan_id = ? AND status = ?", user.Id, plan.Id, common.TopUpStatusPending).Count(&count).Error) + require.EqualValues(t, 1, count) +} + func TestWalletProductPriceResolvesCurrentImmutableVersion(t *testing.T) { truncateTables(t) now := GetDBTimestamp() diff --git a/model/subscription.go b/model/subscription.go index 4f3dda402102..2240bf4bcb5e 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -538,6 +538,17 @@ func createSubscriptionOrderTx(tx *gorm.DB, userId int, planId int, tradeNo stri return nil, nil, errors.New("已有待支付的大肥鱼活动订单,请完成原订单或联系管理员取消后再选档") } } + if plan.ProductType == ProductTypeMonthlySubscription { + var pendingCount int64 + if err := tx.Model(&SubscriptionOrder{}). + Where("user_id = ? AND product_family = ? AND order_type = ? AND status = ?", userId, plan.ProductFamily, SubscriptionOrderTypePurchase, common.TopUpStatusPending). + Count(&pendingCount).Error; err != nil { + return nil, nil, err + } + if pendingCount > 0 { + return nil, nil, errors.New("已有待支付的月套餐订单") + } + } order := &SubscriptionOrder{ UserId: userId, @@ -604,7 +615,7 @@ func createSubscriptionOrderTx(tx *gorm.DB, userId int, planId int, tradeNo stri } } - if order.OrderType == SubscriptionOrderTypePurchase && plan.MaxPurchasePerUser > 0 { + if order.OrderType == SubscriptionOrderTypePurchase && plan.MaxPurchasePerUser > 0 && plan.ProductType != ProductTypePackDeepSeekBigfish { var count int64 if err := tx.Model(&UserSubscription{}). Where("user_id = ? AND plan_id = ?", userId, plan.Id). @@ -614,6 +625,20 @@ func createSubscriptionOrderTx(tx *gorm.DB, userId int, planId int, tradeNo stri if count >= int64(plan.MaxPurchasePerUser) { return nil, nil, errors.New("已达到该套餐购买上限") } + var pendingCount int64 + pendingQuery := tx.Model(&SubscriptionOrder{}). + Where("user_id = ? AND order_type = ? AND status = ?", userId, SubscriptionOrderTypePurchase, common.TopUpStatusPending) + if plan.ProductType == ProductTypeMonthlySubscription { + pendingQuery = pendingQuery.Where("product_family = ?", plan.ProductFamily) + } else { + pendingQuery = pendingQuery.Where("plan_id = ?", plan.Id) + } + if err := pendingQuery.Count(&pendingCount).Error; err != nil { + return nil, nil, err + } + if pendingCount > 0 { + return nil, nil, errors.New("该套餐已有待支付订单") + } } if err := tx.Create(order).Error; err != nil { return nil, nil, err diff --git a/router/api-router.go b/router/api-router.go index 0f3fa4eeb658..c584cf619de8 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -166,7 +166,7 @@ func SetApiRouter(router *gin.Engine) { // Subscription billing (plans, purchase, admin management) // Anonymous landing-page storefront catalog: trimmed DTO, follows the // pricing module's header-nav visibility toggle. - apiRouter.GET("/subscription/public/catalog", middleware.HeaderNavModuleAuth("pricing"), controller.GetSubscriptionPublicCatalog) + apiRouter.GET("/subscription/public/catalog", middleware.HeaderNavModulePublic("pricing"), controller.GetSubscriptionPublicCatalog) subscriptionRoute := apiRouter.Group("/subscription") subscriptionRoute.Use(middleware.UserAuth()) { diff --git a/web/src/features/subscriptions/lib/__tests__/plan-form.test.ts b/web/src/features/subscriptions/lib/__tests__/plan-form.test.ts index 3fbaeef17b85..4728c28bef96 100644 --- a/web/src/features/subscriptions/lib/__tests__/plan-form.test.ts +++ b/web/src/features/subscriptions/lib/__tests__/plan-form.test.ts @@ -84,6 +84,7 @@ describe('plan form model scope conversion', () => { const payload = formValuesToPlanPayload(values) assert.equal(payload.plan.product_type, 'pack_k3') + assert.equal(payload.plan.currency, 'CNY') assert.equal(payload.plan.product_code, 'k3-pack-49') assert.equal(payload.plan.product_family, 'kimi_k3') assert.equal(payload.plan.product_tier, 2) @@ -111,6 +112,11 @@ describe('plan form defaults for legacy plans', () => { assert.equal(values.redemption_enabled, true) }) + test('preserves the existing currency when editing a legacy plan', () => { + const values = planToFormValues(buildPlan({ currency: 'USD' })) + assert.equal(formValuesToPlanPayload(values).plan.currency, 'USD') + }) + test('payload of defaults carries an empty legacy product contract', () => { const payload = formValuesToPlanPayload(PLAN_FORM_DEFAULTS) assert.equal(payload.plan.product_type, '') diff --git a/web/src/features/subscriptions/lib/plan-form.ts b/web/src/features/subscriptions/lib/plan-form.ts index 5a0af218bac8..46fb06715992 100644 --- a/web/src/features/subscriptions/lib/plan-form.ts +++ b/web/src/features/subscriptions/lib/plan-form.ts @@ -27,6 +27,7 @@ export function getPlanFormSchema(t: TFunction) { return z.object({ title: z.string().min(1, t('Please enter plan title')), subtitle: z.string().optional(), + currency: z.string(), price_amount: z.coerce.number().min(0, t('Please enter amount')), duration_unit: z.enum(['year', 'month', 'day', 'hour', 'custom']), duration_value: z.coerce.number().min(1), @@ -70,6 +71,7 @@ export type PlanFormValues = z.infer> export const PLAN_FORM_DEFAULTS: PlanFormValues = { title: '', subtitle: '', + currency: 'CNY', price_amount: 0, duration_unit: 'month', duration_value: 1, @@ -121,6 +123,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues { return { title: plan.title || '', subtitle: plan.subtitle || '', + currency: plan.currency || 'CNY', price_amount: Number(plan.price_amount || 0), duration_unit: plan.duration_unit || 'month', duration_value: Number(plan.duration_value || 1), @@ -158,7 +161,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload { plan: { ...values, price_amount: Number(values.price_amount || 0), - currency: 'USD', + currency: values.product_type ? 'CNY' : values.currency, duration_value: Number(values.duration_value || 0), custom_seconds: Number(values.custom_seconds || 0), quota_reset_period: values.quota_reset_period || 'never', From edf6e18bedee2d002c492f3355c80b5aa0e387a0 Mon Sep 17 00:00:00 2001 From: yunfan <213232889@seu.edu.cn> Date: Wed, 21 Oct 2026 11:35:10 +0800 Subject: [PATCH 2/4] fix: bound pagination offsets --- common/page_info.go | 4 ++++ common/page_info_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/common/page_info.go b/common/page_info.go index 00bcfe46b402..9b0b9779416e 100644 --- a/common/page_info.go +++ b/common/page_info.go @@ -6,6 +6,8 @@ import ( "github.com/gin-gonic/gin" ) +const maxPageNumber = 1_000_000 + type PageInfo struct { Page int `json:"page"` // page num 页码 PageSize int `json:"page_size"` // page size 页大小 @@ -49,6 +51,8 @@ func GetPageQuery(c *gin.Context) *PageInfo { } if pageInfo.Page < 1 { pageInfo.Page = 1 + } else if pageInfo.Page > maxPageNumber { + pageInfo.Page = maxPageNumber } if pageInfo.PageSize == 0 { diff --git a/common/page_info_test.go b/common/page_info_test.go index ada245328381..4989f64be956 100644 --- a/common/page_info_test.go +++ b/common/page_info_test.go @@ -37,3 +37,16 @@ func TestGetPageQueryCapsLargePageSize(t *testing.T) { require.Equal(t, 2, page.Page) require.Equal(t, 100, page.PageSize) } + +func TestGetPageQueryCapsLargePageNumber(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest("GET", "/list?p=2147483647&page_size=100", nil) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + + page := GetPageQuery(context) + + require.Equal(t, maxPageNumber, page.Page) + require.Equal(t, 100, page.PageSize) + require.GreaterOrEqual(t, page.GetStartIdx(), 0) +} From 7a6acd8cbc1b93505bb270b7f4c67d05f80a7ae9 Mon Sep 17 00:00:00 2001 From: yunfan <213232889@seu.edu.cn> Date: Wed, 21 Oct 2026 11:46:02 +0800 Subject: [PATCH 3/4] test: clarify hidden subscription sale state --- controller/subscription_catalog.go | 3 ++- model/bigfish_product_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/controller/subscription_catalog.go b/controller/subscription_catalog.go index 7353c90f55cb..82c3062a6664 100644 --- a/controller/subscription_catalog.go +++ b/controller/subscription_catalog.go @@ -32,7 +32,8 @@ type StorefrontCatalogItemDTO struct { // GetSubscriptionPublicCatalog serves the landing-page product catalog to // anonymous visitors. Listed products and display_metadata.preview drafts are -// included; checkout keeps enforcing enabled/listed server-side. +// included; paid checkout enforces Enabled server-side while Listed controls +// catalog discovery and RedemptionEnabled independently controls code claims. func GetSubscriptionPublicCatalog(c *gin.Context) { items, err := model.GetStorefrontCatalog() if err != nil { diff --git a/model/bigfish_product_test.go b/model/bigfish_product_test.go index 9ff107c88b62..2100368faef9 100644 --- a/model/bigfish_product_test.go +++ b/model/bigfish_product_test.go @@ -176,6 +176,24 @@ func TestBigfishIFLinkPaySharedPoolKeyExpiryAndCrossTierLimit(t *testing.T) { } } +func TestHiddenEnabledPlanRemainsDirectlyPurchasable(t *testing.T) { + plans := setupBigfishProductsTest(t) + plan := plans[0] + require.False(t, plan.Listed) + require.True(t, plan.Enabled) + + user := createV1ContractUser(t, "bigfish-hidden-purchase-user") + order, _, err := CreateSubscriptionOrder(user.Id, plan.Id, "bigfish-hidden-enabled", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.NoError(t, err) + require.Equal(t, common.TopUpStatusPending, order.Status) + + require.NoError(t, DB.Model(&SubscriptionPlan{}).Where("id = ?", plan.Id).Update("enabled", false).Error) + InvalidateSubscriptionPlanCache(plan.Id) + disabledUser := createV1ContractUser(t, "bigfish-disabled-purchase-user") + _, _, err = CreateSubscriptionOrder(disabledUser.Id, plan.Id, "bigfish-hidden-disabled", PaymentMethodIFLinkPay, PaymentProviderIFLinkPay, true) + require.ErrorContains(t, err, "套餐未启用") +} + func TestBigfishConcurrentOrdersCannotReserveDifferentTiers(t *testing.T) { plans := setupBigfishProductsTest(t) user := createV1ContractUser(t, "bigfish-concurrent-user") From 3763e096f6a6b0a9979a34c25617035f2746750f Mon Sep 17 00:00:00 2001 From: yunfan <213232889@seu.edu.cn> Date: Thu, 22 Oct 2026 16:43:47 +0800 Subject: [PATCH 4/4] fix: close remaining commercial review financial gaps --- router/api-router.go | 4 +++- service/billing_refund_task.go | 9 +++++++-- service/billing_refund_task_test.go | 8 ++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/router/api-router.go b/router/api-router.go index c584cf619de8..be645e5e5cf7 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -145,7 +145,9 @@ func SetApiRouter(router *gin.Engine) { { adminRoute.GET("/", controller.GetAllUsers) adminRoute.GET("/topup", controller.GetAllTopUps) - adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) + // Manual balance crediting is a financial correction, not a routine + // admin action. Keep it at the same Root boundary as refund review. + adminRoute.POST("/topup/complete", middleware.RootAuth(), controller.AdminCompleteTopUp) adminRoute.GET("/search", controller.SearchUsers) adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin) adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin) diff --git a/service/billing_refund_task.go b/service/billing_refund_task.go index 6d68c3f3385f..839bc0794b2c 100644 --- a/service/billing_refund_task.go +++ b/service/billing_refund_task.go @@ -61,11 +61,16 @@ func ReconcileBillingRefunds(ctx context.Context) (BillingRefundReconciliation, if err != nil { return result, err } + // Retention is independent from the current retry/alert backlog. A single + // ambiguous receipt must not prevent terminal receipts from being removed + // after the configured retention period. + if _, err = model.CleanupBillingRefunds(30 * 24 * 3600); err != nil { + return result, err + } if result.Failed > 0 || result.Alerts > 0 || result.Pending > 0 { return result, fmt.Errorf("billing refund reconciliation requires attention: failed=%d alerts=%d pending=%d; inspect /api/billing-refund", result.Failed, result.Alerts, result.Pending) } - _, err = model.CleanupBillingRefunds(30 * 24 * 3600) - return result, err + return result, nil } func (billingRefundHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) { diff --git a/service/billing_refund_task_test.go b/service/billing_refund_task_test.go index 611b60bfbe90..806646c57aaf 100644 --- a/service/billing_refund_task_test.go +++ b/service/billing_refund_task_test.go @@ -225,6 +225,11 @@ func TestBillingRefundReconciliationUsesExistingTaskAndRecordsFailure(t *testing receipt := &model.BillingRefund{UserID: 701, WalletQuota: 100} require.NoError(t, model.CreateBillingRefundReceipt(receipt)) require.NoError(t, model.DB.Model(receipt).Update("updated_at", time.Now().Unix()-7*3600).Error) + terminal := &model.BillingRefund{UserID: 701, RequestID: "terminal-cleanup", WalletQuota: 1} + require.NoError(t, model.CreateBillingRefundReceipt(terminal)) + require.NoError(t, model.DB.Model(terminal).Updates(map[string]any{ + "status": model.BillingRefundSucceeded, "updated_at": time.Now().Unix() - 40*24*3600, + }).Error) withSystemTaskRegistry(t, billingRefundHandler{}) runSystemTaskScheduler() task, err := model.GetActiveSystemTask("billing_refund") @@ -239,6 +244,9 @@ func TestBillingRefundReconciliationUsesExistingTaskAndRecordsFailure(t *testing assert.Equal(t, model.SystemTaskStatusFailed, after.Status) assert.Contains(t, after.Error, "alerts=1") assert.Contains(t, after.Result, "\"alerts\":1") + var terminalCount int64 + require.NoError(t, model.DB.Model(&model.BillingRefund{}).Where("id = ?", terminal.ID).Count(&terminalCount).Error) + assert.Zero(t, terminalCount, "terminal refund retention must run even when another receipt needs attention") var users int64 require.NoError(t, model.DB.Model(&model.User{}).Count(&users).Error) assert.Zero(t, users, "ambiguous reservations must not create accounts or credit money")