package packagesilence import ( "context" "time" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // Activator 是沉默期结束后按现有逻辑激活(或继续排队)套餐的接缝,由旧激活服务实现。 type Activator interface { ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error } // Resolver 是沉默期统一判定用例 ResolveUsageSilence(技术方案 §5.2)。 // 所有入口(实名、绑卡、排队、指定激活、孤儿扫描、延期、调整审批、扫描)只调用它,不自写判定。 type Resolver struct { db *gorm.DB sw *Switch audit *audit.Writer activator Activator logger *zap.Logger now func() time.Time } // NewResolver 创建沉默期判定用例。 func NewResolver(db *gorm.DB, sw *Switch, writer *audit.Writer, logger *zap.Logger) *Resolver { if logger == nil { logger = zap.NewNop() } return &Resolver{db: db, sw: sw, audit: writer, logger: logger, now: time.Now} } // SetActivator 注入激活接缝(避免与旧激活服务循环构造)。 func (r *Resolver) SetActivator(activator Activator) { r.activator = activator } // Switch 返回总开关读取器。 func (r *Resolver) Switch() *Switch { return r.sw } // ResolveResult 是一次判定的结果。 type ResolveResult struct { Decision packagedomain.SilenceDecision Changed bool // 本次是否写入了状态变化 Status string // 判定后的 silence_status(空表示不适用) } // Blocked 表示套餐沉默期已过期,不能激活。 func (r ResolveResult) Blocked() bool { return r.Status == constants.SilenceStatusExpired } // ResolveOptions 控制判定行为。 type ResolveOptions struct { Trigger string // AllowExpire 为 false 时只记录"期内实名结束",不判过期(总开关关闭时)。 AllowExpire bool } // ResolveInTx 在调用方事务内对一笔套餐记录执行判定:加锁、读实名事实、条件更新、写流水与审计。 func (r *Resolver) ResolveInTx(ctx context.Context, tx *gorm.DB, usageID uint, options ResolveOptions) (ResolveResult, error) { var usage model.PackageUsage if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", usageID).First(&usage).Error; err != nil { if err == gorm.ErrRecordNotFound { return ResolveResult{Decision: packagedomain.SilenceDecisionNoop}, nil } return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败") } return r.resolveLocked(ctx, tx, &usage, options) } // Resolve 用独立事务执行判定;结束沉默期时提交后按现有逻辑激活或排队。 func (r *Resolver) Resolve(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) { var result ResolveResult err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var resolveErr error result, resolveErr = r.ResolveInTx(ctx, tx, usageID, options) return resolveErr }) if err != nil { return result, err } r.activateIfEnded(ctx, usageID, result) return result, nil } // ResolveAndActivate 判定后,若沉默期已结束(期内实名)则调用激活接缝(排队中的保持排队)。 // 用于扫描、延期生效、调整审批结束等"判定后立即激活"的入口。 func (r *Resolver) ResolveAndActivate(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) { result, err := r.Resolve(ctx, usageID, options) if err != nil { return result, err } if result.Status == constants.SilenceStatusEnded && !result.Changed { r.activate(ctx, usageID) } return result, nil } func (r *Resolver) activateIfEnded(ctx context.Context, usageID uint, result ResolveResult) { if result.Changed && result.Status == constants.SilenceStatusEnded { r.activate(ctx, usageID) } } func (r *Resolver) activate(ctx context.Context, usageID uint) { if r.activator == nil { return } if err := r.activator.ActivateSpecificPackage(ctx, usageID); err != nil { r.logger.Warn("沉默期结束后激活套餐失败,等待后续实名、排队或孤儿扫描接续", zap.Uint("usage_id", usageID), zap.Error(err)) } } func (r *Resolver) resolveLocked(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, options ResolveOptions) (ResolveResult, error) { status := "" if usage.SilenceStatus != nil { status = *usage.SilenceStatus } if status != constants.SilenceStatusWaiting || usage.Status != constants.PackageUsageStatusPending { return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: status}, nil } now := r.now() facts, err := loadRealnameFacts(ctx, tx, usage) if err != nil { return ResolveResult{}, err } judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{ Status: status, DeadlineAt: usage.SilenceDeadlineAt, Realnamed: facts.realnamed, RealnameAt: facts.realnameAt, AdjustPending: usage.SilenceAdjustPending, Now: now, }) switch judgement.Decision { case packagedomain.SilenceDecisionEnd: return r.applyEnd(ctx, tx, usage, facts, judgement.BackfillRealnameAt, options.Trigger, now) case packagedomain.SilenceDecisionExpire: if !options.AllowExpire { return ResolveResult{Decision: packagedomain.SilenceDecisionWait, Status: status}, nil } return r.applyExpire(ctx, tx, usage, options.Trigger, now) default: return ResolveResult{Decision: judgement.Decision, Status: status}, nil } } func (r *Resolver) applyEnd(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, facts realnameFacts, backfill bool, trigger string, now time.Time) (ResolveResult, error) { result := tx.WithContext(ctx).Model(&model.PackageUsage{}). Where("id = ? AND silence_status = ? AND status = ?", usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending). Updates(map[string]any{"silence_status": constants.SilenceStatusEnded, "updated_at": now}) if result.Error != nil { return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "结束套餐沉默期失败") } if result.RowsAffected == 0 { return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil } before := copyStringPtr(usage.SilenceStatus) usage.SilenceStatus = stringPtr(constants.SilenceStatusEnded) realnameAt := facts.realnameAt if backfill || len(facts.backfillCardIDs) > 0 { backfilled, err := backfillRealnameAt(ctx, tx, facts, now) if err != nil { return ResolveResult{}, err } if backfilled > 0 { if err := writeRecord(ctx, tx, r.audit, recordInput{ Usage: usage, EventType: constants.SilenceRecordEventRealnameBackfill, Trigger: trigger, BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: &now, Reason: "资产已实名但实名时间为空,视为期内实名并补写实名时间", Extra: map[string]any{"backfilled_card_count": backfilled}, }); err != nil { return ResolveResult{}, err } } if realnameAt == nil { realnameAt = &now } } if err := writeRecord(ctx, tx, r.audit, recordInput{ Usage: usage, EventType: constants.SilenceRecordEventEnded, Trigger: trigger, BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: realnameAt, Reason: "截止前已实名,沉默期结束", AuditAction: constants.AuditActionUsageSilenceEnded, AuditSummary: "套餐沉默期结束(期内实名)", }); err != nil { return ResolveResult{}, err } return ResolveResult{Decision: packagedomain.SilenceDecisionEnd, Changed: true, Status: constants.SilenceStatusEnded}, nil } func (r *Resolver) applyExpire(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, trigger string, now time.Time) (ResolveResult, error) { result := tx.WithContext(ctx).Model(&model.PackageUsage{}). Where("id = ? AND silence_status = ? AND status = ? AND silence_adjust_pending = ? AND silence_deadline_at < ?", usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending, false, now). Updates(map[string]any{"silence_status": constants.SilenceStatusExpired, "silence_expired_at": now, "updated_at": now}) if result.Error != nil { return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记套餐沉默期过期失败") } if result.RowsAffected == 0 { return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil } before := copyStringPtr(usage.SilenceStatus) usage.SilenceStatus = stringPtr(constants.SilenceStatusExpired) usage.SilenceExpiredAt = &now if err := writeRecord(ctx, tx, r.audit, recordInput{ Usage: usage, EventType: constants.SilenceRecordEventExpired, Trigger: trigger, BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, Reason: "沉默期截止仍未实名,套餐失效(沉默期已过期)", AuditAction: constants.AuditActionUsageSilenceExpired, AuditSummary: "套餐沉默期过期", }); err != nil { return ResolveResult{}, err } return ResolveResult{Decision: packagedomain.SilenceDecisionExpire, Changed: true, Status: constants.SilenceStatusExpired}, nil } // realnameFacts 是判定所需的资产实名事实(技术方案 §5.1)。 type realnameFacts struct { realnamed bool realnameAt *time.Time backfillCardIDs []uint // 已实名但实名时间为空、需要补写的卡 } type boundCardRow struct { CardID uint `gorm:"column:card_id"` RealNameStatus int `gorm:"column:real_name_status"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` BindTime *time.Time `gorm:"column:bind_time"` BindCreatedAt time.Time `gorm:"column:bind_created_at"` } // loadRealnameFacts 读取实名事实: // - 卡:first_realname_at;已实名但时间为空时视为期内实名并补写。 // - 设备:所有已绑定且已实名卡中 max(卡实名时间, 绑定时间) 的最小值,使截止后才绑定的已实名卡不能倒推为期内实名; // 卡实名时间为空时按绑定时间计并补写卡的实名时间。 func loadRealnameFacts(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (realnameFacts, error) { if usage.DeviceID > 0 { var rows []boundCardRow err := tx.WithContext(ctx).Table("tb_device_sim_binding AS b"). Select("c.id AS card_id, c.real_name_status, c.first_realname_at, b.bind_time, b.created_at AS bind_created_at"). Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL"). Where("b.device_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL", usage.DeviceID, constants.BindStatusBound). Where("c.real_name_status = ?", constants.RealNameStatusVerified). Scan(&rows).Error if err != nil { return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡实名事实失败") } facts := realnameFacts{} for _, row := range rows { facts.realnamed = true bindAt := row.BindCreatedAt if row.BindTime != nil { bindAt = *row.BindTime } effective := bindAt if row.FirstRealnameAt == nil { facts.backfillCardIDs = append(facts.backfillCardIDs, row.CardID) } else if row.FirstRealnameAt.After(bindAt) { effective = *row.FirstRealnameAt } if facts.realnameAt == nil || effective.Before(*facts.realnameAt) { value := effective facts.realnameAt = &value } } return facts, nil } if usage.IotCardID == 0 { return realnameFacts{}, nil } var card model.IotCard if err := tx.WithContext(ctx).Select("id", "real_name_status", "first_realname_at"). Where("id = ?", usage.IotCardID).First(&card).Error; err != nil { if err == gorm.ErrRecordNotFound { return realnameFacts{}, nil } return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名事实失败") } facts := realnameFacts{realnamed: card.RealNameStatus == constants.RealNameStatusVerified, realnameAt: card.FirstRealnameAt} if facts.realnamed && card.FirstRealnameAt == nil { facts.backfillCardIDs = []uint{card.ID} } return facts, nil } // backfillRealnameAt 为已实名但实名时间为空的卡补写 first_realname_at(只写为空的,返回补写数量)。 func backfillRealnameAt(ctx context.Context, tx *gorm.DB, facts realnameFacts, now time.Time) (int64, error) { if len(facts.backfillCardIDs) == 0 { return 0, nil } result := tx.WithContext(ctx).Model(&model.IotCard{}). Where("id IN ? AND first_realname_at IS NULL AND real_name_status = ?", facts.backfillCardIDs, constants.RealNameStatusVerified). Update("first_realname_at", now) if result.Error != nil { return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "补写卡实名时间失败") } return result.RowsAffected, nil } // AllowActivationInTx 是激活闸门(技术方案 §5.3):在激活事务内、实名判断前先执行沉默期判定。 // 总开关关闭时只记录"期内实名结束",始终放行(行为与现状一致);打开时沉默期已过期的套餐不能激活。 func (r *Resolver) AllowActivationInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (bool, error) { if r == nil || usage == nil || usage.SilenceStatus == nil { return true, nil } enabled := r.sw.Enabled(ctx) result, err := r.ResolveInTx(ctx, tx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerActivation, AllowExpire: enabled}) if err != nil { return false, err } if result.Status != "" { status := result.Status usage.SilenceStatus = &status } if !enabled { return true, nil } return !result.Blocked(), nil }