Files
邹进 4967f4df43
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 卡实名时间以上游 archiveTime 为准并记录实名时间历史,新增二次实名批次
2026-11-08 19:12:46 +09:00

381 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}