From 34f91f129fe4ca271e592cbd8ec0614b8010f07f Mon Sep 17 00:00:00 2001 From: break Date: Tue, 22 Sep 2026 16:36:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8A=A0=E6=B2=B9=E5=8C=85?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=9C=89=E6=95=88=E6=9C=9F=E5=92=8C=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../polling/package_activation_handler.go | 81 ++++++++++++------- internal/service/order/service.go | 20 ++--- internal/service/package/lifecycle_audit.go | 12 +++ internal/service/package/utils.go | 10 +++ 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index 95e29f62..b7fdfed3 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -123,49 +123,43 @@ func (h *PackageActivationHandler) syncTrafficBeforeExpiry(ctx context.Context, } } -// HandlePackageActivationCheck 任务 19.2-19.5: 处理套餐激活检查 -// 每 10 秒调度一次,检查过期主套餐并激活下一个待生效主套餐 +// HandlePackageActivationCheck 任务 19.2-19.6: 处理套餐激活检查 +// 每 10 秒调度一次,检查主套餐和拥有独立有效期的加油包。 func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error { startTime := time.Now() - // 任务 19.2: 查询已过期的主套餐(status IN (1,2) AND expires_at <= NOW) + // 任务 19.2-19.5:处理过期主套餐及其关联加油包。 expiredPackages, err := h.findExpiredMainPackages(ctx) if err != nil { h.logger.Error("查询过期主套餐失败", zap.Error(err)) return err } - - if len(expiredPackages) > 0 { - h.logger.Info("发现过期主套餐", - zap.Int("count", len(expiredPackages)), - zap.Duration("check_duration", time.Since(startTime))) - - // 处理每个过期的主套餐 - for _, pkg := range expiredPackages { - if err := h.processExpiredPackage(ctx, pkg); err != nil { - h.logger.Error("处理过期套餐失败", - zap.Uint("package_usage_id", pkg.ID), - zap.Error(err)) - // 继续处理下一个,不中断 - continue - } + for _, pkg := range expiredPackages { + if err := h.processExpiredPackage(ctx, pkg); err != nil { + h.logger.Error("处理过期套餐失败", zap.Uint("package_usage_id", pkg.ID), zap.Error(err)) } - - h.logger.Info("套餐激活检查完成", - zap.Int("processed", len(expiredPackages)), - zap.Duration("total_duration", time.Since(startTime))) } - // 任务 6: 孤儿套餐扫描 + // 任务 19.6:独立有效期加油包单独到期,不等待主套餐到期。 + expiredAddons, err := h.findExpiredIndependentAddons(ctx) + if err != nil { + h.logger.Error("查询独立有效期加油包失败", zap.Error(err)) + return err + } + for _, addon := range expiredAddons { + if err := h.processExpiredIndependentAddon(ctx, addon); err != nil { + h.logger.Error("处理独立有效期加油包失败", zap.Uint("package_usage_id", addon.ID), zap.Error(err)) + } + } + + // 孤儿套餐扫描仍照常执行。 orphanCount, err := h.findAndActivateOrphanPackages(ctx) if err != nil { h.logger.Error("孤儿套餐扫描失败", zap.Error(err)) - // 不中断主流程 } else if orphanCount > 0 { - h.logger.Info("孤儿套餐扫描完成", - zap.Int("orphan_count", orphanCount)) + h.logger.Info("孤儿套餐扫描完成", zap.Int("orphan_count", orphanCount)) } - + h.logger.Debug("套餐激活检查完成", zap.Int("expired_main_count", len(expiredPackages)), zap.Int("expired_addon_count", len(expiredAddons)), zap.Duration("total_duration", time.Since(startTime))) return nil } @@ -252,6 +246,39 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context) return packages, err } +// findExpiredIndependentAddons 查询拥有独立有效期且已到期的加油包。 +func (h *PackageActivationHandler) findExpiredIndependentAddons(ctx context.Context) ([]*model.PackageUsage, error) { + var addons []*model.PackageUsage + err := h.db.WithContext(ctx). + Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Where("master_usage_id IS NOT NULL AND has_independent_expiry = ?", true). + Where("expires_at IS NOT NULL AND expires_at <= ?", time.Now()). + Limit(1000). + Find(&addons).Error + return addons, err +} + +// processExpiredIndependentAddon 将独立有效期加油包标记为已过期,不影响主套餐和其他加油包。 +func (h *PackageActivationHandler) processExpiredIndependentAddon(ctx context.Context, addon *model.PackageUsage) error { + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(addon). + Where("status IN ? AND has_independent_expiry = ? AND expires_at IS NOT NULL AND expires_at <= ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, true, time.Now()). + Update("status", constants.PackageUsageStatusExpired) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + if h.activationService == nil { + return errors.New(errors.CodeInternalError, "套餐激活服务未注入") + } + return h.activationService.AppendAddonExpirationAudit(ctx, tx, addon) + }) +} + +// findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活 + // processExpiredPackage 处理单个过期套餐 // 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后投递下一套餐; // 仅明确没有后续套餐时才触发停机检查,避免与异步激活任务竞态。 diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 2a9fa684..9b3a1e01 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2690,15 +2690,15 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * status := constants.PackageUsageStatusActive activatedAt := now - // 计算过期时间(根据 has_independent_expiry) - var expiresAt time.Time - // 注意:has_independent_expiry 字段在 Package 模型中,暂时使用默认行为 - // 默认加油包跟随主套餐过期 - if mainPackage.ExpiresAt != nil { - expiresAt = *mainPackage.ExpiresAt - } else { - expiresAt = now - } + // 加油包按自身有效期计算;仅当主套餐更早到期时截断到主套餐到期时间。 + expiresAt, hasIndependentExpiry := packagepkg.CalculateAddonExpiryTime( + activatedAt, + terms.CalendarType, + terms.DurationMonths, + terms.DurationDays, + mainPackage.ExpiresAt, + ) + nextResetAt := packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) addonRetailAmount := order.TotalAmount @@ -2719,8 +2719,10 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * Status: status, Priority: priority, MasterUsageID: &mainPackage.ID, + HasIndependentExpiry: hasIndependentExpiry, ActivatedAt: &activatedAt, ExpiresAt: &expiresAt, + NextResetAt: nextResetAt, DataResetCycle: pkg.DataResetCycle, PaidAmount: &order.SellerCostPrice, RetailAmount: &addonRetailAmount, diff --git a/internal/service/package/lifecycle_audit.go b/internal/service/package/lifecycle_audit.go index bc6b1798..8fb8ee11 100644 --- a/internal/service/package/lifecycle_audit.go +++ b/internal/service/package/lifecycle_audit.go @@ -62,6 +62,18 @@ func (s *ActivationService) AppendExpirationAudit(ctx context.Context, tx *gorm. return s.appendExpirationAudit(ctx, tx, &mainAfter, packageUsageStateData(main), addonChanges) } +// AppendAddonExpirationAudit 记录独立有效期加油包到期事实。 +func (s *ActivationService) AppendAddonExpirationAudit(ctx context.Context, tx *gorm.DB, addon *model.PackageUsage) error { + if addon == nil { + return errors.New(errors.CodeInvalidParam, "加油包权益资源不完整") + } + after := *addon + after.Status = constants.PackageUsageStatusExpired + return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageExpired, "加油包权益到期", []packageUsageAuditChange{{ + Usage: &after, BeforeData: packageUsageStateData(addon), AfterData: packageUsageStateData(&after), + }}, nil, map[string]any{"addon_usage_id": addon.ID}) +} + // RecordUsageFailure 在权益已定位且业务事务回滚后记录失败事实。 func (s *ActivationService) RecordUsageFailure(ctx context.Context, actionCode, summary string, usage *model.PackageUsage, businessErr error) { recordPackageUsageFailure(ctx, s.db, s.auditWriter, actionCode, summary, usage, businessErr) diff --git a/internal/service/package/utils.go b/internal/service/package/utils.go index d299349a..7833f830 100644 --- a/internal/service/package/utils.go +++ b/internal/service/package/utils.go @@ -39,6 +39,16 @@ func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMon return expiryDate } +// CalculateAddonExpiryTime 计算加油包到期时间,并在主套餐更早到期时截断。 +// 返回值中的 independent 表示加油包自身有效期未晚于主套餐,因而按自身规则到期。 +func CalculateAddonExpiryTime(activatedAt time.Time, calendarType string, durationMonths, durationDays int, mainExpiresAt *time.Time) (expiresAt time.Time, independent bool) { + addonExpiresAt := CalculateExpiryTime(calendarType, activatedAt, durationMonths, durationDays) + if mainExpiresAt != nil && mainExpiresAt.Before(addonExpiresAt) { + return *mainExpiresAt, false + } + return addonExpiresAt, true +} + // CalculateValidityDays 计算套餐展示有效天数。 // 按天套餐优先返回折算后的总天数;自然月套餐按 baseTime 推算到目标月月末。 func CalculateValidityDays(calendarType string, baseTime time.Time, durationMonths, durationDays int) int {