修复套餐快照集成测试与自动购包幂等

This commit is contained in:
2026-07-23 12:23:52 +09:00
parent 2df44d8e3c
commit 992757c056
8 changed files with 33 additions and 23 deletions

View File

@@ -8710,6 +8710,8 @@ components:
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效) description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
nullable: true nullable: true
type: string type: string
required:
- expiry_base_override
type: object type: object
DtoUpdateAssetPackageExpiresAtRequest: DtoUpdateAssetPackageExpiresAtRequest:
properties: properties:

View File

@@ -21,6 +21,7 @@ func NewShopPackageBatchAllocationHandler(service *batchAllocationService.Servic
} }
// BatchAllocate 批量分配套餐 // BatchAllocate 批量分配套餐
// POST /api/admin/shop-package-allocations/batch
func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error { func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error {
var req dto.BatchAllocatePackagesRequest var req dto.BatchAllocatePackagesRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {

View File

@@ -1,8 +1,5 @@
package admin package admin
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
import ( import (
"bytes" "bytes"
"context" "context"
@@ -27,6 +24,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
// TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。 // TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。
func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) { func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
testCases := []struct { testCases := []struct {

View File

@@ -1,8 +1,5 @@
package order package order
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = testIDMask
import ( import (
"context" "context"
"strconv" "strconv"
@@ -17,6 +14,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。 // TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。
func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) { func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
tx := testutil.NewPostgresTransaction(t) tx := testutil.NewPostgresTransaction(t)
@@ -99,13 +99,13 @@ func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()} service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
now := time.Now() now := time.Now()
order := &model.Order{ order := &model.Order{
Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)}, Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)},
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10), OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
OrderType: model.OrderTypeSingleCard, OrderType: model.OrderTypeSingleCard,
BuyerType: model.BuyerTypePersonal, BuyerType: model.BuyerTypePersonal,
IotCardID: &card.ID, IotCardID: &card.ID,
TotalAmount: 100, TotalAmount: 100,
Generation: 1, Generation: 1,
} }
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil { if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
t.Fatalf("C 端购买创建主套餐失败:%v", err) t.Fatalf("C 端购买创建主套餐失败:%v", err)

View File

@@ -1,8 +1,5 @@
package packagepkg package packagepkg
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = testIDMask
import ( import (
"context" "context"
"strconv" "strconv"
@@ -16,6 +13,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。 // TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。
func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) { func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) {
tx := testutil.NewPostgresTransaction(t) tx := testutil.NewPostgresTransaction(t)
@@ -134,6 +134,10 @@ func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) {
redisClient := testutil.NewRedisClient(t) redisClient := testutil.NewRedisClient(t)
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified) card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21) pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
// 仅在会回滚的测试事务内禁用触发器,以构造 UR#55 上线前的历史空快照记录。
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
t.Fatalf("禁用快照校验触发器失败:%v", err)
}
// 历史记录:四个快照字段全为空值/零值 // 历史记录:四个快照字段全为空值/零值
unique := uint(time.Now().UnixNano() & testIDMask) unique := uint(time.Now().UnixNano() & testIDMask)
usage := &model.PackageUsage{ usage := &model.PackageUsage{

View File

@@ -20,11 +20,11 @@ type Service struct {
packageAllocationStore *postgres.ShopPackageAllocationStore packageAllocationStore *postgres.ShopPackageAllocationStore
seriesAllocationStore *postgres.ShopSeriesAllocationStore seriesAllocationStore *postgres.ShopSeriesAllocationStore
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
auditService AuditService auditService AuditLogger
} }
// AuditService 敏感配置变更审计能力。 // AuditLogger 敏感配置变更审计能力。
type AuditService interface { type AuditLogger interface {
LogOperation(ctx context.Context, log *model.AccountOperationLog) LogOperation(ctx context.Context, log *model.AccountOperationLog)
} }
@@ -34,7 +34,7 @@ func New(
packageAllocationStore *postgres.ShopPackageAllocationStore, packageAllocationStore *postgres.ShopPackageAllocationStore,
seriesAllocationStore *postgres.ShopSeriesAllocationStore, seriesAllocationStore *postgres.ShopSeriesAllocationStore,
shopStore *postgres.ShopStore, shopStore *postgres.ShopStore,
auditService AuditService, auditService AuditLogger,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,

View File

@@ -462,6 +462,10 @@ func (h *AutoPurchaseHandler) activatePackages(
} else { } else {
return errors.New("无效的订单载体") return errors.New("无效的订单载体")
} }
// 在查询既有记录前锁定载体,避免并发任务同时判定不存在而重复创建套餐使用记录。
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
return err
}
for _, pkg := range packages { for _, pkg := range packages {
var existingUsage model.PackageUsage var existingUsage model.PackageUsage

View File

@@ -1,8 +1,5 @@
package task package task
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = testIDMask
import ( import (
"context" "context"
"strconv" "strconv"
@@ -17,6 +14,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
const testIDMask = 0x7fffffff
// TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision 验证自动购包复用快照并遵守实名起算规则。 // TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision 验证自动购包复用快照并遵守实名起算规则。
func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) { func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) {
testCases := []struct { testCases := []struct {
@@ -192,4 +192,3 @@ func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order {
orderID := uint(time.Now().UnixNano() & testIDMask) orderID := uint(time.Now().UnixNano() & testIDMask)
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} 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}
} }