实现套餐生效条件覆盖与购买快照

This commit is contained in:
2026-07-22 20:51:12 +09:00
parent c7f8b4c702
commit 9818537239
30 changed files with 1092 additions and 149 deletions

View File

@@ -113,18 +113,22 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
}
}
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
terms, termsErr := ResolveUsageTerms(usage, &pkg, s.logger)
if termsErr != nil {
return termsErr
}
// REALNAME-04: 按购买快照选择激活计时基准
// from_purchase购买时起算用 PackageUsage 创建时间;否则用实名触发当前时刻
var activatedAt time.Time
if pkg.ExpiryBase == "from_purchase" {
if terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
activatedAt = usage.CreatedAt
} else {
activatedAt = now
}
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
// 计算下次重置时间
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
// 更新套餐使用记录
updates := map[string]interface{}{
@@ -552,20 +556,24 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB,
}
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
terms, err := ResolveUsageTerms(usage, pkg, s.logger)
if err != nil {
return err
}
// ExpiryBase=from_purchase 只用于"等待实名激活"场景REALNAME-04套餐已购买但资产未实名
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
// 这种情况下 usage.PendingRealnameActivation 为 false不应该套用购买时间否则排队等待的天数会
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
var activatedAt time.Time
if usage.PendingRealnameActivation && pkg.ExpiryBase == "from_purchase" {
if usage.PendingRealnameActivation && terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
activatedAt = usage.CreatedAt
} else {
activatedAt = now
}
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
// 计算下次重置时间
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
// 更新套餐使用记录
updates := map[string]interface{}{

View File

@@ -101,18 +101,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
}
pkg := &model.Package{
PackageCode: req.PackageCode,
PackageName: req.PackageName,
PackageType: req.PackageType,
IsGift: req.IsGift,
DurationMonths: req.DurationMonths,
CostPrice: req.CostPrice,
PriceConfigStatus: priceConfigStatus,
PackageCode: req.PackageCode,
PackageName: req.PackageName,
PackageType: req.PackageType,
IsGift: req.IsGift,
DurationMonths: req.DurationMonths,
CostPrice: req.CostPrice,
PriceConfigStatus: priceConfigStatus,
SuggestedRetailPrice: storedRetailPrice,
EnableVirtualData: req.EnableVirtualData,
CalendarType: calendarType,
Status: constants.StatusEnabled,
ShelfStatus: 2,
EnableVirtualData: req.EnableVirtualData,
CalendarType: calendarType,
Status: constants.StatusEnabled,
ShelfStatus: 2,
}
if req.SeriesID != nil {
pkg.SeriesID = *req.SeriesID
@@ -607,29 +607,33 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
}
resp := &dto.PackageResponse{
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
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),
}
userType := middleware.GetUserTypeFromContext(ctx)
@@ -646,6 +650,7 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
profitMargin := effectiveRetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
resp.ShelfStatus = allocation.ShelfStatus
applyAllocationExpiryBase(resp, pkg, allocation)
}
} else {
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
@@ -684,29 +689,33 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
}
resp := &dto.PackageResponse{
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
CalendarType: pkg.CalendarType,
DurationDays: durationDays,
DataResetCycle: pkg.DataResetCycle,
ExpiryBase: pkg.ExpiryBase,
Status: pkg.Status,
ShelfStatus: pkg.ShelfStatus,
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
ID: pkg.ID,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
SeriesID: seriesID,
PackageType: pkg.PackageType,
IsGift: pkg.IsGift,
DurationMonths: pkg.DurationMonths,
RealDataMB: pkg.RealDataMB,
VirtualDataMB: pkg.VirtualDataMB,
EnableVirtualData: pkg.EnableVirtualData,
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
CostPrice: pkg.CostPrice,
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
PriceConfigStatus: pkg.PriceConfigStatus,
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
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),
}
if allocationMap != nil {
@@ -719,6 +728,7 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
resp.EffectiveRetailPrice = &effectiveRetailPrice
profitMargin := effectiveRetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
applyAllocationExpiryBase(resp, pkg, allocation)
resp.ShelfStatus = allocation.ShelfStatus
}
} else {
@@ -734,6 +744,13 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
return resp
}
func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) {
resp.ExpiryBaseOverride = allocation.ExpiryBaseOverride
resp.ExpiryBaseOverrideName = ExpiryBaseOverrideName(allocation.ExpiryBaseOverride)
resp.EffectiveExpiryBase = EffectiveExpiryBase(pkg, allocation)
resp.EffectiveExpiryBaseName = ExpiryBaseName(resp.EffectiveExpiryBase)
}
// fillCommissionInfo 填充返佣信息到响应中
func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) {
seriesAllocation, hasAllocation := seriesAllocationMap[seriesID]

View File

@@ -0,0 +1,49 @@
package packagepkg
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ValidateExpiryBaseOverride 校验显式提交的分配生效条件覆盖。
func ValidateExpiryBaseOverride(value *string, submitted bool) (*string, error) {
if !submitted {
return nil, errors.New(errors.CodeInvalidParam)
}
if value == nil {
return nil, nil
}
if *value != constants.PackageExpiryBaseFromActivation && *value != constants.PackageExpiryBaseFromPurchase {
return nil, errors.New(errors.CodeInvalidParam)
}
return value, nil
}
// EffectiveExpiryBase 返回套餐分配最终采用的生效条件。
func EffectiveExpiryBase(pkg *model.Package, allocation *model.ShopPackageAllocation) string {
if allocation != nil && allocation.ExpiryBaseOverride != nil {
return *allocation.ExpiryBaseOverride
}
return pkg.ExpiryBase
}
// ExpiryBaseName 返回生效条件中文名称。
func ExpiryBaseName(value string) string {
switch value {
case constants.PackageExpiryBaseFromActivation:
return "实名激活时生效"
case constants.PackageExpiryBaseFromPurchase:
return "购买即生效"
default:
return "未知"
}
}
// ExpiryBaseOverrideName 返回覆盖值中文名称,空值表示跟随套餐默认。
func ExpiryBaseOverrideName(value *string) string {
if value == nil {
return "跟随套餐默认"
}
return ExpiryBaseName(*value)
}

View File

@@ -0,0 +1,57 @@
package packagepkg
import (
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// TestValidateExpiryBaseOverride 验证覆盖字段必须显式提交且仅接受既定枚举。
func TestValidateExpiryBaseOverride(t *testing.T) {
fromPurchase := constants.PackageExpiryBaseFromPurchase
invalid := "from_realname"
tests := []struct {
name string
value *string
submitted bool
wantValue *string
wantErr bool
}{
{name: "字段缺失", wantErr: true},
{name: "跟随默认", submitted: true},
{name: "购买即生效", value: &fromPurchase, submitted: true, wantValue: &fromPurchase},
{name: "非法枚举", value: &invalid, submitted: true, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ValidateExpiryBaseOverride(test.value, test.submitted)
if (err != nil) != test.wantErr {
t.Fatalf("错误状态不符合预期:%v", err)
}
if test.wantValue != nil && (got == nil || *got != *test.wantValue) {
t.Fatalf("覆盖值不符合预期:%v", got)
}
if test.wantValue == nil && !test.wantErr && got != nil {
t.Fatalf("期望跟随默认,实际为:%v", *got)
}
})
}
}
// TestEffectiveExpiryBase 验证覆盖优先于套餐默认值。
func TestEffectiveExpiryBase(t *testing.T) {
pkg := &model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation}
if got := EffectiveExpiryBase(pkg, nil); got != constants.PackageExpiryBaseFromActivation {
t.Fatalf("无覆盖时应使用套餐默认值,实际为 %s", got)
}
override := constants.PackageExpiryBaseFromPurchase
allocation := &model.ShopPackageAllocation{ExpiryBaseOverride: &override}
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
t.Fatalf("有覆盖时应使用覆盖值,实际为 %s", got)
}
pkg.ExpiryBase = constants.PackageExpiryBaseFromPurchase
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
t.Fatalf("套餐默认值变化不应改变覆盖结果,实际为 %s", got)
}
}

