381 lines
16 KiB
Go
381 lines
16 KiB
Go
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
|
||
}
|
||
|
||
// cardRealnameAtExpr 是卡当前实名时间:取卡当前实名记录的实名时间(上游 archiveTime 优先,见 add-realname-time-history);
|
||
// 还没有当前实名记录的卡(上线迁移前后的空窗)回退到 first_realname_at。
|
||
const cardRealnameAtExpr = "CASE WHEN r.id IS NULL THEN c.first_realname_at ELSE r.realname_at END"
|
||
|
||
// 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 读取实名事实:
|
||
// - 卡:当前实名时间;已实名但时间为空时视为期内实名并补写。
|
||
// - 设备:所有已绑定且已实名卡中 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, "+cardRealnameAtExpr+" AS 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").
|
||
Joins("LEFT JOIN tb_iot_card_realname AS r ON r.card_id = c.id").
|
||
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 cards []boundCardRow
|
||
if err := tx.WithContext(ctx).Table("tb_iot_card AS c").
|
||
Select("c.id AS card_id, c.real_name_status, "+cardRealnameAtExpr+" AS first_realname_at").
|
||
Joins("LEFT JOIN tb_iot_card_realname AS r ON r.card_id = c.id").
|
||
Where("c.id = ?", usage.IotCardID).Limit(1).Scan(&cards).Error; err != nil {
|
||
return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名事实失败")
|
||
}
|
||
if len(cards) == 0 {
|
||
return realnameFacts{}, nil
|
||
}
|
||
card := cards[0]
|
||
facts := realnameFacts{realnamed: card.RealNameStatus == constants.RealNameStatusVerified, realnameAt: card.FirstRealnameAt}
|
||
if facts.realnamed && card.FirstRealnameAt == nil {
|
||
facts.backfillCardIDs = []uint{card.CardID}
|
||
}
|
||
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, "补写卡实名时间失败")
|
||
}
|
||
if err := backfillCurrentRealnameAt(ctx, tx, facts.backfillCardIDs, now); err != nil {
|
||
return 0, err
|
||
}
|
||
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
|
||
}
|
||
|
||
// backfillCurrentRealnameAt 为已实名但当前实名时间为空的卡写入当前实名记录(来源"首次读到",取判定时间)并记一条首次实名历史。
|
||
// 二次实名等待中的卡是未实名状态,不会进入这里。
|
||
func backfillCurrentRealnameAt(ctx context.Context, tx *gorm.DB, cardIDs []uint, now time.Time) error {
|
||
var rows []struct {
|
||
CardID uint `gorm:"column:card_id"`
|
||
ICCID string `gorm:"column:iccid"`
|
||
RecordID *uint `gorm:"column:record_id"`
|
||
Realname *time.Time `gorm:"column:realname_at"`
|
||
}
|
||
if err := tx.WithContext(ctx).Table("tb_iot_card AS c").
|
||
Select("c.id AS card_id, c.iccid, r.id AS record_id, r.realname_at").
|
||
Joins("LEFT JOIN tb_iot_card_realname AS r ON r.card_id = c.id").
|
||
Where("c.id IN ? AND c.real_name_status = ?", cardIDs, constants.RealNameStatusVerified).
|
||
Scan(&rows).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前实名记录失败")
|
||
}
|
||
for _, row := range rows {
|
||
if row.Realname != nil {
|
||
continue
|
||
}
|
||
updates := map[string]any{"realname_at": now, "realname_at_source": constants.RealnameAtSourceFirstObserved, "updated_at": now}
|
||
if row.RecordID == nil {
|
||
record := model.IotCardRealname{CardID: row.CardID, RealnameAt: &now, RealnameAtSource: constants.RealnameAtSourceFirstObserved,
|
||
FirstObservedAt: &now, ResetStatus: constants.RealnameResetStatusNone, CreatedAt: now, UpdatedAt: now}
|
||
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "补写卡当前实名记录失败")
|
||
}
|
||
} else if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ? AND realname_at IS NULL", *row.RecordID).
|
||
Updates(updates).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "补写卡当前实名时间失败")
|
||
}
|
||
history := model.IotCardRealnameHistory{CardID: row.CardID, ICCID: row.ICCID, AfterAt: &now,
|
||
AfterSource: constants.RealnameAtSourceFirstObserved, ChangeType: constants.RealnameChangeFirst,
|
||
ObservationSource: "package_silence", ObservedAt: now, CreatedAt: now}
|
||
if err := tx.WithContext(ctx).Create(&history).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名时间历史失败")
|
||
}
|
||
}
|
||
return nil
|
||
}
|