// 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) }