View File

@@ -0,0 +1,47 @@
package packagepkg
import (
"sync/atomic"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
)
var historicalTermsFallbackCount atomic.Uint64
// ResolveUsageTerms 优先读取使用记录快照,仅对完整空快照的历史记录显式回退。
func ResolveUsageTerms(usage *model.PackageUsage, pkg *model.Package, logger *zap.Logger) (packagedomain.TermsSnapshot, error) {
terms := packagedomain.TermsSnapshotFromUsage(usage)
if terms.IsValid() {
return terms, nil
}
if !isEmptyHistoricalTerms(usage) {
if logger != nil {
logger.Error("套餐使用记录计时快照异常", zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID))
}
return packagedomain.TermsSnapshot{}, errors.New(errors.CodeInternalError, "套餐使用记录计时快照异常")
}
fallback, err := packagedomain.ResolveTermsSnapshot(pkg, nil)
if err != nil {
return packagedomain.TermsSnapshot{}, err
}
historicalTermsFallbackCount.Add(1)
if logger != nil {
logger.Warn("历史套餐使用记录缺少计时快照,回退套餐当前配置",
zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID),
zap.Uint64("historical_terms_fallback_count", historicalTermsFallbackCount.Load()))
}
return fallback, nil
}
// HistoricalTermsFallbackCount 返回历史计时条款回退累计次数。
func HistoricalTermsFallbackCount() uint64 {
return historicalTermsFallbackCount.Load()
}
func isEmptyHistoricalTerms(usage *model.PackageUsage) bool {
return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" &&
usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0
}

