diff --git a/README.md b/README.md index 14a5dc9..af59d86 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ default: - **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md) - **批量同步**:卡状态、实名状态、流量使用情况 - **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md) +- **批量换货脚本**:支持从双列 CSV 读取新旧资产映射,固定执行直接换货和全量数据迁移,提供预演、批次冲突拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_exchange/README.md) 与 [功能总结](docs/批量换货脚本/功能总结.md) - **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) diff --git a/internal/handler/admin/shop_package_expiry_base_integration_test.go b/internal/handler/admin/shop_package_expiry_base_integration_test.go new file mode 100644 index 0000000..cc69faa --- /dev/null +++ b/internal/handler/admin/shop_package_expiry_base_integration_test.go @@ -0,0 +1,345 @@ +package admin + +import ( + "bytes" + "context" + "io" + "net/http" + "strconv" + "testing" + "time" + + internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + batchAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation" + grantService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/bytedance/sonic" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。 +func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) { + testCases := []struct { + name string + field string + expected *string + }{ + {name: "跟随默认", field: "null"}, + {name: "购买即生效", field: `"from_purchase"`, expected: stringPointer(constants.PackageExpiryBaseFromPurchase)}, + {name: "实名即生效", field: `"from_activation"`, expected: stringPointer(constants.PackageExpiryBaseFromActivation)}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + app := fixture.newApp(constants.UserTypeSuperAdmin, 0) + body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + `,"expiry_base_override":` + testCase.field + `}` + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body) + if status != http.StatusOK { + t.Fatalf("批量分配失败:status=%d body=%s", status, responseBody) + } + var allocations []model.ShopPackageAllocation + if err := tx.Where("shop_id = ?", fixture.shop.ID).Order("package_id").Find(&allocations).Error; err != nil { + t.Fatalf("查询套餐分配失败:%v", err) + } + if len(allocations) != len(fixture.packages) { + t.Fatalf("期望创建 %d 条分配,实际 %d", len(fixture.packages), len(allocations)) + } + for _, allocation := range allocations { + if !nullableStringEqual(allocation.ExpiryBaseOverride, testCase.expected) { + t.Fatalf("分配 %d 的覆盖值错误:%v", allocation.ID, allocation.ExpiryBaseOverride) + } + } + }) + } +} + +// TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举统一拒绝且不写入。 +func TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) { + for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} { + t.Run(bodySuffix, func(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + app := fixture.newApp(constants.UserTypeSuperAdmin, 0) + body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + bodySuffix + `}` + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body) + if status != http.StatusBadRequest { + t.Fatalf("期望参数错误,实际 status=%d body=%s", status, responseBody) + } + var count int64 + if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", fixture.shop.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err) + } + }) + } +} + +// TestUpdateAllocationExpiryBaseHTTP 验证 PATCH 的恢复默认、幂等、权限和历史快照隔离。 +func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + override := constants.PackageExpiryBaseFromPurchase + allocation := &model.ShopPackageAllocation{ShopID: fixture.shop.ID, PackageID: fixture.packages[0].ID, AllocatorShopID: 101, CostPrice: 1, RetailPrice: 2, ExpiryBaseOverride: &override, Status: constants.StatusEnabled, ShelfStatus: 1} + if err := tx.Create(allocation).Error; err != nil { + t.Fatalf("创建套餐分配失败:%v", err) + } + usage := completeExpiryBaseTestUsage(fixture.packages[0].ID) + if err := tx.Create(usage).Error; err != nil { + t.Fatalf("创建既有购买记录失败:%v", err) + } + app := fixture.newApp(constants.UserTypeSuperAdmin, 0) + path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base" + + for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} { + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body) + if status != http.StatusOK { + t.Fatalf("修改覆盖值失败:status=%d body=%s", status, responseBody) + } + } + var refreshed model.ShopPackageAllocation + if err := tx.First(&refreshed, allocation.ID).Error; err != nil || refreshed.ExpiryBaseOverride != nil { + t.Fatalf("显式 null 应恢复跟随默认:allocation=%+v err=%v", refreshed, err) + } + var refreshedUsage model.PackageUsage + if err := tx.First(&refreshedUsage, usage.ID).Error; err != nil { + t.Fatalf("查询既有购买记录失败:%v", err) + } + if refreshedUsage.ExpiryBaseSnapshot != usage.ExpiryBaseSnapshot || refreshedUsage.CalendarTypeSnapshot != usage.CalendarTypeSnapshot || refreshedUsage.DurationDaysSnapshot != usage.DurationDaysSnapshot { + t.Fatalf("修改分配不应改变既有购买快照:before=%+v after=%+v", usage, refreshedUsage) + } + + for _, body := range []string{`{}`, `{"expiry_base_override":"invalid"}`} { + status, _ := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body) + if status != http.StatusBadRequest { + t.Fatalf("字段缺失或非法值应返回 400,body=%s status=%d", body, status) + } + } + forbiddenApp := fixture.newApp(constants.UserTypeAgent, 202) + status, _ := expiryBaseHTTPRequest(t, forbiddenApp, http.MethodPatch, path, `{"expiry_base_override":null}`) + if status != http.StatusForbidden { + t.Fatalf("越权修改应返回 403,实际 %d", status) + } + if err := tx.Delete(&refreshed).Error; err != nil { + t.Fatalf("软删除分配失败:%v", err) + } + status, _ = expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":null}`) + if status != http.StatusForbidden { + t.Fatalf("软删除分配应使用统一 403 语义,实际 %d", status) + } +} + +type expiryBaseHTTPFixture struct { + tx *gorm.DB + shop *model.Shop + series *model.PackageSeries + packages []*model.Package +} + +func newExpiryBaseHTTPFixture(t *testing.T) (*gorm.DB, *expiryBaseHTTPFixture) { + t.Helper() + tx := testutil.NewPostgresTransaction(t) + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + shop := &model.Shop{ShopName: "UR55测试店铺", ShopCode: "UR55-SHOP-" + suffix, Status: constants.StatusEnabled} + series := &model.PackageSeries{SeriesCode: "UR55-SERIES-" + suffix, SeriesName: "UR55测试系列", Status: constants.StatusEnabled} + if err := tx.Create(shop).Error; err != nil { + t.Fatalf("创建测试店铺失败:%v", err) + } + if err := tx.Create(series).Error; err != nil { + t.Fatalf("创建测试系列失败:%v", err) + } + packages := make([]*model.Package, 0, 2) + for index, expiryBase := range []string{constants.PackageExpiryBaseFromActivation, constants.PackageExpiryBaseFromPurchase} { + pkg := &model.Package{PackageCode: "UR55-PKG-" + suffix + "-" + strconv.Itoa(index), PackageName: "UR55测试套餐", SeriesID: series.ID, PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: 30, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, PriceConfigStatus: 2} + if err := tx.Create(pkg).Error; err != nil { + t.Fatalf("创建测试套餐失败:%v", err) + } + packages = append(packages, pkg) + } + seriesAllocation := &model.ShopSeriesAllocation{ShopID: shop.ID, SeriesID: series.ID, Status: constants.StatusEnabled} + if err := tx.Create(seriesAllocation).Error; err != nil { + t.Fatalf("创建系列授权失败:%v", err) + } + return tx, &expiryBaseHTTPFixture{tx: tx, shop: shop, series: series, packages: packages} +} + +func (f *expiryBaseHTTPFixture) newApp(userType int, shopID uint) *fiber.App { + service := batchAllocationService.New(f.tx, postgres.NewPackageStore(f.tx), postgres.NewShopPackageAllocationStore(f.tx), postgres.NewShopSeriesAllocationStore(f.tx), postgres.NewShopStore(f.tx, nil), nil) + handler := NewShopPackageBatchAllocationHandler(service) + app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())}) + setContext := func(c *fiber.Ctx) error { + ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID}) + c.SetUserContext(ctx) + return c.Next() + } + app.Post("/api/admin/shop-package-allocations/batch", setContext, handler.BatchAllocate) + app.Patch("/api/admin/shop-package-allocations/:id/expiry-base", setContext, handler.UpdateExpiryBase) + return app +} + +func expiryBaseHTTPRequest(t *testing.T, app *fiber.App, method, path, body string) (int, []byte) { + t.Helper() + request, err := http.NewRequestWithContext(context.Background(), method, path, bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("创建 HTTP 请求失败:%v", err) + } + request.Header.Set("Content-Type", "application/json") + response, err := app.Test(request, -1) + if err != nil { + t.Fatalf("执行 HTTP 请求失败:%v", err) + } + defer response.Body.Close() + responseBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("读取 HTTP 响应失败:%v", err) + } + return response.StatusCode, responseBody +} + +func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage { + unique := uint(time.Now().UnixNano() & 0x7fffffff) + return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30} +} + +func stringPointer(value string) *string { return &value } + +func nullableStringEqual(left, right *string) bool { + return left == nil && right == nil || left != nil && right != nil && *left == *right +} + +// TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase 验证系列首次授权(含套餐)固化生效条件到分配记录。 +func TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase(t *testing.T) { + testCases := []struct { + name string + field string + expected *string + }{ + {name: "跟随默认", field: "null"}, + {name: "购买即生效", field: `"from_purchase"`, expected: stringPointer(constants.PackageExpiryBaseFromPurchase)}, + {name: "实名即生效", field: `"from_activation"`, expected: stringPointer(constants.PackageExpiryBaseFromActivation)}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled} + if err := tx.Create(targetShop).Error; err != nil { + t.Fatalf("创建目标店铺失败:%v", err) + } + app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx) + pkg := fixture.packages[0] + body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) + + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + + `,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` + + `,"expiry_base_override":` + testCase.field + `}` + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body) + if status != http.StatusOK { + t.Fatalf("系列授权创建失败:status=%d body=%s", status, responseBody) + } + var alloc model.ShopPackageAllocation + if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil { + t.Fatalf("查询套餐分配失败:%v", err) + } + if !nullableStringEqual(alloc.ExpiryBaseOverride, testCase.expected) { + t.Fatalf("系列授权覆盖值错误:got=%v want=%v", alloc.ExpiryBaseOverride, testCase.expected) + } + }) + } +} + +// TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举拒绝系列授权创建。 +func TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) { + for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} { + t.Run(bodySuffix, func(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled} + if err := tx.Create(targetShop).Error; err != nil { + t.Fatalf("创建目标店铺失败:%v", err) + } + app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx) + pkg := fixture.packages[0] + body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) + + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + + `,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` + + bodySuffix + `}` + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body) + if status != http.StatusBadRequest { + t.Fatalf("期望 400,实际 status=%d body=%s", status, responseBody) + } + var count int64 + if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", targetShop.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err) + } + }) + } +} + +// TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase 验证后续追加套餐固化生效条件到新分配记录。 +func TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) { + tx, fixture := newExpiryBaseHTTPFixture(t) + targetShop := &model.Shop{ShopName: "UR55追加目标店铺", ShopCode: "UR55-MANAGE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled} + if err := tx.Create(targetShop).Error; err != nil { + t.Fatalf("创建目标店铺失败:%v", err) + } + // 先创建不含套餐的系列授权 + seriesAlloc := &model.ShopSeriesAllocation{ShopID: targetShop.ID, SeriesID: fixture.series.ID, AllocatorShopID: 0, Status: constants.StatusEnabled} + if err := tx.Create(seriesAlloc).Error; err != nil { + t.Fatalf("创建系列授权失败:%v", err) + } + app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx) + path := "/api/admin/shop-series-grants/" + strconv.FormatUint(uint64(seriesAlloc.ID), 10) + "/packages" + pkg := fixture.packages[0] + + // 有效追加(from_purchase) + body := `{"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":50}],"expiry_base_override":"from_purchase"}` + status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPut, path, body) + if status != http.StatusOK { + t.Fatalf("追加套餐失败:status=%d body=%s", status, responseBody) + } + var alloc model.ShopPackageAllocation + if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil { + t.Fatalf("查询新建套餐分配失败:%v", err) + } + expected := constants.PackageExpiryBaseFromPurchase + if !nullableStringEqual(alloc.ExpiryBaseOverride, &expected) { + t.Fatalf("追加套餐覆盖值错误:%v", alloc.ExpiryBaseOverride) + } + + // 缺失 expiry_base_override 字段应返回 400 + for _, badBody := range []string{ + `{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}]}`, + `{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}],"expiry_base_override":"invalid"}`, + } { + s, _ := expiryBaseHTTPRequest(t, app, http.MethodPut, path, badBody) + if s != http.StatusBadRequest { + t.Fatalf("缺失/非法 expiry_base_override 应返回 400,实际 %d", s) + } + } +} + +func (f *expiryBaseHTTPFixture) newGrantApp(userType int, shopID uint, tx *gorm.DB) *fiber.App { + svc := grantService.New( + tx, + postgres.NewShopSeriesAllocationStore(tx), + postgres.NewShopPackageAllocationStore(tx), + postgres.NewShopPackageAllocationPriceHistoryStore(tx), + postgres.NewShopStore(tx, nil), + postgres.NewPackageStore(tx), + postgres.NewPackageSeriesStore(tx), + zap.NewNop(), + ) + handler := NewShopSeriesGrantHandler(svc) + app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())}) + setContext := func(c *fiber.Ctx) error { + ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID}) + c.SetUserContext(ctx) + return c.Next() + } + app.Post("/api/admin/shop-series-grants", setContext, handler.Create) + app.Put("/api/admin/shop-series-grants/:id/packages", setContext, handler.ManagePackages) + return app +} + +var _ = errors.CodeSuccess diff --git a/internal/model/dto/shop_package_batch_allocation_dto.go b/internal/model/dto/shop_package_batch_allocation_dto.go index 3064354..95dca64 100644 --- a/internal/model/dto/shop_package_batch_allocation_dto.go +++ b/internal/model/dto/shop_package_batch_allocation_dto.go @@ -13,7 +13,7 @@ type BatchAllocatePackagesRequest struct { PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"` OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿到的一次性佣金(分)"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` - ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` + ExpiryBaseOverrideSet bool `json:"-"` } // BatchAllocatePackagesResponse 批量分配套餐响应 @@ -27,7 +27,7 @@ type BatchAllocatePackagesResponse struct { // UpdateAllocationExpiryBaseRequest 修改套餐分配生效条件覆盖请求。 type UpdateAllocationExpiryBaseRequest struct { ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` - ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` + ExpiryBaseOverrideSet bool `json:"-"` } // ShopPackageAllocationTermsResponse 套餐分配计时条款响应。 diff --git a/internal/model/dto/shop_series_grant_dto.go b/internal/model/dto/shop_series_grant_dto.go index 30d9acf..91edc2d 100644 --- a/internal/model/dto/shop_series_grant_dto.go +++ b/internal/model/dto/shop_series_grant_dto.go @@ -64,7 +64,7 @@ type CreateShopSeriesGrantRequest struct { ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"` Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` - ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` + ExpiryBaseOverrideSet bool `json:"-"` } // UpdateShopSeriesGrantRequest 更新系列授权请求 @@ -79,7 +79,7 @@ type UpdateShopSeriesGrantRequest struct { type ManageGrantPackagesRequest struct { Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` - ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` + ExpiryBaseOverrideSet bool `json:"-"` } // ShopSeriesGrantListRequest 系列授权列表查询请求 diff --git a/internal/service/order/package_terms_integration_test.go b/internal/service/order/package_terms_integration_test.go new file mode 100644 index 0000000..d1cc15f --- /dev/null +++ b/internal/service/order/package_terms_integration_test.go @@ -0,0 +1,173 @@ +package order + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。 +func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified) + formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45) + addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10) + shopID := uint(time.Now().UnixNano() & 0x7fffffff) + override := constants.PackageExpiryBaseFromPurchase + for _, packageID := range []uint{formal.ID, addon.ID} { + allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override} + if err := tx.Create(allocation).Error; err != nil { + t.Fatalf("创建套餐覆盖配置失败:%v", err) + } + } + service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.Local) + formalOrder := newOrderTermsOrder(formal.ID, card.ID, &shopID) + if err := service.activateMainPackage(context.Background(), tx, formalOrder, formal, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil { + t.Fatalf("同步创建主套餐失败:%v", err) + } + addonOrder := newOrderTermsOrder(addon.ID, card.ID, &shopID) + if err := service.activateAddonPackage(context.Background(), tx, addonOrder, addon, constants.AssetWalletResourceTypeIotCard, card.ID, now.Add(time.Minute)); err != nil { + t.Fatalf("同步创建加油包失败:%v", err) + } + + for _, item := range []struct { + orderID uint + packageID uint + durationDay int + }{ + {formalOrder.ID, formal.ID, 45}, + {addonOrder.ID, addon.ID, 10}, + } { + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", item.orderID, item.packageID).First(&usage).Error; err != nil { + t.Fatalf("查询套餐使用记录失败:%v", err) + } + if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != item.durationDay { + t.Fatalf("同步购买快照错误:%+v", usage) + } + } + + if err := tx.Model(&model.Package{}).Where("id IN ?", []uint{formal.ID, addon.ID}).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil { + t.Fatalf("修改套餐当前配置失败:%v", err) + } + if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", shopID).Update("expiry_base_override", nil).Error; err != nil { + t.Fatalf("修改分配当前配置失败:%v", err) + } + var usages []model.PackageUsage + if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil { + t.Fatalf("重新查询购买快照失败:%v", err) + } + if len(usages) != 2 || usages[0].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[1].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[0].DurationDaysSnapshot == 99 || usages[1].DurationDaysSnapshot == 99 { + t.Fatalf("购买后配置变化不应修改快照:%+v", usages) + } +} + +// TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage 验证非法配置不会留下空快照使用记录。 +func TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified) + pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, "invalid", 30) + order := newOrderTermsOrder(pkg.ID, card.ID, nil) + service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err == nil { + t.Fatal("非法计时条款必须拒绝同步创建") + } + var count int64 + if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("失败路径不应留下使用记录:count=%d err=%v", count, err) + } +} + +// TestCEndPurchasePersistsSnapshotAndActivatesImmediately 验证 C 端购买无论 expiry_base 如何均写入快照并立即激活。 +func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + // C 端购买前已做实名前置检查;测试用未实名卡以区分"C 端即时激活"与"实名才激活" + card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified) + pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 20) + service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + now := time.Now() + order := &model.Order{ + Model: gorm.Model{ID: uint(time.Now().UnixNano() & 0x7fffffff)}, + OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10), + OrderType: model.OrderTypeSingleCard, + BuyerType: model.BuyerTypePersonal, + IotCardID: &card.ID, + TotalAmount: 100, + Generation: 1, + } + if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil { + t.Fatalf("C 端购买创建主套餐失败:%v", err) + } + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil { + t.Fatalf("查询 C 端购买使用记录失败:%v", err) + } + // 快照必须完整 + if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromActivation || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 20 { + t.Fatalf("C 端购买快照错误:%+v", usage) + } + // C 端购买不等实名,必须立即激活 + if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation { + t.Fatalf("C 端购买必须立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation) + } +} + +// TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot 验证无分配配置时使用套餐默认值写入快照。 +func TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified) + pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 15) + service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + // sellerShopID=nil 模拟平台后台代购(无代理分配) + order := newOrderTermsOrder(pkg.ID, card.ID, nil) + if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil { + t.Fatalf("平台代购创建主套餐失败:%v", err) + } + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil { + t.Fatalf("查询平台代购使用记录失败:%v", err) + } + // 无分配时快照应取套餐默认值 from_purchase + if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 15 { + t.Fatalf("平台代购快照应使用套餐默认值:%+v", usage) + } +} + +func createOrderTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10) + iccid := "89" + suffix + if len(iccid) < 19 { + iccid += "0000000000000000000"[:19-len(iccid)] + } + iccid = iccid[:19] + card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1} + if err := tx.Create(card).Error; err != nil { + t.Fatalf("创建测试卡失败:%v", err) + } + return card +} + +func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + pkg := &model.Package{PackageCode: "UR55-ORDER-" + suffix, PackageName: "UR55同步购买测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"} + if err := tx.Create(pkg).Error; err != nil { + t.Fatalf("创建测试套餐失败:%v", err) + } + return pkg +} + +func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order { + orderID := uint(time.Now().UnixNano() & 0x7fffffff) + return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1} +} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 1f9c4a0..97250db 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2262,9 +2262,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m // 计算下次重置时间(基于套餐周期类型) nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) - // REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态) - // 查询卡类型(行业卡永远直接激活,不等实名) - // 同时查询载体当前实名状态:已实名则跳过 pending,直接激活 + // REALNAME-02: 后台囤货场景三维决策(购买路径 + expiry_base + 实名状态) + // 查询载体当前实名状态:已实名则跳过 pending,直接激活 var currentlyRealnamed bool if carrierType == "iot_card" { var card model.IotCard diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index dcbfa17..1f41d9d 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -658,14 +658,20 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass constants.PackageUsageStatusDepleted, } - baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}) - switch assetType { - case "iot_card": - baseQuery = baseQuery.Where("iot_card_id = ?", assetID) - case "device": - baseQuery = baseQuery.Where("device_id = ?", assetID) - default: - return errors.New(errors.CodeInvalidParam, "无效的资产类型") + newAssetQuery := func() (*gorm.DB, error) { + query := s.db.WithContext(ctx).Model(&model.PackageUsage{}) + switch assetType { + case "iot_card": + return query.Where("iot_card_id = ?", assetID), nil + case "device": + return query.Where("device_id = ?", assetID), nil + default: + return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型") + } + } + baseQuery, err := newAssetQuery() + if err != nil { + return err } var targets []model.PackageUsage @@ -716,7 +722,11 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass if len(mainUsageIDs) > 0 { var addons []model.PackageUsage - if err := baseQuery. + addonQuery, queryErr := newAssetQuery() + if queryErr != nil { + return queryErr + } + if err := addonQuery. Where("master_usage_id IN ?", mainUsageIDs). Where("status IN ?", validStatuses). Find(&addons).Error; err != nil { diff --git a/internal/service/package/activation_terms_integration_test.go b/internal/service/package/activation_terms_integration_test.go new file mode 100644 index 0000000..fc3b34d --- /dev/null +++ b/internal/service/package/activation_terms_integration_test.go @@ -0,0 +1,163 @@ +package packagepkg + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。 +func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) + pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 99) + purchasedAt := time.Now().Add(-48 * time.Hour).Truncate(time.Second) + usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 10, true, purchasedAt) + service := NewActivationService(tx, nil, nil, nil, nil, zap.NewNop()) + if err := service.ActivateByRealname(context.Background(), constants.AssetTypeIotCard, card.ID); err != nil { + t.Fatalf("实名激活失败:%v", err) + } + var refreshed model.PackageUsage + if err := tx.First(&refreshed, usage.ID).Error; err != nil { + t.Fatalf("查询激活结果失败:%v", err) + } + expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, refreshed.CreatedAt, 0, 10) + if refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil || refreshed.ActivatedAt.Unix() != refreshed.CreatedAt.Unix() || refreshed.ExpiresAt.Unix() != expectedExpiry.Unix() { + t.Fatalf("实名激活未使用购买快照:%+v", refreshed) + } +} + +// TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent 验证连续排队按优先级和各自快照接续。 +func TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + redisClient := testutil.NewRedisClient(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) + firstPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90) + secondPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90) + first := createActivationTermsUsage(t, tx, firstPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromActivation, 3, false, time.Now().Add(-time.Hour)) + second := createActivationTermsUsage(t, tx, secondPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 4, false, time.Now()) + service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop()) + activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID) + if err != nil || !activated { + t.Fatalf("激活队首套餐失败:activated=%v err=%v", activated, err) + } + activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID) + if err != nil || activated { + t.Fatalf("已有生效主套餐时重复接续应幂等:activated=%v err=%v", activated, err) + } + if err := tx.Model(&model.PackageUsage{}).Where("id = ?", first.ID).Update("status", constants.PackageUsageStatusExpired).Error; err != nil { + t.Fatalf("结束队首套餐失败:%v", err) + } + activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID) + if err != nil || !activated { + t.Fatalf("激活第二个排队套餐失败:activated=%v err=%v", activated, err) + } + var refreshed []model.PackageUsage + if err := tx.Where("id IN ?", []uint{first.ID, second.ID}).Order("priority").Find(&refreshed).Error; err != nil { + t.Fatalf("查询队列接续结果失败:%v", err) + } + if len(refreshed) != 2 || refreshed[1].ExpiresAt == nil || refreshed[1].ActivatedAt == nil || !refreshed[1].ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed[1].ActivatedAt, 0, 4)) { + t.Fatalf("第二个排队套餐未使用自身快照:%+v", refreshed) + } +} + +// TestRefundFollowUpActivatesNextPackageFromSnapshot 验证退款失效后下一套餐按购买快照接续。 +func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + redisClient := testutil.NewRedisClient(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) + currentPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60) + nextPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60) + current := createActivationTermsUsage(t, tx, currentPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now().Add(-time.Hour)) + now := time.Now() + if err := tx.Model(current).Updates(map[string]any{"status": constants.PackageUsageStatusActive, "activated_at": now, "expires_at": now.AddDate(0, 0, 30)}).Error; err != nil { + t.Fatalf("设置当前生效套餐失败:%v", err) + } + next := createActivationTermsUsage(t, tx, nextPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 6, false, time.Now()) + service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop()) + if err := service.InvalidatePackagesForRefund(context.Background(), constants.AssetTypeIotCard, card.ID, current.OrderID, 77, "UR55-REFUND", ¤t.ID); err != nil { + t.Fatalf("退款失效当前套餐失败:%v", err) + } + activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID) + if err != nil || !activated { + t.Fatalf("退款后接续下一套餐失败:activated=%v err=%v", activated, err) + } + var refreshed model.PackageUsage + if err := tx.First(&refreshed, next.ID).Error; err != nil { + t.Fatalf("查询退款接续结果失败:%v", err) + } + if refreshed.ExpiresAt == nil || refreshed.ActivatedAt == nil || !refreshed.ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 6)) { + t.Fatalf("退款接续未使用下一套餐快照:%+v", refreshed) + } +} + +// TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。 +func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + redisClient := testutil.NewRedisClient(t) + card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) + pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 30) + usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now()) + if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil { + t.Fatalf("禁用快照校验触发器失败:%v", err) + } + if err := tx.Model(usage).Update("calendar_type_snapshot", "invalid").Error; err != nil { + t.Fatalf("构造非法快照失败:%v", err) + } + if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil { + t.Fatalf("恢复快照校验触发器失败:%v", err) + } + service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop()) + if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err == nil { + t.Fatal("非法部分快照必须拒绝激活") + } + var refreshed model.PackageUsage + if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusPending { + t.Fatalf("非法快照失败后状态必须保持待生效:status=%d err=%v", refreshed.Status, err) + } +} + +func createActivationTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10) + iccid := "87" + suffix + if len(iccid) < 19 { + iccid += "0000000000000000000"[:19-len(iccid)] + } + iccid = iccid[:19] + card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1} + if err := tx.Create(card).Error; err != nil { + t.Fatalf("创建激活测试卡失败:%v", err) + } + return card +} + +func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string, durationDays int) *model.Package { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + pkg := &model.Package{PackageCode: "UR55-ACTIVATE-" + suffix, PackageName: "UR55激活测试套餐", PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"} + if err := tx.Create(pkg).Error; err != nil { + t.Fatalf("创建激活测试套餐失败:%v", err) + } + return pkg +} + +func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage { + t.Helper() + unique := uint(time.Now().UnixNano() & 0x7fffffff) + usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays} + if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil { + t.Fatalf("创建激活测试使用记录失败:%v", err) + } + if err := tx.Model(usage).Updates(map[string]any{"status": constants.PackageUsageStatusPending, "pending_realname_activation": pendingRealname}).Error; err != nil { + t.Fatalf("设置激活测试状态失败:%v", err) + } + return usage +} diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 998a7cf..353cdea 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -625,16 +625,13 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa CalendarType: pkg.CalendarType, DurationDays: durationDays, DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - DefaultExpiryBase: pkg.ExpiryBase, - DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), - EffectiveExpiryBase: pkg.ExpiryBase, - EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ExpiryBase: pkg.ExpiryBase, + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } + initPackageExpiryBaseFields(resp, pkg) userType := middleware.GetUserTypeFromContext(ctx) shopID := middleware.GetShopIDFromContext(ctx) @@ -707,16 +704,13 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package CalendarType: pkg.CalendarType, DurationDays: durationDays, DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - DefaultExpiryBase: pkg.ExpiryBase, - DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), - EffectiveExpiryBase: pkg.ExpiryBase, - EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ExpiryBase: pkg.ExpiryBase, + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } + initPackageExpiryBaseFields(resp, pkg) if allocationMap != nil { if allocation, ok := allocationMap[pkg.ID]; ok { @@ -744,6 +738,14 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package return resp } +// initPackageExpiryBaseFields 初始化响应中生效条件默认字段(无分配覆盖时跟随套餐默认)。 +func initPackageExpiryBaseFields(resp *dto.PackageResponse, pkg *model.Package) { + resp.DefaultExpiryBase = pkg.ExpiryBase + resp.DefaultExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase) + resp.EffectiveExpiryBase = pkg.ExpiryBase + resp.EffectiveExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase) +} + func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) { resp.ExpiryBaseOverride = allocation.ExpiryBaseOverride resp.ExpiryBaseOverrideName = ExpiryBaseOverrideName(allocation.ExpiryBaseOverride) diff --git a/internal/service/package/terms.go b/internal/service/package/terms.go index e8c8a7d..34697c0 100644 --- a/internal/service/package/terms.go +++ b/internal/service/package/terms.go @@ -9,13 +9,13 @@ import ( // ValidateExpiryBaseOverride 校验显式提交的分配生效条件覆盖。 func ValidateExpiryBaseOverride(value *string, submitted bool) (*string, error) { if !submitted { - return nil, errors.New(errors.CodeInvalidParam) + return nil, errors.New(errors.CodeInvalidParam, "expiry_base_override 字段未提交,须显式传值(null 或合法枚举)") } if value == nil { return nil, nil } if *value != constants.PackageExpiryBaseFromActivation && *value != constants.PackageExpiryBaseFromPurchase { - return nil, errors.New(errors.CodeInvalidParam) + return nil, errors.New(errors.CodeInvalidParam, "expiry_base_override 枚举值非法,合法值:from_activation, from_purchase") } return value, nil } diff --git a/internal/store/postgres/package_terms_snapshot_integration_test.go b/internal/store/postgres/package_terms_snapshot_integration_test.go index 53fe0ae..50ebcb8 100644 --- a/internal/store/postgres/package_terms_snapshot_integration_test.go +++ b/internal/store/postgres/package_terms_snapshot_integration_test.go @@ -33,10 +33,15 @@ func TestPackageUsageAcceptsCompleteTermsSnapshot(t *testing.T) { // TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus 验证历史空快照可继续状态流转。 func TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus(t *testing.T) { tx := testutil.NewPostgresTransaction(t) - var usage model.PackageUsage - if err := tx.Where("expiry_base_snapshot = '' AND calendar_type_snapshot = '' AND duration_months_snapshot = 0 AND duration_days_snapshot = 0"). - First(&usage).Error; err != nil { - t.Skip("当前开发库没有历史空快照记录") + if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil { + t.Fatalf("禁用快照校验触发器失败:%v", err) + } + usage := newTermsSnapshotTestUsage() + if err := tx.Create(usage).Error; err != nil { + t.Fatalf("构造历史空快照记录失败:%v", err) + } + if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil { + t.Fatalf("恢复快照校验触发器失败:%v", err) } if err := tx.Model(&usage).Update("status", usage.Status).Error; err != nil { t.Fatalf("历史空快照记录应可继续更新状态:%v", err) diff --git a/internal/task/auto_purchase_terms_integration_test.go b/internal/task/auto_purchase_terms_integration_test.go new file mode 100644 index 0000000..1e4c30b --- /dev/null +++ b/internal/task/auto_purchase_terms_integration_test.go @@ -0,0 +1,169 @@ +package task + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision 验证自动购包复用快照并遵守实名起算规则。 +func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) { + testCases := []struct { + name string + defaultBase string + override *string + realnameStatus int + expectedBase string + expectedStatus int + expectedPending bool + }{ + {name: "跟随默认等待实名", defaultBase: constants.PackageExpiryBaseFromActivation, realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusPending, expectedPending: true}, + {name: "覆盖购买即生效", defaultBase: constants.PackageExpiryBaseFromActivation, override: taskStringPointer(constants.PackageExpiryBaseFromPurchase), realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromPurchase, expectedStatus: constants.PackageUsageStatusActive}, + {name: "覆盖实名但已实名", defaultBase: constants.PackageExpiryBaseFromPurchase, override: taskStringPointer(constants.PackageExpiryBaseFromActivation), realnameStatus: constants.RealNameStatusVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusActive}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createAutoPurchaseCard(t, tx, testCase.realnameStatus) + pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, testCase.defaultBase, 31) + shopID := uint(time.Now().UnixNano() & 0x7fffffff) + if testCase.override != nil { + allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: pkg.ID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: testCase.override} + if err := tx.Create(allocation).Error; err != nil { + t.Fatalf("创建套餐覆盖配置失败:%v", err) + } + } + handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + order := newAutoPurchaseOrder(card.ID, &shopID) + if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil { + t.Fatalf("自动购包创建主套餐失败:%v", err) + } + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil { + t.Fatalf("查询自动购包记录失败:%v", err) + } + if usage.ExpiryBaseSnapshot != testCase.expectedBase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 31 || usage.Status != testCase.expectedStatus || usage.PendingRealnameActivation != testCase.expectedPending { + t.Fatalf("自动购包快照或实名决策错误:%+v", usage) + } + }) + } +} + +// TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname 验证套餐默认为 from_purchase 时自动购包不需要实名即激活。 +func TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + // 卡未实名,但套餐默认 from_purchase,应立即激活 + card := createAutoPurchaseCard(t, tx, constants.RealNameStatusNotVerified) + pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 14) + handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + order := newAutoPurchaseOrder(card.ID, nil) + if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil { + t.Fatalf("from_purchase 默认值自动购包失败:%v", err) + } + var usage model.PackageUsage + if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil { + t.Fatalf("查询自动购包记录失败:%v", err) + } + if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 14 { + t.Fatalf("from_purchase 默认值快照错误:%+v", usage) + } + if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation { + t.Fatalf("from_purchase 默认值未实名仍应立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation) + } +} + +// TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact 验证主套餐、加油包和重复消费依赖数据库事实去重。 +func TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified) + formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30) + addon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 7) + handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + formalOrder := newAutoPurchaseOrder(card.ID, nil) + addonOrder := newAutoPurchaseOrder(card.ID, nil) + now := time.Now() + if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil { + t.Fatalf("首次自动创建主套餐失败:%v", err) + } + if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil { + t.Fatalf("重复消费应按业务事实幂等:%v", err) + } + if err := handler.activatePackages(context.Background(), tx, addonOrder, []*model.Package{addon}, now.Add(time.Minute)); err != nil { + t.Fatalf("自动创建加油包失败:%v", err) + } + var usages []model.PackageUsage + if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil { + t.Fatalf("查询自动购包记录失败:%v", err) + } + if len(usages) != 2 { + t.Fatalf("重复消费只应形成一组记录,实际 %d", len(usages)) + } + for _, usage := range usages { + if usage.ExpiryBaseSnapshot == "" || usage.CalendarTypeSnapshot == "" { + t.Fatalf("自动购包不得产生空快照:%+v", usage) + } + } + if usages[1].MasterUsageID == nil { + t.Fatalf("加油包必须关联主套餐:%+v", usages[1]) + } +} + +// TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms 验证同一事务中后续套餐失败时不留下部分使用记录。 +func TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified) + formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30) + invalidAddon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, "invalid", 7) + handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} + order := newAutoPurchaseOrder(card.ID, nil) + err := tx.Transaction(func(inner *gorm.DB) error { + return handler.activatePackages(context.Background(), inner, order, []*model.Package{formal, invalidAddon}, time.Now()) + }) + if err == nil { + t.Fatal("非法加油包计时条款必须使业务事务失败") + } + var count int64 + if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("事务失败不应留下部分主套餐:count=%d err=%v", count, err) + } +} + +func createAutoPurchaseCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10) + iccid := "88" + suffix + if len(iccid) < 19 { + iccid += "0000000000000000000"[:19-len(iccid)] + } + iccid = iccid[:19] + card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1} + if err := tx.Create(card).Error; err != nil { + t.Fatalf("创建自动购包测试卡失败:%v", err) + } + return card +} + +func createAutoPurchasePackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package { + t.Helper() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + pkg := &model.Package{PackageCode: "UR55-AUTO-" + suffix, PackageName: "UR55自动购包测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"} + if err := tx.Create(pkg).Error; err != nil { + t.Fatalf("创建自动购包测试套餐失败:%v", err) + } + return pkg +} + +func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order { + orderID := uint(time.Now().UnixNano() & 0x7fffffff) + return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-AUTO-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1} +} + +func taskStringPointer(value string) *string { return &value } diff --git a/internal/testutil/database.go b/internal/testutil/database.go index f9e7e6b..fcaa758 100644 --- a/internal/testutil/database.go +++ b/internal/testutil/database.go @@ -3,10 +3,12 @@ package testutil import ( "os" + "strconv" "testing" "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/database" + "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) @@ -37,3 +39,25 @@ func NewPostgresTransaction(t *testing.T) *gorm.DB { }) return tx } + +// NewRedisClient 创建真实 Redis 测试客户端并在测试结束时关闭。 +func NewRedisClient(t *testing.T) *redis.Client { + t.Helper() + if os.Getenv("JUNHONG_REDIS_ADDRESS") == "" { + t.Skip("未加载 .env.local,跳过依赖真实 Redis 的集成测试") + } + cfg, err := config.Load() + if err != nil { + t.Fatalf("加载测试配置失败:%v", err) + } + client, err := database.NewRedisClient(database.RedisConfig{ + Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, + DB: cfg.Redis.DB, PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns, + DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout, + }, zap.NewNop()) + if err != nil { + t.Fatalf("连接 Redis 失败:%v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +}