实现资产预计最终到期时间
This commit is contained in:
215
internal/query/packageexpiry/query.go
Normal file
215
internal/query/packageexpiry/query.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Package packageexpiry 提供资产主套餐最终到期时间的统一只读推算。
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// Query 查询单个或批量资产的套餐最终到期时间。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewQuery 创建套餐最终到期查询。
|
||||
func NewQuery(db *gorm.DB) *Query {
|
||||
return &Query{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// Resolve 查询单个资产的最终到期推算结果。
|
||||
func (q *Query) Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error) {
|
||||
results, err := q.ResolveBatch(ctx, assetType, []uint{assetID})
|
||||
if err != nil {
|
||||
return dto.PackageExpiryEstimate{}, err
|
||||
}
|
||||
return results[assetID], nil
|
||||
}
|
||||
|
||||
// ResolveBatch 批量查询同类型资产的最终到期推算结果,避免列表场景逐资产读取。
|
||||
func (q *Query) ResolveBatch(ctx context.Context, assetType string, assetIDs []uint) (map[uint]dto.PackageExpiryEstimate, error) {
|
||||
results := make(map[uint]dto.PackageExpiryEstimate, len(assetIDs))
|
||||
if len(assetIDs) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
column, ok := packageUsageAssetColumn(assetType)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
usages := make([]*model.PackageUsage, 0)
|
||||
if err := q.db.WithContext(ctx).
|
||||
Where(column+" IN ?", assetIDs).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Where("refund_id IS NULL").
|
||||
Order("priority ASC, created_at ASC, id ASC").
|
||||
Find(&usages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐最终到期记录失败")
|
||||
}
|
||||
|
||||
packages, err := q.loadFallbackPackages(ctx, usages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := make(map[uint][]*model.PackageUsage, len(assetIDs))
|
||||
for _, usage := range usages {
|
||||
assetID := usage.IotCardID
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
assetID = usage.DeviceID
|
||||
}
|
||||
grouped[assetID] = append(grouped[assetID], usage)
|
||||
}
|
||||
for _, assetID := range assetIDs {
|
||||
results[assetID] = Calculate(grouped[assetID], packages, q.now())
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (q *Query) loadFallbackPackages(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
|
||||
ids := make([]uint, 0)
|
||||
seen := make(map[uint]struct{})
|
||||
for _, usage := range usages {
|
||||
if !isEmptyHistoricalTerms(usage) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[usage.PackageID]; !exists {
|
||||
seen[usage.PackageID] = struct{}{}
|
||||
ids = append(ids, usage.PackageID)
|
||||
}
|
||||
}
|
||||
packages := make(map[uint]*model.Package, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return packages, nil
|
||||
}
|
||||
var rows []*model.Package
|
||||
if err := q.db.WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史套餐计时条款失败")
|
||||
}
|
||||
for _, pkg := range rows {
|
||||
packages[pkg.ID] = pkg
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// Calculate 使用已批量读取的记录和历史套餐回退数据计算最终到期时间。
|
||||
func Calculate(usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time) dto.PackageExpiryEstimate {
|
||||
eligible := make([]*model.PackageUsage, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
if usage == nil || usage.DeletedAt.Valid || usage.RefundID != nil || usage.MasterUsageID != nil {
|
||||
continue
|
||||
}
|
||||
if usage.Status == constants.PackageUsageStatusPending || usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
|
||||
eligible = append(eligible, usage)
|
||||
}
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusNone, nil, now)
|
||||
}
|
||||
sort.SliceStable(eligible, func(i, j int) bool {
|
||||
if eligible[i].Priority != eligible[j].Priority {
|
||||
return eligible[i].Priority < eligible[j].Priority
|
||||
}
|
||||
if !eligible[i].CreatedAt.Equal(eligible[j].CreatedAt) {
|
||||
return eligible[i].CreatedAt.Before(eligible[j].CreatedAt)
|
||||
}
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
})
|
||||
|
||||
currentIndex := -1
|
||||
for i, usage := range eligible {
|
||||
if usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
|
||||
if currentIndex >= 0 || usage.ExpiresAt == nil {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
currentIndex = i
|
||||
}
|
||||
}
|
||||
if currentIndex < 0 {
|
||||
first := eligible[0]
|
||||
terms, ok := resolveTerms(first, packages)
|
||||
if !ok {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
if first.PendingRealnameActivation || terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusWaitingActivation, nil, now)
|
||||
}
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
for i := 0; i < currentIndex; i++ {
|
||||
if eligible[i].Status == constants.PackageUsageStatusPending {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
}
|
||||
cursor := eligible[currentIndex].ExpiresAt.In(shanghaiLocation)
|
||||
for _, usage := range eligible[currentIndex+1:] {
|
||||
if usage.Status != constants.PackageUsageStatusPending {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
terms, ok := resolveTerms(usage, packages)
|
||||
if !ok {
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
|
||||
}
|
||||
start := cursor.Add(time.Second)
|
||||
cursor = packagepkg.CalculateExpiryTime(terms.CalendarType, start, terms.DurationMonths, terms.DurationDays).In(shanghaiLocation)
|
||||
}
|
||||
return newEstimate(constants.PackageExpiryEstimateStatusExact, &cursor, now)
|
||||
}
|
||||
|
||||
func resolveTerms(usage *model.PackageUsage, packages map[uint]*model.Package) (packagedomain.TermsSnapshot, bool) {
|
||||
terms := packagedomain.TermsSnapshotFromUsage(usage)
|
||||
if terms.IsValid() {
|
||||
return terms, true
|
||||
}
|
||||
if !isEmptyHistoricalTerms(usage) {
|
||||
return packagedomain.TermsSnapshot{}, false
|
||||
}
|
||||
pkg := packages[usage.PackageID]
|
||||
terms, err := packagedomain.ResolveTermsSnapshot(pkg, nil)
|
||||
return terms, err == nil
|
||||
}
|
||||
|
||||
func isEmptyHistoricalTerms(usage *model.PackageUsage) bool {
|
||||
// UR55 的 000163 迁移已在测试环境生效:触发器拒绝新记录写入空快照,
|
||||
// 因此整组空值只可能是迁移前遗留记录;任意部分空值仍按异常处理。
|
||||
return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" && usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0
|
||||
}
|
||||
|
||||
func packageUsageAssetColumn(assetType string) (string, bool) {
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard, constants.AssetResolveTypeCard:
|
||||
return "iot_card_id", true
|
||||
case constants.AssetTypeDevice:
|
||||
return "device_id", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func newEstimate(status string, expiresAt *time.Time, now time.Time) dto.PackageExpiryEstimate {
|
||||
result := dto.PackageExpiryEstimate{ExpiryEstimateStatus: status, ExpiryEstimateStatusName: constants.GetPackageExpiryEstimateStatusName(status)}
|
||||
if status != constants.PackageExpiryEstimateStatusExact || expiresAt == nil {
|
||||
return result
|
||||
}
|
||||
days := dateInShanghai(*expiresAt).Sub(dateInShanghai(now))
|
||||
daysUntil := int(days.Hours() / 24)
|
||||
result.EstimatedFinalExpiresAt = expiresAt
|
||||
result.DaysUntilFinalExpiry = &daysUntil
|
||||
result.IsExpiring = daysUntil >= 0 && daysUntil <= 15
|
||||
return result
|
||||
}
|
||||
|
||||
func dateInShanghai(value time.Time) time.Time {
|
||||
local := value.In(shanghaiLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
167
internal/query/packageexpiry/query_test.go
Normal file
167
internal/query/packageexpiry/query_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package packageexpiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCalculate(t *testing.T) {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
|
||||
currentExpiry := time.Date(2026, 7, 31, 23, 59, 59, 0, location)
|
||||
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, location)
|
||||
newUsage := func(id uint, status int, priority int) *model.PackageUsage {
|
||||
return &model.PackageUsage{
|
||||
Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)),
|
||||
PackageID: id,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
||||
DurationDaysSnapshot: 3,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
usages []*model.PackageUsage
|
||||
packages map[uint]*model.Package
|
||||
wantStatus string
|
||||
wantDate *time.Time
|
||||
wantDays *int
|
||||
}{
|
||||
{name: "无套餐", wantStatus: constants.PackageExpiryEstimateStatusNone},
|
||||
{
|
||||
name: "当前套餐", usages: func() []*model.PackageUsage {
|
||||
u := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
u.ExpiresAt = ¤tExpiry
|
||||
return []*model.PackageUsage{u}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: ¤tExpiry, wantDays: intPointer(8),
|
||||
},
|
||||
{
|
||||
name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage {
|
||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
queued.DurationDaysSnapshot = 3
|
||||
return []*model.PackageUsage{current, queued}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
|
||||
wantDate: timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), wantDays: intPointer(12),
|
||||
},
|
||||
{
|
||||
name: "自然月月末", usages: func() []*model.PackageUsage {
|
||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
queued.CalendarTypeSnapshot = constants.PackageCalendarTypeNaturalMonth
|
||||
queued.DurationDaysSnapshot = 0
|
||||
queued.DurationMonthsSnapshot = 1
|
||||
return []*model.PackageUsage{current, queued}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
|
||||
wantDate: timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), wantDays: intPointer(69),
|
||||
},
|
||||
{
|
||||
name: "等待实名激活", usages: func() []*model.PackageUsage {
|
||||
u := newUsage(1, constants.PackageUsageStatusPending, 1)
|
||||
u.PendingRealnameActivation = true
|
||||
return []*model.PackageUsage{u}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusWaitingActivation,
|
||||
},
|
||||
{
|
||||
name: "历史空快照回退", usages: func() []*model.PackageUsage {
|
||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
queued.ExpiryBaseSnapshot, queued.CalendarTypeSnapshot, queued.DurationDaysSnapshot = "", "", 0
|
||||
return []*model.PackageUsage{current, queued}
|
||||
}(), packages: map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}},
|
||||
wantStatus: constants.PackageExpiryEstimateStatusExact,
|
||||
},
|
||||
{
|
||||
name: "非法快照", usages: func() []*model.PackageUsage {
|
||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
current.ExpiresAt = ¤tExpiry
|
||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
||||
queued.CalendarTypeSnapshot = "bad"
|
||||
return []*model.PackageUsage{current, queued}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Calculate(tt.usages, tt.packages, now)
|
||||
if got.ExpiryEstimateStatus != tt.wantStatus {
|
||||
t.Fatalf("状态错误:want=%s got=%s", tt.wantStatus, got.ExpiryEstimateStatus)
|
||||
}
|
||||
if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) {
|
||||
t.Fatalf("到期时间错误:want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt)
|
||||
}
|
||||
if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) {
|
||||
t.Fatalf("剩余天数错误:want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry)
|
||||
}
|
||||
if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
||||
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryResolveBatchUsesOneBatchLoad 验证一页资产只通过批量读取计算,不逐卡查询套餐。
|
||||
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
|
||||
assetIDs := make([]uint, 0, 100)
|
||||
for i := 1; i <= 100; i++ {
|
||||
assetID := uint(970000 + i)
|
||||
assetIDs = append(assetIDs, assetID)
|
||||
expiresAt := now.AddDate(0, 0, i%16)
|
||||
usage := &model.PackageUsage{
|
||||
OrderID: assetID,
|
||||
OrderNo: "UR46-BATCH",
|
||||
PackageID: assetID,
|
||||
UsageType: constants.AssetTypeIotCard,
|
||||
IotCardID: assetID,
|
||||
DataLimitMB: 1,
|
||||
Status: constants.PackageUsageStatusActive,
|
||||
Priority: 1,
|
||||
ActivatedAt: &now,
|
||||
ExpiresAt: &expiresAt,
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
||||
DurationDaysSnapshot: 30,
|
||||
DurationMonthsSnapshot: 0,
|
||||
}
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建批量套餐使用记录失败:%v", err)
|
||||
}
|
||||
}
|
||||
query := NewQuery(tx)
|
||||
query.now = func() time.Time { return now }
|
||||
results, err := query.ResolveBatch(context.Background(), constants.AssetTypeIotCard, assetIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("批量查询失败:%v", err)
|
||||
}
|
||||
if len(results) != len(assetIDs) {
|
||||
t.Fatalf("批量结果数量错误:want=%d got=%d", len(assetIDs), len(results))
|
||||
}
|
||||
for _, assetID := range assetIDs {
|
||||
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
||||
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
||||
return gorm.Model{ID: id, CreatedAt: createdAt}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
Reference in New Issue
Block a user