View File

@@ -0,0 +1,59 @@
package packagepkg
import (
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"go.uber.org/zap"
)
// TestResolveUsageTerms 验证快照优先、历史完整空值回退和部分缺失拒绝。
func TestResolveUsageTerms(t *testing.T) {
pkg := &model.Package{
ExpiryBase: constants.PackageExpiryBaseFromActivation,
CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30,
}
usage := &model.PackageUsage{
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
CalendarTypeSnapshot: constants.PackageCalendarTypeNaturalMonth,
DurationMonthsSnapshot: 12,
}
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
if err != nil || terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.DurationMonths != 12 {
t.Fatalf("应优先读取不可变快照:%+v, %v", terms, err)
}
before := HistoricalTermsFallbackCount()
historical := &model.PackageUsage{Model: usage.Model}
terms, err = ResolveUsageTerms(historical, pkg, zap.NewNop())
if err != nil || terms.DurationDays != 30 || HistoricalTermsFallbackCount() != before+1 {
t.Fatalf("历史空快照应可观测回退:%+v, %v", terms, err)
}
broken := &model.PackageUsage{ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase}
if _, err = ResolveUsageTerms(broken, pkg, zap.NewNop()); err == nil {
t.Fatal("部分缺失快照必须拒绝")
}
}
// TestResolveUsageTermsKeepsPurchasedTerms 验证购买后修改套餐配置不改变已有使用记录语义。
func TestResolveUsageTermsKeepsPurchasedTerms(t *testing.T) {
usage := &model.PackageUsage{
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
DurationDaysSnapshot: 90,
}
pkg := &model.Package{
ExpiryBase: constants.PackageExpiryBaseFromActivation,
CalendarType: constants.PackageCalendarTypeNaturalMonth,
DurationMonths: 1,
}
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
if err != nil {
t.Fatalf("读取购买快照失败:%v", err)
}
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.CalendarType != constants.PackageCalendarTypeByDay || terms.DurationDays != 90 {
t.Fatalf("套餐当前配置不应覆盖购买快照:%+v", terms)
}
}