diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index 76123a7c..a14b7cbd 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -20,10 +20,11 @@ import ( // RealnameChangedEvent 是卡实名状态变化的可靠领域事实。 type RealnameChangedEvent struct { - EventID string `json:"event_id"` - CardID uint `json:"card_id"` - BeforeStatus int `json:"before_status"` - AfterStatus int `json:"after_status"` + EventID string `json:"event_id"` + CardID uint `json:"card_id"` + BeforeStatus int `json:"before_status"` + AfterStatus int `json:"after_status"` + // FirstVerified 表示需要触发实名激活:首次实名,或二次实名完成(此时 first_realname_at 不变)。 FirstVerified bool `json:"first_verified"` ObservedAt time.Time `json:"observed_at"` Source string `json:"source"` @@ -175,6 +176,27 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R stale = true return nil } + realnameRecord, realnameSnapshot, loadErr := loadRealnameTime(ctx, tx, card.ID) + if loadErr != nil { + return loadErr + } + resetGate := domain.EvaluateRealnameResetGate(realnameSnapshot, observation) + if resetGate == domain.RealnameResetGateBlocked { + // 等待二次实名期间读到的仍是旧实名(上游实名时间不晚于清除前时间,或没有上游时间):不改实名状态,只记检查时间。 + decision = domain.RealnameDecision{BeforeStatus: card.RealNameStatus, AfterStatus: card.RealNameStatus, + ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt} + checked := map[string]any{"last_real_name_check_at": observation.Metadata.ObservedAt} + if observation.Metadata.Source != constants.CardObservationSourceManualOverride { + checked["last_sync_time"] = observation.Metadata.ObservedAt + } + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(checked).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名检查时间失败") + } + s.warnLogger().Info("等待二次实名的卡读到旧实名,不覆盖", + zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID), + zap.Timep("upstream_realname_at", observation.UpstreamRealnameAt), zap.Timep("baseline_at", realnameSnapshot.ResetBaselineAt)) + return nil + } nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{ CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt, ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt, @@ -207,11 +229,16 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新") } + if observation.Verified && decision.AfterStatus == constants.RealNameStatusVerified { + if err := applyRealnameTime(ctx, tx, &card, realnameRecord, observation, resetGate); err != nil { + return err + } + } if decision.StatusChanged { eventID := realnameChangedEventID(card.ID, observation.Metadata.ObservationID) if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{ EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus, - FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt, + FirstVerified: decision.FirstVerified || resetGate == domain.RealnameResetGateCompleted, ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source, Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID, }); err != nil { diff --git a/internal/application/cardobservation/realname_reset.go b/internal/application/cardobservation/realname_reset.go new file mode 100644 index 00000000..dc4bb329 --- /dev/null +++ b/internal/application/cardobservation/realname_reset.go @@ -0,0 +1,177 @@ +package cardobservation + +import ( + "context" + "strconv" + "time" + + domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// RealnameResetCommand 描述对一张卡的二次实名清除或撤销。 +type RealnameResetCommand struct { + CardID uint + BatchID uint + OperatorID uint +} + +// 二次实名不可执行的原因(业务可预期,按行记录,不当作系统错误)。 +var ( + ErrRealnameResetNotVerified = errors.New(errors.CodeInvalidStatus, "卡未实名,无需二次实名") + ErrRealnameResetNoUpstream = errors.New(errors.CodeInvalidStatus, "上游未返回实名时间,不支持二次实名") + ErrRealnameResetAlreadyReset = errors.New(errors.CodeInvalidStatus, "卡已在等待二次实名") + ErrRealnameResetNotPending = errors.New(errors.CodeInvalidStatus, "卡不在等待二次实名,不能撤销") +) + +// ResetRealname 二次实名清除:卡置为未实名、当前实名时间置空,记下清除前的上游实名时间作为新旧判定基准。 +// 只支持上游返回过 archiveTime 的卡;已激活套餐不回退,first_realname_at 不修改。 +// 状态变化按现有流程写实名变化事件与审计,下游停复机评估与普通未实名卡一致。 +func (s *Service) ResetRealname(ctx context.Context, command RealnameResetCommand) error { + return s.changeRealnameForReset(ctx, command, false) +} + +// RevertRealnameReset 撤销等待中的二次实名:卡恢复已实名,当前实名时间恢复为清除前时间。 +func (s *Service) RevertRealnameReset(ctx context.Context, command RealnameResetCommand) error { + return s.changeRealnameForReset(ctx, command, true) +} + +func (s *Service) changeRealnameForReset(ctx context.Context, command RealnameResetCommand, revert bool) error { + if s == nil || s.db == nil || s.eventWriter == nil || s.auditWriter == nil { + return errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置") + } + if command.CardID == 0 { + return errors.New(errors.CodeInvalidParam, "卡 ID 无效") + } + now := time.Now() + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var card model.IotCard + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", command.CardID).First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "IoT卡不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败") + } + record, snapshot, err := loadRealnameTime(ctx, tx, card.ID) + if err != nil { + return err + } + if revert { + return s.revertResetInTx(ctx, tx, &card, record, snapshot, command, now) + } + return s.resetInTx(ctx, tx, &card, record, snapshot, command, now) + }) + if err != nil { + return err + } + if s.cache != nil { + s.cache.Invalidate(ctx, command.CardID) + } + return nil +} + +func (s *Service) resetInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname, + snapshot domain.RealnameTimeSnapshot, command RealnameResetCommand, now time.Time) error { + if card.RealNameStatus != constants.RealNameStatusVerified { + return ErrRealnameResetNotVerified + } + if record == nil || record.UpstreamRealnameAt == nil { + return ErrRealnameResetNoUpstream + } + if snapshot.ResetStatus == constants.RealnameResetStatusPending { + return ErrRealnameResetAlreadyReset + } + if err := s.updateCardRealnameStatusInTx(ctx, tx, card, constants.RealNameStatusNotVerified, now, "reset", command.BatchID); err != nil { + return err + } + batchID := command.BatchID + if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(map[string]any{ + "realname_at": nil, "reset_status": constants.RealnameResetStatusPending, "reset_at": now, + "reset_baseline_at": *record.UpstreamRealnameAt, "reset_batch_id": batchID, "updated_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入二次实名等待状态失败") + } + return writeResetHistory(ctx, tx, card, record.RealnameAt, nil, "", constants.RealnameChangeReset, command, now) +} + +func (s *Service) revertResetInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname, + snapshot domain.RealnameTimeSnapshot, command RealnameResetCommand, now time.Time) error { + if record == nil || snapshot.ResetStatus != constants.RealnameResetStatusPending { + return ErrRealnameResetNotPending + } + if command.BatchID != 0 && (record.ResetBatchID == nil || *record.ResetBatchID != command.BatchID) { + return ErrRealnameResetNotPending + } + if card.RealNameStatus != constants.RealNameStatusVerified { + if err := s.updateCardRealnameStatusInTx(ctx, tx, card, constants.RealNameStatusVerified, now, "revert", command.BatchID); err != nil { + return err + } + } + restored := record.ResetBaselineAt + if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(map[string]any{ + "realname_at": restored, "realname_at_source": constants.RealnameAtSourceUpstream, + "reset_status": constants.RealnameResetStatusNone, "reset_at": nil, "reset_baseline_at": nil, "reset_batch_id": nil, + "updated_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "撤销二次实名等待状态失败") + } + return writeResetHistory(ctx, tx, card, nil, restored, constants.RealnameAtSourceUpstream, constants.RealnameChangeResetReverted, command, now) +} + +// updateCardRealnameStatusInTx 按实名观测的同一口径更新卡实名状态,并写实名变化事件与人工审计。 +func (s *Service) updateCardRealnameStatusInTx(ctx context.Context, tx *gorm.DB, card *model.IotCard, after int, now time.Time, kind string, batchID uint) error { + verified := after == constants.RealNameStatusVerified + result := tx.WithContext(ctx).Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(map[string]any{ + "real_name_status": after, + "realname_reversal_count": 0, + "realname_reversal_started_at": nil, + "activation_status": gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, verified), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名状态失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新") + } + observationID := "realname-" + kind + ":" + strconv.FormatUint(uint64(batchID), 10) + ":" + strconv.FormatInt(now.UnixNano(), 10) + eventID := outboxid.Stable("card-realname:", strconv.FormatUint(uint64(card.ID), 10)+":"+observationID+":changed") + if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{ + EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: after, + ObservedAt: now, Source: constants.CardObservationSourceManualOverride, Scene: "realname_" + kind, + CorrelationID: observationID, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败") + } + summary := "二次实名清除:卡实名状态置为未实名" + if verified { + summary = "二次实名撤销:卡恢复已实名" + } + return s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ + ActionCode: constants.AuditActionIotCardRealnameStatusUpdated, Summary: summary, Card: card, + BeforeData: map[string]any{"real_name_status": card.RealNameStatus}, + AfterData: map[string]any{"real_name_status": after, "realname_reset_batch_id": batchID}, + }) +} + +func writeResetHistory(ctx context.Context, tx *gorm.DB, card *model.IotCard, before, after *time.Time, afterSource, changeType string, + command RealnameResetCommand, now time.Time) error { + var batchID *uint + if command.BatchID != 0 { + value := command.BatchID + batchID = &value + } + history := model.IotCardRealnameHistory{ + CardID: card.ID, ICCID: card.ICCID, BeforeAt: before, AfterAt: after, AfterSource: afterSource, + ChangeType: changeType, ObservationSource: constants.CardObservationSourceManualOverride, + ResetBatchID: batchID, OperatorID: command.OperatorID, ObservedAt: now, CreatedAt: now, + } + if err := tx.WithContext(ctx).Create(&history).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名时间历史失败") + } + return nil +} diff --git a/internal/application/cardobservation/realname_time.go b/internal/application/cardobservation/realname_time.go new file mode 100644 index 00000000..83c8b1f3 --- /dev/null +++ b/internal/application/cardobservation/realname_time.go @@ -0,0 +1,96 @@ +package cardobservation + +import ( + "context" + "time" + + domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +// loadRealnameTime 在调用方事务中读取卡当前实名记录;调用方已锁定卡行,同一张卡的写入因此串行。 +func loadRealnameTime(ctx context.Context, tx *gorm.DB, cardID uint) (*model.IotCardRealname, domain.RealnameTimeSnapshot, error) { + var record model.IotCardRealname + err := tx.WithContext(ctx).Where("card_id = ?", cardID).Take(&record).Error + if err == gorm.ErrRecordNotFound { + return nil, domain.RealnameTimeSnapshot{}, nil + } + if err != nil { + return nil, domain.RealnameTimeSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前实名记录失败") + } + return &record, realnameTimeSnapshot(&record), nil +} + +func realnameTimeSnapshot(record *model.IotCardRealname) domain.RealnameTimeSnapshot { + if record == nil { + return domain.RealnameTimeSnapshot{} + } + return domain.RealnameTimeSnapshot{ + Exists: true, RealnameAt: record.RealnameAt, RealnameAtSource: record.RealnameAtSource, + UpstreamRealnameAt: record.UpstreamRealnameAt, FirstObservedAt: record.FirstObservedAt, + ResetStatus: record.ResetStatus, ResetBaselineAt: record.ResetBaselineAt, + } +} + +// applyRealnameTime 在卡已被采纳为已实名后更新当前实名记录;只有实名时间变化才写历史。 +func applyRealnameTime(ctx context.Context, tx *gorm.DB, card *model.IotCard, record *model.IotCardRealname, + observation domain.RealnameObservation, gate domain.RealnameResetGate) error { + decision := domain.DecideRealnameTime(realnameTimeSnapshot(record), observation, gate) + if !decision.Changed { + return nil + } + now := time.Now() + var resetBatchID *uint + if record != nil { + resetBatchID = record.ResetBatchID + } + if record == nil { + created := model.IotCardRealname{ + CardID: card.ID, RealnameAt: decision.RealnameAt, RealnameAtSource: decision.Source, + UpstreamRealnameAt: decision.UpstreamRealnameAt, FirstObservedAt: decision.FirstObservedAt, + ResetStatus: constants.RealnameResetStatusNone, CreatedAt: now, UpdatedAt: now, + } + if err := tx.WithContext(ctx).Create(&created).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建卡当前实名记录失败") + } + } else { + updates := map[string]any{ + "realname_at": decision.RealnameAt, "realname_at_source": decision.Source, + "upstream_realname_at": decision.UpstreamRealnameAt, "first_observed_at": decision.FirstObservedAt, + "updated_at": now, + } + if decision.ClearReset { + updates["reset_status"] = constants.RealnameResetStatusNone + updates["reset_at"] = nil + updates["reset_baseline_at"] = nil + updates["reset_batch_id"] = nil + } + if err := tx.WithContext(ctx).Model(&model.IotCardRealname{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡当前实名记录失败") + } + } + if decision.ClearReset && resetBatchID != nil { + if err := tx.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", *resetBatchID). + Updates(map[string]any{"completed_count": gorm.Expr("completed_count + 1"), "updated_at": now}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次完成数失败") + } + } + if !decision.WriteHistory { + return nil + } + history := model.IotCardRealnameHistory{ + CardID: card.ID, ICCID: card.ICCID, BeforeAt: decision.BeforeAt, AfterAt: decision.RealnameAt, + AfterSource: decision.Source, ChangeType: decision.ChangeType, ObservationSource: observation.Metadata.Source, + ObservedAt: observation.Metadata.ObservedAt, CreatedAt: now, + } + if decision.ClearReset { + history.ResetBatchID = resetBatchID + } + if err := tx.WithContext(ctx).Create(&history).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名时间历史失败") + } + return nil +} diff --git a/internal/application/packagesilence/resolve.go b/internal/application/packagesilence/resolve.go index 3f8f70c1..2e311eca 100644 --- a/internal/application/packagesilence/resolve.go +++ b/internal/application/packagesilence/resolve.go @@ -223,6 +223,10 @@ func (r *Resolver) applyExpire(ctx context.Context, tx *gorm.DB, usage *model.Pa 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 @@ -239,15 +243,16 @@ type boundCardRow struct { } // 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"). + 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 @@ -277,22 +282,25 @@ func loadRealnameFacts(ctx context.Context, tx *gorm.DB, usage *model.PackageUsa 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 - } + 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.ID} + facts.backfillCardIDs = []uint{card.CardID} } return facts, nil } -// backfillRealnameAt 为已实名但实名时间为空的卡补写 first_realname_at(只写为空的,返回补写数量)。 +// 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 @@ -303,6 +311,9 @@ func backfillRealnameAt(ctx context.Context, tx *gorm.DB, facts realnameFacts, n 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 } @@ -326,3 +337,44 @@ func (r *Resolver) AllowActivationInTx(ctx context.Context, tx *gorm.DB, usage * } 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 +} diff --git a/internal/application/realnamereset/service.go b/internal/application/realnamereset/service.go new file mode 100644 index 00000000..f7c49b2a --- /dev/null +++ b/internal/application/realnamereset/service.go @@ -0,0 +1,259 @@ +// Package realnamereset 提供二次实名批次的导入与撤销用例。 +// 二次实名:清除卡的实名状态与当前实名时间,之后只有上游 archiveTime 晚于清除前时间才算重新实名(见 add-realname-time-history)。 +package realnamereset + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "strings" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + cardobservationapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// CardResetter 执行单卡二次实名清除与撤销。 +type CardResetter interface { + ResetRealname(ctx context.Context, command cardobservationapp.RealnameResetCommand) error + RevertRealnameReset(ctx context.Context, command cardobservationapp.RealnameResetCommand) error +} + +// PermissionChecker 校验后台账号权限点。 +type PermissionChecker interface { + CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error) +} + +// Service 是二次实名批次用例。 +type Service struct { + db *gorm.DB + resetter CardResetter + perms PermissionChecker + now func() time.Time +} + +// NewService 创建二次实名批次用例。 +func NewService(db *gorm.DB, resetter CardResetter, perms PermissionChecker) *Service { + return &Service{db: db, resetter: resetter, perms: perms, now: time.Now} +} + +// resetTarget 是一行资产号展开出的一张卡。 +type resetTarget struct { + CardID uint `gorm:"column:id"` + ICCID string `gorm:"column:iccid"` +} + +// Import 导入二次实名名单:逐行展开为卡并逐卡清除,不合格的行记录原因跳过,不影响其他行。 +func (s *Service) Import(ctx context.Context, req dto.RealnameResetImportRequest) (*dto.RealnameResetBatchResponse, error) { + operatorID, err := s.authorize(ctx) + if err != nil { + return nil, err + } + identifiers := normalizeIdentifiers(req.Identifiers) + if len(identifiers) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "请导入至少一个资产号") + } + if len(identifiers) > constants.RealnameResetBatchMaxRows { + return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("单批最多导入 %d 个资产号", constants.RealnameResetBatchMaxRows)) + } + now := s.now() + batch := &model.IotCardRealnameResetBatch{ + BatchNo: generateBatchNo(now), ImportFileKey: strings.TrimSpace(req.ImportFileKey), Reason: strings.TrimSpace(req.Reason), + Items: []byte("[]"), OperatorID: operatorID, OperatorName: middleware.GetUsernameFromContext(ctx), CreatedAt: now, UpdatedAt: now, + } + if err := s.db.WithContext(ctx).Create(batch).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建二次实名批次失败") + } + items := make([]dto.RealnameResetItem, 0, len(identifiers)) + seen := make(map[uint]bool) + succeeded, skipped := 0, 0 + for index, identifier := range identifiers { + rowNo := index + 1 + targets, resolveErr := s.resolveTargets(ctx, identifier) + if resolveErr != nil { + return nil, resolveErr + } + if len(targets) == 0 { + items = append(items, skippedItem(rowNo, identifier, resetTarget{}, "资产不存在或设备未绑定卡")) + skipped++ + continue + } + for _, target := range targets { + if seen[target.CardID] { + items = append(items, skippedItem(rowNo, identifier, target, "同一张卡在名单中重复")) + skipped++ + continue + } + seen[target.CardID] = true + resetErr := s.resetter.ResetRealname(ctx, cardobservationapp.RealnameResetCommand{CardID: target.CardID, BatchID: batch.ID, OperatorID: operatorID}) + switch { + case resetErr == nil: + items = append(items, dto.RealnameResetItem{RowNo: rowNo, Input: identifier, CardID: target.CardID, ICCID: target.ICCID, + Result: constants.RealnameResetItemPending}) + succeeded++ + case isExpectedResetError(resetErr): + items = append(items, skippedItem(rowNo, identifier, target, errorMessage(resetErr))) + skipped++ + default: + items = append(items, dto.RealnameResetItem{RowNo: rowNo, Input: identifier, CardID: target.CardID, ICCID: target.ICCID, + Result: constants.RealnameResetItemFailed, Reason: errorMessage(resetErr)}) + skipped++ + } + } + } + encoded, err := sonic.Marshal(items) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码二次实名批次结果失败") + } + batch.Items, batch.TotalCount, batch.SucceededCount, batch.SkippedCount = encoded, len(items), succeeded, skipped + if err := s.db.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{ + "items": encoded, "total_count": len(items), "succeeded_count": succeeded, "skipped_count": skipped, "updated_at": s.now(), + }).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次结果失败") + } + return &dto.RealnameResetBatchResponse{ + ID: batch.ID, BatchNo: batch.BatchNo, Reason: batch.Reason, ImportFileKey: batch.ImportFileKey, + TotalCount: len(items), SucceededCount: succeeded, SkippedCount: skipped, PendingCount: succeeded, + OperatorID: operatorID, OperatorName: batch.OperatorName, CreatedAt: batch.CreatedAt, Items: withResultNames(items), + }, nil +} + +// Revert 撤销批次中仍在等待二次实名的卡;card_ids 为空时撤销整批。 +func (s *Service) Revert(ctx context.Context, req dto.RealnameResetRevertRequest) (*dto.RealnameResetRevertResponse, error) { + operatorID, err := s.authorize(ctx) + if err != nil { + return nil, err + } + var batch model.IotCardRealnameResetBatch + if err := s.db.WithContext(ctx).Select("id").Where("id = ?", req.ID).Take(&batch).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "二次实名批次不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询二次实名批次失败") + } + query := s.db.WithContext(ctx).Model(&model.IotCardRealname{}). + Where("reset_batch_id = ? AND reset_status = ?", batch.ID, constants.RealnameResetStatusPending) + if len(req.CardIDs) > 0 { + query = query.Where("card_id IN ?", req.CardIDs) + } + var cardIDs []uint + if err := query.Pluck("card_id", &cardIDs).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询等待二次实名的卡失败") + } + result := &dto.RealnameResetRevertResponse{} + for _, cardID := range cardIDs { + if err := s.resetter.RevertRealnameReset(ctx, cardobservationapp.RealnameResetCommand{CardID: cardID, BatchID: batch.ID, OperatorID: operatorID}); err != nil { + result.FailedCount++ + result.Failures = append(result.Failures, fmt.Sprintf("卡 %d:%s", cardID, errorMessage(err))) + continue + } + result.RevertedCount++ + } + if result.RevertedCount > 0 { + if err := s.db.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{ + "reverted_count": gorm.Expr("reverted_count + ?", result.RevertedCount), "updated_at": s.now(), + }).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新二次实名批次撤销数失败") + } + } + return result, nil +} + +// authorize 二次实名只允许平台账号,且需要 iot_card:realname_reset 权限(超级管理员不受限)。 +func (s *Service) authorize(ctx context.Context) (uint, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin: + return operatorID, nil + case constants.UserTypePlatform: + default: + return 0, errors.New(errors.CodeForbidden, "仅平台账号可办理二次实名") + } + if s.perms == nil { + return 0, errors.New(errors.CodeForbidden) + } + allowed, err := s.perms.CheckPermission(ctx, operatorID, constants.PermissionIotCardRealnameReset, constants.PlatformWeb) + if err != nil { + return 0, err + } + if !allowed { + return 0, errors.New(errors.CodeForbidden, "无二次实名权限") + } + return operatorID, nil +} + +// resolveTargets 把资产号展开为卡:先按卡 ICCID / 虚拟号,再按设备虚拟号 / IMEI 取设备当前全部绑定卡。 +func (s *Service) resolveTargets(ctx context.Context, identifier string) ([]resetTarget, error) { + var cards []resetTarget + if err := s.db.WithContext(ctx).Table("tb_iot_card").Select("id, iccid"). + Where("deleted_at IS NULL AND (iccid = ? OR virtual_no = ?)", identifier, identifier). + Order("id").Limit(1).Scan(&cards).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产号查询卡失败") + } + if len(cards) > 0 { + return cards, nil + } + if err := s.db.WithContext(ctx).Table("tb_device AS d").Select("c.id, c.iccid"). + Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id AND b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound). + Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL"). + Where("d.deleted_at IS NULL AND (d.virtual_no = ? OR d.imei = ?)", identifier, identifier). + Order("c.id").Scan(&cards).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产号查询设备绑定卡失败") + } + return cards, nil +} + +func normalizeIdentifiers(raw []string) []string { + result := make([]string, 0, len(raw)) + seen := make(map[string]bool, len(raw)) + for _, value := range raw { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func isExpectedResetError(err error) bool { + return err == cardobservationapp.ErrRealnameResetNotVerified || err == cardobservationapp.ErrRealnameResetNoUpstream || + err == cardobservationapp.ErrRealnameResetAlreadyReset +} + +func errorMessage(err error) string { + if appErr, ok := err.(*errors.AppError); ok && appErr.Message != "" { + return appErr.Message + } + return err.Error() +} + +func skippedItem(rowNo int, input string, target resetTarget, reason string) dto.RealnameResetItem { + return dto.RealnameResetItem{RowNo: rowNo, Input: input, CardID: target.CardID, ICCID: target.ICCID, + Result: constants.RealnameResetItemSkipped, Reason: reason} +} + +// withResultNames 补结果中文名。 +func withResultNames(items []dto.RealnameResetItem) []dto.RealnameResetItem { + for index := range items { + items[index].ResultName = constants.RealnameResetItemName(items[index].Result) + } + return items +} + +func generateBatchNo(now time.Time) string { + suffix, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + suffix = big.NewInt(now.UnixNano() % 10000) + } + return fmt.Sprintf("%s%s%04d", constants.RealnameResetBatchNoPrefix, now.Format("20060102150405"), suffix.Int64()) +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 87e695f7..a363bc86 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -11,6 +11,7 @@ import ( notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence" + realnameresetApp "github.com/break/junhong_cmp_fiber/internal/application/realnamereset" roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" @@ -37,6 +38,7 @@ import ( assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit" businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup" + cardrealnameQuery "github.com/break/junhong_cmp_fiber/internal/query/cardrealname" distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal" employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" @@ -360,7 +362,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { pollingSvcPkg.NewPollingStatusImportService(deps.DB, deps.QueueClient, deps.Logger), validate), CarrierScheduler: admin.NewCarrierSchedulerHandler(pollingSvcPkg.NewCarrierSchedulerService(deps.DB, deps.Redis, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), carrierSchedulerConfig()), validate), - PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), + PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), + CardRealname: admin.NewCardRealnameHandler(realnameresetApp.NewService(deps.DB, svc.CardObservation, svc.Permission), + cardrealnameQuery.NewQuery(deps.DB), validate), PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert), PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup), PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 8fe67123..8c0eb41f 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -68,6 +68,7 @@ type Handlers struct { CarrierScheduler *admin.CarrierSchedulerHandler PollingPause *admin.PollingPauseHandler PackageSilence *admin.PackageSilenceHandler + CardRealname *admin.CardRealnameHandler ClientPackageSilence *app.ClientPackageSilenceHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler diff --git a/internal/domain/cardobservation/realname.go b/internal/domain/cardobservation/realname.go index d039675b..808188a1 100644 --- a/internal/domain/cardobservation/realname.go +++ b/internal/domain/cardobservation/realname.go @@ -28,7 +28,9 @@ type ObservationMetadata struct { type RealnameObservation struct { CardID uint Verified bool - Metadata ObservationMetadata + // UpstreamRealnameAt 是上游返回的实名时间 archiveTime;未返回时为 nil。 + UpstreamRealnameAt *time.Time + Metadata ObservationMetadata } // CardRealnameSnapshot 是应用层从持久化模型映射出的最小卡实名快照。 diff --git a/internal/domain/cardobservation/realname_time.go b/internal/domain/cardobservation/realname_time.go new file mode 100644 index 00000000..9b969c0c --- /dev/null +++ b/internal/domain/cardobservation/realname_time.go @@ -0,0 +1,144 @@ +package cardobservation + +import ( + "time" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// RealnameTimeSnapshot 是卡当前实名记录的最小快照;Exists=false 表示还没有当前实名记录。 +type RealnameTimeSnapshot struct { + Exists bool + RealnameAt *time.Time + RealnameAtSource string + UpstreamRealnameAt *time.Time + FirstObservedAt *time.Time + ResetStatus string + ResetBaselineAt *time.Time +} + +// RealnameResetGate 描述二次实名等待期间对一次"已实名"观测的处理。 +type RealnameResetGate int + +const ( + // RealnameResetGateNone 卡不在等待二次实名,或本次不是已实名观测。 + RealnameResetGateNone RealnameResetGate = iota + // RealnameResetGateBlocked 读到的仍是旧实名(或没有上游实名时间),不得改实名状态。 + RealnameResetGateBlocked + // RealnameResetGateCompleted 上游实名时间晚于清除前时间,二次实名完成。 + RealnameResetGateCompleted +) + +// EvaluateRealnameResetGate 判断等待二次实名的卡本次已实名观测是新实名还是旧实名。 +// 以上游 archiveTime 为准:晚于清除前的上游实名时间才算新实名;没有上游时间一律视为旧实名。 +// 人工纠偏是运营明确操作,不受拦截。 +func EvaluateRealnameResetGate(snapshot RealnameTimeSnapshot, observation RealnameObservation) RealnameResetGate { + if !observation.Verified || snapshot.ResetStatus != constants.RealnameResetStatusPending { + return RealnameResetGateNone + } + if observation.Metadata.Source == constants.CardObservationSourceManualOverride { + return RealnameResetGateCompleted + } + upstream := observation.UpstreamRealnameAt + if upstream == nil || snapshot.ResetBaselineAt == nil || !upstream.After(*snapshot.ResetBaselineAt) { + return RealnameResetGateBlocked + } + return RealnameResetGateCompleted +} + +// RealnameTimeDecision 描述一次已实名观测后当前实名记录应如何更新。 +type RealnameTimeDecision struct { + // Changed 表示当前实名记录有任何字段需要写入(含不写历史的上游最近时间等)。 + Changed bool + // RealnameAt / Source 是写入后的当前实名时间与来源。 + RealnameAt *time.Time + Source string + // UpstreamRealnameAt / FirstObservedAt 是写入后的值。 + UpstreamRealnameAt *time.Time + FirstObservedAt *time.Time + // ClearReset 表示清除二次实名等待状态。 + ClearReset bool + // WriteHistory 仅在当前实名时间变化时为 true。 + WriteHistory bool + ChangeType string + BeforeAt *time.Time +} + +// DecideRealnameTime 根据已被采纳为"已实名"的观测确定当前实名时间: +// 1. 有上游实名时间 → 以它为准(来源 upstream); +// 2. 否则已有当前实名时间 → 保持不变; +// 3. 否则 → 取本次观测时间(来源 first_observed)。 +// +// 只有当前实名时间变化才写历史;相同时间只可能静默更新来源或上游最近返回时间。 +// 调用方只应在观测被采纳为已实名(卡最终为已实名、且未被二次实名拦截)时调用。 +func DecideRealnameTime(snapshot RealnameTimeSnapshot, observation RealnameObservation, gate RealnameResetGate) RealnameTimeDecision { + decision := RealnameTimeDecision{ + RealnameAt: snapshot.RealnameAt, Source: snapshot.RealnameAtSource, + UpstreamRealnameAt: snapshot.UpstreamRealnameAt, FirstObservedAt: snapshot.FirstObservedAt, + BeforeAt: snapshot.RealnameAt, + } + if !snapshot.Exists { + decision.Changed = true + } + observedAt := observation.Metadata.ObservedAt + if decision.FirstObservedAt == nil { + decision.FirstObservedAt = timePtr(observedAt) + decision.Changed = true + } + upstream := observation.UpstreamRealnameAt + if upstream != nil && !sameTime(decision.UpstreamRealnameAt, upstream) { + decision.UpstreamRealnameAt = timePtr(*upstream) + decision.Changed = true + } + + var next *time.Time + source := decision.Source + switch { + case upstream != nil: + next, source = timePtr(*upstream), constants.RealnameAtSourceUpstream + case snapshot.RealnameAt != nil: + next = snapshot.RealnameAt + default: + next, source = timePtr(observedAt), constants.RealnameAtSourceFirstObserved + } + + if gate == RealnameResetGateCompleted { + decision.ClearReset = true + decision.Changed = true + decision.ChangeType = constants.RealnameChangeResetCompleted + } + if !sameTime(snapshot.RealnameAt, next) { + decision.RealnameAt, decision.Source = next, source + decision.Changed = true + decision.WriteHistory = true + if decision.ChangeType == "" { + if snapshot.RealnameAt == nil { + decision.ChangeType = constants.RealnameChangeFirst + } else { + decision.ChangeType = constants.RealnameChangeUpstreamFix + } + } + return decision + } + if source != decision.Source { + // 时间相同只是来源不同(如迁移时间恰好等于上游时间):静默更正来源,不写历史。 + decision.Source = source + decision.Changed = true + } + if gate == RealnameResetGateCompleted { + // 二次实名完成但时间未变(理论上不会发生,上游时间一定晚于基准):仍记一条完成历史。 + decision.WriteHistory = true + } + return decision +} + +func sameTime(left, right *time.Time) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.Equal(*right) +} + +func timePtr(value time.Time) *time.Time { + return &value +} diff --git a/internal/domain/cardobservation/realname_time_test.go b/internal/domain/cardobservation/realname_time_test.go new file mode 100644 index 00000000..4c0ad721 --- /dev/null +++ b/internal/domain/cardobservation/realname_time_test.go @@ -0,0 +1,149 @@ +package cardobservation + +import ( + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +var cst = time.FixedZone("CST", 8*3600) + +func at(value string) *time.Time { + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, cst) + if err != nil { + panic(err) + } + return &parsed +} + +func verifiedObservation(source string, observedAt, upstream *time.Time) RealnameObservation { + return RealnameObservation{CardID: 1, Verified: true, UpstreamRealnameAt: upstream, + Metadata: ObservationMetadata{ObservationID: "o", Source: source, Scene: "s", ObservedAt: *observedAt}} +} + +func TestDecideRealnameTime(t *testing.T) { + polling := constants.CardObservationSourcePolling + cases := []struct { + name string + snapshot RealnameTimeSnapshot + observation RealnameObservation + gate RealnameResetGate + wantAt *time.Time + wantSource string + wantHistory bool + wantChange string + wantChanged bool + wantUpstream *time.Time + }{ + { + name: "每次轮询读到相同上游时间不写", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: at("2026-07-16 19:43:39"), RealnameAtSource: constants.RealnameAtSourceUpstream, + UpstreamRealnameAt: at("2026-07-16 19:43:39"), FirstObservedAt: at("2026-07-16 20:03:25")}, + observation: verifiedObservation(polling, at("2026-11-08 17:10:51"), at("2026-07-16 19:43:39")), + wantAt: at("2026-07-16 19:43:39"), wantSource: constants.RealnameAtSourceUpstream, + wantUpstream: at("2026-07-16 19:43:39"), + }, + { + name: "上游更正迁移时间", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: at("2026-07-16 20:03:25"), RealnameAtSource: constants.RealnameAtSourceMigrated, + FirstObservedAt: at("2026-07-16 20:03:25")}, + observation: verifiedObservation(polling, at("2026-11-08 17:10:51"), at("2026-07-16 19:43:39")), + wantAt: at("2026-07-16 19:43:39"), wantSource: constants.RealnameAtSourceUpstream, + wantHistory: true, wantChange: constants.RealnameChangeUpstreamFix, wantChanged: true, wantUpstream: at("2026-07-16 19:43:39"), + }, + { + name: "不返回时间的运营商首次实名", + snapshot: RealnameTimeSnapshot{}, + observation: verifiedObservation(polling, at("2026-11-08 10:00:00"), nil), + wantAt: at("2026-11-08 10:00:00"), wantSource: constants.RealnameAtSourceFirstObserved, + wantHistory: true, wantChange: constants.RealnameChangeFirst, wantChanged: true, + }, + { + name: "不返回时间的运营商再次读到不写", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: at("2026-11-08 10:00:00"), RealnameAtSource: constants.RealnameAtSourceFirstObserved, + FirstObservedAt: at("2026-11-08 10:00:00")}, + observation: verifiedObservation(polling, at("2026-11-08 12:00:00"), nil), + wantAt: at("2026-11-08 10:00:00"), wantSource: constants.RealnameAtSourceFirstObserved, + }, + { + name: "迁移沿用的时间在无上游时间时保持", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: at("2026-09-01 10:00:00"), RealnameAtSource: constants.RealnameAtSourceMigrated, + FirstObservedAt: at("2026-09-01 10:00:00")}, + observation: verifiedObservation(polling, at("2026-11-08 12:00:00"), nil), + wantAt: at("2026-09-01 10:00:00"), wantSource: constants.RealnameAtSourceMigrated, + }, + { + name: "已实名但无记录的老卡首次读到上游时间", + snapshot: RealnameTimeSnapshot{}, + observation: verifiedObservation(polling, at("2026-11-09 00:10:00"), at("2026-08-01 09:00:00")), + wantAt: at("2026-08-01 09:00:00"), wantSource: constants.RealnameAtSourceUpstream, + wantHistory: true, wantChange: constants.RealnameChangeFirst, wantChanged: true, wantUpstream: at("2026-08-01 09:00:00"), + }, + { + name: "二次实名完成", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: nil, RealnameAtSource: "", UpstreamRealnameAt: at("2026-07-16 19:43:39"), + FirstObservedAt: at("2026-07-16 20:03:25"), ResetStatus: constants.RealnameResetStatusPending, ResetBaselineAt: at("2026-07-16 19:43:39")}, + observation: verifiedObservation(polling, at("2026-11-09 10:05:00"), at("2026-11-09 10:00:00")), + gate: RealnameResetGateCompleted, + wantAt: at("2026-11-09 10:00:00"), wantSource: constants.RealnameAtSourceUpstream, + wantHistory: true, wantChange: constants.RealnameChangeResetCompleted, wantChanged: true, wantUpstream: at("2026-11-09 10:00:00"), + }, + { + name: "时间相同只是来源不同静默更正", + snapshot: RealnameTimeSnapshot{Exists: true, RealnameAt: at("2026-07-16 19:43:39"), RealnameAtSource: constants.RealnameAtSourceMigrated, + FirstObservedAt: at("2026-07-16 19:43:39")}, + observation: verifiedObservation(polling, at("2026-11-08 17:10:51"), at("2026-07-16 19:43:39")), + wantAt: at("2026-07-16 19:43:39"), wantSource: constants.RealnameAtSourceUpstream, wantChanged: true, + wantUpstream: at("2026-07-16 19:43:39"), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DecideRealnameTime(tc.snapshot, tc.observation, tc.gate) + if !sameTime(got.RealnameAt, tc.wantAt) || got.Source != tc.wantSource { + t.Fatalf("realname_at=%v source=%s, want %v %s", got.RealnameAt, got.Source, tc.wantAt, tc.wantSource) + } + if got.WriteHistory != tc.wantHistory || got.ChangeType != tc.wantChange { + t.Fatalf("history=%v change=%s, want %v %s", got.WriteHistory, got.ChangeType, tc.wantHistory, tc.wantChange) + } + if got.Changed != tc.wantChanged { + t.Fatalf("changed=%v, want %v", got.Changed, tc.wantChanged) + } + if tc.wantUpstream != nil && !sameTime(got.UpstreamRealnameAt, tc.wantUpstream) { + t.Fatalf("upstream=%v, want %v", got.UpstreamRealnameAt, tc.wantUpstream) + } + if tc.gate == RealnameResetGateCompleted && !got.ClearReset { + t.Fatal("reset completion must clear reset state") + } + }) + } +} + +func TestEvaluateRealnameResetGate(t *testing.T) { + pending := RealnameTimeSnapshot{Exists: true, ResetStatus: constants.RealnameResetStatusPending, ResetBaselineAt: at("2026-07-16 19:43:39")} + polling := constants.CardObservationSourcePolling + now := at("2026-11-09 10:05:00") + cases := []struct { + name string + snapshot RealnameTimeSnapshot + observation RealnameObservation + want RealnameResetGate + }{ + {"不在等待", RealnameTimeSnapshot{Exists: true}, verifiedObservation(polling, now, nil), RealnameResetGateNone}, + {"读到旧实名", pending, verifiedObservation(polling, now, at("2026-07-16 19:43:39")), RealnameResetGateBlocked}, + {"读到更早实名", pending, verifiedObservation(polling, now, at("2026-07-01 00:00:00")), RealnameResetGateBlocked}, + {"没有上游时间", pending, verifiedObservation(polling, now, nil), RealnameResetGateBlocked}, + {"回调没有时间", pending, verifiedObservation(constants.CardObservationSourceCarrierCallback, now, nil), RealnameResetGateBlocked}, + {"新实名", pending, verifiedObservation(polling, now, at("2026-11-09 10:00:00")), RealnameResetGateCompleted}, + {"人工改为已实名", pending, verifiedObservation(constants.CardObservationSourceManualOverride, now, nil), RealnameResetGateCompleted}, + {"未实名观测", pending, RealnameObservation{CardID: 1, Metadata: ObservationMetadata{Source: polling, ObservedAt: *now}}, RealnameResetGateNone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := EvaluateRealnameResetGate(tc.snapshot, tc.observation); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 4e500387..d55e705c 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -61,7 +61,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs COALESCE(d.device_name, '') AS device_name, c.real_name_status, c.enable_polling, - c.first_realname_at, + CASE WHEN rn.id IS NULL THEN c.first_realname_at ELSE rn.realname_at END AS first_realname_at, c.first_purchase_at, c.network_status, COALESCE(pkg.package_name, '') AS package_name, @@ -169,6 +169,7 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx). Table("tb_iot_card AS c"). Joins("LEFT JOIN tb_shop AS sh ON sh.id = c.shop_id"). + Joins("LEFT JOIN tb_iot_card_realname AS rn ON rn.card_id = c.id"). Joins(` LEFT JOIN LATERAL ( SELECT d.device_name diff --git a/internal/gateway/models.go b/internal/gateway/models.go index 2f635f63..e35adb30 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strconv" "strings" + "time" ) // FlexBool 灵活布尔类型 @@ -206,8 +207,17 @@ type CardOperationReq struct { // RealnameStatusResp 是实名认证状态的响应 type RealnameStatusResp struct { - ICCID string `json:"iccid" description:"ICCID"` - RealStatus bool `json:"realStatus" description:"实名状态(true=已实名, false=未实名)"` + ICCID string `json:"iccid" description:"ICCID"` + RealStatus bool `json:"realStatus" description:"实名状态(true=已实名, false=未实名)"` + ArchiveTime *string `json:"archiveTime,omitempty" description:"上游实名时间(YYYY-MM-DD HH:mm:ss,北京时间;部分运营商不返回)"` +} + +// UpstreamRealnameAt 返回上游实名时间:仅已实名且 archiveTime 可解析时有值;缺失、空、格式错误或未实名时返回 nil。 +func (r *RealnameStatusResp) UpstreamRealnameAt() *time.Time { + if r == nil || !r.RealStatus || r.ArchiveTime == nil { + return nil + } + return parseUpstreamTime(*r.ArchiveTime) } // RealnameLinkResp 是实名认证链接的响应 diff --git a/internal/gateway/upstream_time.go b/internal/gateway/upstream_time.go new file mode 100644 index 00000000..7586e236 --- /dev/null +++ b/internal/gateway/upstream_time.go @@ -0,0 +1,25 @@ +package gateway + +import ( + "strings" + "time" +) + +// upstreamLocation 是上游时间字段的时区(北京时间,固定 +08:00,不依赖系统时区数据库)。 +var upstreamLocation = time.FixedZone("CST", 8*3600) + +var upstreamTimeLayouts = []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006/01/02 15:04:05"} + +// parseUpstreamTime 按北京时间解析上游时间字符串,空或无法解析返回 nil。 +func parseUpstreamTime(raw string) *time.Time { + value := strings.TrimSpace(raw) + if value == "" || strings.EqualFold(value, "null") { + return nil + } + for _, layout := range upstreamTimeLayouts { + if parsed, err := time.ParseInLocation(layout, value, upstreamLocation); err == nil { + return &parsed + } + } + return nil +} diff --git a/internal/gateway/upstream_time_test.go b/internal/gateway/upstream_time_test.go new file mode 100644 index 00000000..5cdfe3e4 --- /dev/null +++ b/internal/gateway/upstream_time_test.go @@ -0,0 +1,42 @@ +package gateway + +import ( + "encoding/json" + "testing" + "time" +) + +func TestRealnameStatusRespUpstreamRealnameAt(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"移动返回实名时间", `{"archiveTime":"2026-07-16 19:43:39","iccid":"89860885192590574640","realStatus":true}`, "2026-07-16T19:43:39+08:00"}, + {"电信不返回", `{"iccid":"8986112520903884400","realStatus":true}`, ""}, + {"联通 extend 无时间", `{"extend":"已实名","iccid":"89860625750010460562","realStatus":true}`, ""}, + {"未实名 null", `{"archiveTime":null,"iccid":"898608851925D1284902","realStatus":false}`, ""}, + {"未实名但带时间忽略", `{"archiveTime":"2026-07-16 19:43:39","realStatus":false}`, ""}, + {"空串", `{"archiveTime":"","realStatus":true}`, ""}, + {"格式错误", `{"archiveTime":"07/16/2026","realStatus":true}`, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var resp RealnameStatusResp + if err := json.Unmarshal([]byte(tc.body), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := resp.UpstreamRealnameAt() + if tc.want == "" { + if got != nil { + t.Fatalf("want nil, got %v", got) + } + return + } + want, _ := time.Parse(time.RFC3339, tc.want) + if got == nil || !got.Equal(want) { + t.Fatalf("want %v, got %v", want, got) + } + }) + } +} diff --git a/internal/handler/admin/card_realname.go b/internal/handler/admin/card_realname.go new file mode 100644 index 00000000..a2ee3c31 --- /dev/null +++ b/internal/handler/admin/card_realname.go @@ -0,0 +1,128 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/application/realnamereset" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/query/cardrealname" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// CardRealnameHandler 处理卡实名记录与二次实名批次。 +type CardRealnameHandler struct { + reset *realnamereset.Service + query *cardrealname.Query + validator *validator.Validate +} + +// NewCardRealnameHandler 创建卡实名记录处理器。 +func NewCardRealnameHandler(reset *realnamereset.Service, query *cardrealname.Query, validate *validator.Validate) *CardRealnameHandler { + if validate == nil { + validate = validator.New() + } + return &CardRealnameHandler{reset: reset, query: query, validator: validate} +} + +func (h *CardRealnameHandler) validate(prefix string, req any) error { + if err := h.validator.Struct(req); err != nil { + return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err)) + } + return nil +} + +// CardHistory 查询单卡当前实名时间与实名时间历史。 +// GET /api/admin/iot-cards/:id/realname-history +func (h *CardRealnameHandler) CardHistory(c *fiber.Ctx) error { + var req dto.CardRealnameHistoryListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + id, err := rateLimitPathID(c, "id", "IoT 卡 ID") + if err != nil { + return err + } + req.ID = id + if err := h.validate("实名记录查询参数不合法", &req); err != nil { + return err + } + result, err := h.query.CardHistory(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ImportResetBatch 导入二次实名名单。 +// POST /api/admin/realname-reset/batches +func (h *CardRealnameHandler) ImportResetBatch(c *fiber.Ctx) error { + var req dto.RealnameResetImportRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("二次实名导入参数不合法", &req); err != nil { + return err + } + result, err := h.reset.Import(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListResetBatches 分页查询二次实名批次。 +// GET /api/admin/realname-reset/batches +func (h *CardRealnameHandler) ListResetBatches(c *fiber.Ctx) error { + var req dto.RealnameResetBatchListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("二次实名批次查询参数不合法", &req); err != nil { + return err + } + list, total, page, size, err := h.query.ListResetBatches(c.UserContext(), req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, list, total, page, size) +} + +// GetResetBatch 查询二次实名批次详情。 +// GET /api/admin/realname-reset/batches/:id +func (h *CardRealnameHandler) GetResetBatch(c *fiber.Ctx) error { + id, err := rateLimitPathID(c, "id", "批次 ID") + if err != nil { + return err + } + result, err := h.query.GetResetBatch(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// RevertResetBatch 撤销批次中仍在等待二次实名的卡。 +// POST /api/admin/realname-reset/batches/:id/revert +func (h *CardRealnameHandler) RevertResetBatch(c *fiber.Ctx) error { + var req dto.RealnameResetRevertRequest + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + } + id, err := rateLimitPathID(c, "id", "批次 ID") + if err != nil { + return err + } + req.ID = id + if err := h.validate("二次实名撤销参数不合法", &req); err != nil { + return err + } + result, err := h.reset.Revert(c.UserContext(), req) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/cardobservation/series_runner.go b/internal/infrastructure/cardobservation/series_runner.go index d3fffb64..a73222de 100644 --- a/internal/infrastructure/cardobservation/series_runner.go +++ b/internal/infrastructure/cardobservation/series_runner.go @@ -551,7 +551,7 @@ func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.Series if err != nil { return cardapp.RunResult{}, err } - decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, Metadata: metadata}) + decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, UpstreamRealnameAt: response.UpstreamRealnameAt(), Metadata: metadata}) return cardapp.RunResult{StateChanged: decision.StatusChanged}, err case constants.CardObservationSyncTypeTraffic: response, err := r.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID}) diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 2948d2a7..2292a374 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -27,7 +27,9 @@ type AssetResolveResponse struct { ActivationStatus int `json:"activation_status" description:"激活状态:0未激活 1已激活;设备需有生效中主套餐且任意绑定卡已实名"` ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` - RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"` + RealNameAt *time.Time `json:"real_name_at" description:"实名时间:上游返回的实名时间优先,否则为首次读到已实名的时间;未实名时为 null"` + RealNameAtSource string `json:"real_name_at_source,omitempty" description:"实名时间来源(卡资产):upstream 上游返回 / first_observed 首次读到 / migrated 迁移沿用"` + RealNameAtSourceName string `json:"real_name_at_source_name,omitempty" description:"实名时间来源中文名"` RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` @@ -116,7 +118,9 @@ type BoundCardInfo struct { MSISDN string `json:"msisdn,omitempty" description:"手机号"` NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机"` RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` - RealNameAt *time.Time `json:"real_name_at" description:"该卡最近一次完成实名的时间,未实名时为 null"` + RealNameAt *time.Time `json:"real_name_at" description:"该卡实名时间:上游返回的实名时间优先,否则为首次读到已实名的时间;未实名时为 null"` + RealNameAtSource string `json:"real_name_at_source,omitempty" description:"实名时间来源:upstream 上游返回 / first_observed 首次读到 / migrated 迁移沿用"` + RealNameAtSourceName string `json:"real_name_at_source_name,omitempty" description:"实名时间来源中文名"` SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"` IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"` CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"` diff --git a/internal/model/dto/card_realname_dto.go b/internal/model/dto/card_realname_dto.go new file mode 100644 index 00000000..e220c795 --- /dev/null +++ b/internal/model/dto/card_realname_dto.go @@ -0,0 +1,114 @@ +package dto + +import "time" + +// CardRealnameHistoryListRequest 查询单卡实名时间历史。 +type CardRealnameHistoryListRequest struct { + ID uint `json:"-" path:"id" description:"IoT卡ID"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` +} + +// CardRealnameCurrentResponse 是卡当前实名时间。 +type CardRealnameCurrentResponse struct { + CardID uint `json:"card_id" description:"IoT卡ID"` + RealnameAt *time.Time `json:"realname_at" description:"当前实名时间:上游返回的实名时间优先,否则为首次读到已实名的时间"` + RealnameAtSource string `json:"realname_at_source" description:"实名时间来源:upstream 上游返回 / first_observed 首次读到 / migrated 迁移沿用"` + RealnameAtSourceName string `json:"realname_at_source_name" description:"实名时间来源中文名"` + UpstreamRealnameAt *time.Time `json:"upstream_realname_at" description:"上游最近一次返回的实名时间"` + FirstObservedAt *time.Time `json:"first_observed_at" description:"首次读到已实名的时间"` + ResetPending bool `json:"reset_pending" description:"是否等待二次实名(仅后台展示)"` + ResetAt *time.Time `json:"reset_at" description:"二次实名清除时间"` + ResetBaselineAt *time.Time `json:"reset_baseline_at" description:"清除前的上游实名时间(新旧判定基准)"` + ResetBatchID *uint `json:"reset_batch_id" description:"所属二次实名批次 ID"` +} + +// CardRealnameHistoryItem 是一条实名时间变化历史。 +type CardRealnameHistoryItem struct { + ID uint `json:"id" description:"历史 ID"` + CardID uint `json:"card_id" description:"IoT卡ID"` + ICCID string `json:"iccid" description:"ICCID"` + BeforeAt *time.Time `json:"before_at" description:"原实名时间"` + AfterAt *time.Time `json:"after_at" description:"新实名时间"` + AfterSource string `json:"after_source" description:"新实名时间来源"` + AfterSourceName string `json:"after_source_name" description:"新实名时间来源中文名"` + ChangeType string `json:"change_type" description:"变化类型:first 首次实名 / upstream_fix 上游更正 / reset 二次实名清除 / reset_completed 二次实名完成 / reset_reverted 二次实名撤销 / migrated 迁移补录"` + ChangeTypeName string `json:"change_type_name" description:"变化类型中文名"` + ObservationSource string `json:"observation_source" description:"观测来源:polling 轮询 / manual_sync 手动刷新 / carrier_callback 回调 / business_event 业务观测 / manual_override 人工 / migration 迁移 / package_silence 沉默期判定"` + ObservationSourceName string `json:"observation_source_name" description:"观测来源中文名"` + ResetBatchID *uint `json:"reset_batch_id" description:"关联二次实名批次 ID"` + OperatorID uint `json:"operator_id" description:"操作人账号 ID(人工时)"` + OperatorName string `json:"operator_name" description:"操作人名称"` + ObservedAt time.Time `json:"observed_at" description:"发现时间"` + CreatedAt time.Time `json:"created_at" description:"记录时间"` +} + +// CardRealnameHistoryResponse 是单卡实名记录:当前实名时间与历史分页。 +type CardRealnameHistoryResponse struct { + Current CardRealnameCurrentResponse `json:"current" description:"当前实名时间"` + Items []CardRealnameHistoryItem `json:"items" description:"实名时间历史(按时间倒序)"` + Total int64 `json:"total" description:"历史总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// RealnameResetImportRequest 导入二次实名名单。 +type RealnameResetImportRequest struct { + Identifiers []string `json:"identifiers" validate:"required,min=1,max=1000,dive,required,max=64" required:"true" description:"资产号列表:ICCID / 虚拟号 / IMEI,设备展开为其全部绑定卡,最多 1000 个"` + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" description:"原因"` + ImportFileKey string `json:"import_file_key" validate:"omitempty,max=500" description:"导入文件对象存储 Key(可选,留档)"` +} + +// RealnameResetItem 是二次实名批次的一行结果。 +type RealnameResetItem struct { + RowNo int `json:"row_no" description:"行号(从 1 开始)"` + Input string `json:"input" description:"导入的资产号"` + CardID uint `json:"card_id,omitempty" description:"卡 ID"` + ICCID string `json:"iccid,omitempty" description:"ICCID"` + Result string `json:"result" description:"结果:pending 等待二次实名 / completed 已完成 / reverted 已撤销 / skipped 跳过 / failed 失败"` + ResultName string `json:"result_name" description:"结果中文名"` + Reason string `json:"reason,omitempty" description:"跳过或失败原因"` +} + +// RealnameResetBatchResponse 是二次实名批次。 +type RealnameResetBatchResponse struct { + ID uint `json:"id" description:"批次 ID"` + BatchNo string `json:"batch_no" description:"批次号"` + Reason string `json:"reason" description:"原因"` + ImportFileKey string `json:"import_file_key" description:"导入文件 Key"` + TotalCount int `json:"total_count" description:"处理卡数(含跳过)"` + SucceededCount int `json:"succeeded_count" description:"已清除卡数"` + SkippedCount int `json:"skipped_count" description:"跳过或失败数"` + CompletedCount int `json:"completed_count" description:"已完成二次实名卡数"` + RevertedCount int `json:"reverted_count" description:"已撤销卡数"` + PendingCount int `json:"pending_count" description:"仍在等待二次实名卡数"` + OperatorID uint `json:"operator_id" description:"操作人账号 ID"` + OperatorName string `json:"operator_name" description:"操作人"` + CreatedAt time.Time `json:"created_at" description:"导入时间"` + Items []RealnameResetItem `json:"items,omitempty" description:"逐行结果(详情返回)"` +} + +// RealnameResetBatchListRequest 查询二次实名批次。 +type RealnameResetBatchListRequest struct { + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=40" description:"批次号"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` +} + +// RealnameResetBatchIDRequest 是批次路径参数。 +type RealnameResetBatchIDRequest struct { + ID uint `json:"-" path:"id" description:"批次 ID"` +} + +// RealnameResetRevertRequest 撤销二次实名。 +type RealnameResetRevertRequest struct { + ID uint `json:"-" path:"id" description:"批次 ID"` + CardIDs []uint `json:"card_ids" validate:"omitempty,max=1000" description:"只撤销这些卡;为空撤销整批仍在等待的卡"` +} + +// RealnameResetRevertResponse 撤销结果。 +type RealnameResetRevertResponse struct { + RevertedCount int `json:"reverted_count" description:"撤销成功卡数"` + FailedCount int `json:"failed_count" description:"撤销失败卡数"` + Failures []string `json:"failures,omitempty" description:"失败说明"` +} diff --git a/internal/model/iot_card_realname.go b/internal/model/iot_card_realname.go new file mode 100644 index 00000000..3b798816 --- /dev/null +++ b/internal/model/iot_card_realname.go @@ -0,0 +1,67 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// IotCardRealname 是卡的当前实名记录(每卡一行)。 +// 实名状态 real_name_status 仍在 tb_iot_card;这里只记录实名时间及来源、二次实名状态。 +type IotCardRealname struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + CardID uint `gorm:"column:card_id;not null" json:"card_id"` + RealnameAt *time.Time `gorm:"column:realname_at" json:"realname_at,omitempty"` + RealnameAtSource string `gorm:"column:realname_at_source;type:varchar(20);not null;default:''" json:"realname_at_source"` + UpstreamRealnameAt *time.Time `gorm:"column:upstream_realname_at" json:"upstream_realname_at,omitempty"` + FirstObservedAt *time.Time `gorm:"column:first_observed_at" json:"first_observed_at,omitempty"` + ResetStatus string `gorm:"column:reset_status;type:varchar(20);not null;default:'none'" json:"reset_status"` + ResetAt *time.Time `gorm:"column:reset_at" json:"reset_at,omitempty"` + ResetBaselineAt *time.Time `gorm:"column:reset_baseline_at" json:"reset_baseline_at,omitempty"` + ResetBatchID *uint `gorm:"column:reset_batch_id" json:"reset_batch_id,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名。 +func (IotCardRealname) TableName() string { return "tb_iot_card_realname" } + +// IotCardRealnameHistory 是卡实名时间变化历史(只追加)。 +type IotCardRealnameHistory struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + CardID uint `gorm:"column:card_id;not null" json:"card_id"` + ICCID string `gorm:"column:iccid;type:varchar(20);not null;default:''" json:"iccid"` + BeforeAt *time.Time `gorm:"column:before_at" json:"before_at,omitempty"` + AfterAt *time.Time `gorm:"column:after_at" json:"after_at,omitempty"` + AfterSource string `gorm:"column:after_source;type:varchar(20);not null;default:''" json:"after_source"` + ChangeType string `gorm:"column:change_type;type:varchar(30);not null" json:"change_type"` + ObservationSource string `gorm:"column:observation_source;type:varchar(30);not null;default:''" json:"observation_source"` + ResetBatchID *uint `gorm:"column:reset_batch_id" json:"reset_batch_id,omitempty"` + OperatorID uint `gorm:"column:operator_id;not null;default:0" json:"operator_id"` + ObservedAt time.Time `gorm:"column:observed_at;not null" json:"observed_at"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` +} + +// TableName 指定表名。 +func (IotCardRealnameHistory) TableName() string { return "tb_iot_card_realname_history" } + +// IotCardRealnameResetBatch 是二次实名导入批次。 +type IotCardRealnameResetBatch struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + BatchNo string `gorm:"column:batch_no;type:varchar(40);not null" json:"batch_no"` + ImportFileKey string `gorm:"column:import_file_key;type:varchar(500);not null;default:''" json:"import_file_key"` + Reason string `gorm:"column:reason;type:varchar(500);not null;default:''" json:"reason"` + TotalCount int `gorm:"column:total_count;not null;default:0" json:"total_count"` + SucceededCount int `gorm:"column:succeeded_count;not null;default:0" json:"succeeded_count"` + SkippedCount int `gorm:"column:skipped_count;not null;default:0" json:"skipped_count"` + RevertedCount int `gorm:"column:reverted_count;not null;default:0" json:"reverted_count"` + CompletedCount int `gorm:"column:completed_count;not null;default:0" json:"completed_count"` + Items datatypes.JSON `gorm:"column:items;type:jsonb;not null;default:'[]'" json:"items"` + OperatorID uint `gorm:"column:operator_id;not null;default:0" json:"operator_id"` + OperatorName string `gorm:"column:operator_name;type:varchar(100);not null;default:''" json:"operator_name"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +// TableName 指定表名。 +func (IotCardRealnameResetBatch) TableName() string { return "tb_iot_card_realname_reset_batch" } diff --git a/internal/query/cardrealname/current.go b/internal/query/cardrealname/current.go new file mode 100644 index 00000000..62479566 --- /dev/null +++ b/internal/query/cardrealname/current.go @@ -0,0 +1,42 @@ +// Package cardrealname 提供卡当前实名时间与实名时间历史的只读查询。 +package cardrealname + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +// Current 是卡当前实名时间及来源。 +type Current struct { + RealnameAt *time.Time + Source string +} + +// LoadCurrent 批量读取卡当前实名记录;没有记录的卡不在返回结果中。 +func LoadCurrent(ctx context.Context, db *gorm.DB, cardIDs []uint) (map[uint]Current, error) { + result := make(map[uint]Current, len(cardIDs)) + if db == nil || len(cardIDs) == 0 { + return result, nil + } + var rows []model.IotCardRealname + if err := db.WithContext(ctx).Select("card_id", "realname_at", "realname_at_source"). + Where("card_id IN ?", cardIDs).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前实名时间失败") + } + for _, row := range rows { + result[row.CardID] = Current{RealnameAt: row.RealnameAt, Source: row.RealnameAtSource} + } + return result, nil +} + +// ResolveRealnameAt 返回卡的实名时间:有当前实名记录用记录(可能为空,如等待二次实名),没有记录回退 first_realname_at。 +func ResolveRealnameAt(current map[uint]Current, cardID uint, fallback *time.Time) (*time.Time, string) { + if value, ok := current[cardID]; ok { + return value.RealnameAt, value.Source + } + return fallback, "" +} diff --git a/internal/query/cardrealname/query.go b/internal/query/cardrealname/query.go new file mode 100644 index 00000000..0da5112c --- /dev/null +++ b/internal/query/cardrealname/query.go @@ -0,0 +1,269 @@ +package cardrealname + +import ( + "context" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// Query 提供单卡实名记录与二次实名批次查询。 +type Query struct { + db *gorm.DB +} + +// NewQuery 创建实名记录查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db} +} + +// CardHistory 返回单卡当前实名时间与实名时间历史(按时间倒序分页);卡按当前账号数据范围可见。 +func (q *Query) CardHistory(ctx context.Context, req dto.CardRealnameHistoryListRequest) (*dto.CardRealnameHistoryResponse, error) { + var card model.IotCard + if err := middleware.ApplyShopFilter(ctx, q.db.WithContext(ctx).Select("id", "first_realname_at").Where("id = ?", req.ID)). + First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败") + } + page, size := normalizePage(req.Page, req.PageSize) + resp := &dto.CardRealnameHistoryResponse{Current: dto.CardRealnameCurrentResponse{CardID: card.ID}, Page: page, PageSize: size} + var record model.IotCardRealname + err := q.db.WithContext(ctx).Where("card_id = ?", card.ID).Take(&record).Error + switch { + case err == nil: + resp.Current = dto.CardRealnameCurrentResponse{ + CardID: card.ID, RealnameAt: record.RealnameAt, RealnameAtSource: record.RealnameAtSource, + RealnameAtSourceName: constants.RealnameAtSourceName(record.RealnameAtSource), + UpstreamRealnameAt: record.UpstreamRealnameAt, FirstObservedAt: record.FirstObservedAt, + ResetPending: record.ResetStatus == constants.RealnameResetStatusPending, ResetAt: record.ResetAt, + ResetBaselineAt: record.ResetBaselineAt, ResetBatchID: record.ResetBatchID, + } + case err == gorm.ErrRecordNotFound: + resp.Current.RealnameAt = card.FirstRealnameAt + default: + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前实名记录失败") + } + base := q.db.WithContext(ctx).Model(&model.IotCardRealnameHistory{}).Where("card_id = ?", card.ID) + if err := base.Count(&resp.Total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计卡实名时间历史失败") + } + var rows []model.IotCardRealnameHistory + if err := base.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名时间历史失败") + } + operators, err := q.operatorNames(ctx, rows) + if err != nil { + return nil, err + } + resp.Items = make([]dto.CardRealnameHistoryItem, 0, len(rows)) + for _, row := range rows { + resp.Items = append(resp.Items, dto.CardRealnameHistoryItem{ + ID: row.ID, CardID: row.CardID, ICCID: row.ICCID, BeforeAt: row.BeforeAt, AfterAt: row.AfterAt, + AfterSource: row.AfterSource, AfterSourceName: constants.RealnameAtSourceName(row.AfterSource), + ChangeType: row.ChangeType, ChangeTypeName: constants.RealnameChangeTypeName(row.ChangeType), + ObservationSource: row.ObservationSource, ObservationSourceName: observationSourceName(row.ObservationSource), + ResetBatchID: row.ResetBatchID, OperatorID: row.OperatorID, OperatorName: operators[row.OperatorID], + ObservedAt: row.ObservedAt, CreatedAt: row.CreatedAt, + }) + } + return resp, nil +} + +// ListResetBatches 分页查询二次实名批次(不含逐行结果)。 +func (q *Query) ListResetBatches(ctx context.Context, req dto.RealnameResetBatchListRequest) ([]dto.RealnameResetBatchResponse, int64, int, int, error) { + if err := requirePlatform(ctx); err != nil { + return nil, 0, 0, 0, err + } + page, size := normalizePage(req.Page, req.PageSize) + base := q.db.WithContext(ctx).Model(&model.IotCardRealnameResetBatch{}) + if req.BatchNo != "" { + base = base.Where("batch_no = ?", req.BatchNo) + } + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计二次实名批次失败") + } + var rows []model.IotCardRealnameResetBatch + if err := base.Omit("items").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询二次实名批次失败") + } + pending, err := q.pendingCounts(ctx, batchIDs(rows)) + if err != nil { + return nil, 0, 0, 0, err + } + list := make([]dto.RealnameResetBatchResponse, 0, len(rows)) + for _, row := range rows { + item := batchResponse(row) + item.PendingCount = pending[row.ID] + list = append(list, item) + } + return list, total, page, size, nil +} + +// GetResetBatch 查询二次实名批次详情,逐行结果按卡当前状态刷新为等待 / 已完成 / 已撤销。 +func (q *Query) GetResetBatch(ctx context.Context, id uint) (*dto.RealnameResetBatchResponse, error) { + if err := requirePlatform(ctx); err != nil { + return nil, err + } + var row model.IotCardRealnameResetBatch + if err := q.db.WithContext(ctx).Where("id = ?", id).Take(&row).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "二次实名批次不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询二次实名批次失败") + } + var items []dto.RealnameResetItem + if len(row.Items) > 0 { + if err := sonic.Unmarshal(row.Items, &items); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析二次实名批次结果失败") + } + } + finals, err := q.finalResults(ctx, row.ID) + if err != nil { + return nil, err + } + pending := 0 + for index := range items { + if items[index].Result == constants.RealnameResetItemPending { + if final, ok := finals[items[index].CardID]; ok { + items[index].Result = final + } else { + pending++ + } + } + items[index].ResultName = constants.RealnameResetItemName(items[index].Result) + } + resp := batchResponse(row) + resp.PendingCount = pending + resp.Items = items + return &resp, nil +} + +// finalResults 返回批次内已完成或已撤销的卡(以历史为准,同卡取最新一条)。 +func (q *Query) finalResults(ctx context.Context, batchID uint) (map[uint]string, error) { + var rows []model.IotCardRealnameHistory + if err := q.db.WithContext(ctx).Select("card_id", "change_type"). + Where("reset_batch_id = ? AND change_type IN ?", batchID, + []string{constants.RealnameChangeResetCompleted, constants.RealnameChangeResetReverted}). + Order("id").Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询二次实名结果失败") + } + result := make(map[uint]string, len(rows)) + for _, row := range rows { + if row.ChangeType == constants.RealnameChangeResetCompleted { + result[row.CardID] = constants.RealnameResetItemCompleted + } else { + result[row.CardID] = constants.RealnameResetItemReverted + } + } + return result, nil +} + +func (q *Query) pendingCounts(ctx context.Context, ids []uint) (map[uint]int, error) { + result := make(map[uint]int, len(ids)) + if len(ids) == 0 { + return result, nil + } + var rows []struct { + BatchID uint `gorm:"column:reset_batch_id"` + Count int `gorm:"column:count"` + } + if err := q.db.WithContext(ctx).Model(&model.IotCardRealname{}).Select("reset_batch_id, COUNT(*) AS count"). + Where("reset_batch_id IN ? AND reset_status = ?", ids, constants.RealnameResetStatusPending). + Group("reset_batch_id").Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计等待二次实名卡数失败") + } + for _, row := range rows { + result[row.BatchID] = row.Count + } + return result, nil +} + +func (q *Query) operatorNames(ctx context.Context, rows []model.IotCardRealnameHistory) (map[uint]string, error) { + ids := make([]uint, 0) + for _, row := range rows { + if row.OperatorID > 0 { + ids = append(ids, row.OperatorID) + } + } + result := make(map[uint]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + var accounts []model.Account + if err := q.db.WithContext(ctx).Select("id", "username").Where("id IN ?", ids).Find(&accounts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询操作人失败") + } + for _, account := range accounts { + result[account.ID] = account.Username + } + return result, nil +} + +func batchResponse(row model.IotCardRealnameResetBatch) dto.RealnameResetBatchResponse { + return dto.RealnameResetBatchResponse{ + ID: row.ID, BatchNo: row.BatchNo, Reason: row.Reason, ImportFileKey: row.ImportFileKey, + TotalCount: row.TotalCount, SucceededCount: row.SucceededCount, SkippedCount: row.SkippedCount, + CompletedCount: row.CompletedCount, RevertedCount: row.RevertedCount, + OperatorID: row.OperatorID, OperatorName: row.OperatorName, CreatedAt: row.CreatedAt, + } +} + +func batchIDs(rows []model.IotCardRealnameResetBatch) []uint { + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + return ids +} + +func requirePlatform(ctx context.Context) error { + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return nil + default: + return errors.New(errors.CodeForbidden, "仅平台账号可查看二次实名批次") + } +} + +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} + +func observationSourceName(source string) string { + switch source { + case constants.CardObservationSourcePolling: + return "轮询" + case constants.CardObservationSourceManualSync: + return "手动刷新" + case constants.CardObservationSourceManualOverride: + return "人工" + case constants.CardObservationSourceCarrierCallback: + return "运营商回调" + case constants.CardObservationSourceBusinessEvent: + return "业务观测" + case "migration": + return "上线迁移" + case "package_silence": + return "沉默期判定" + default: + return source + } +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index f93f9678..9beb51d6 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -154,6 +154,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PollingMonitoring != nil { registerPollingMonitoringRoutes(authGroup, handlers.PollingMonitoring, doc, basePath) } + if handlers.CardRealname != nil { + registerCardRealnameRoutes(authGroup, handlers.CardRealname, doc, basePath) + } if handlers.PollingAlert != nil { registerPollingAlertRoutes(authGroup, handlers.PollingAlert, doc, basePath) } diff --git a/internal/routes/card_realname.go b/internal/routes/card_realname.go new file mode 100644 index 00000000..fbf39d2c --- /dev/null +++ b/internal/routes/card_realname.go @@ -0,0 +1,38 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerCardRealnameRoutes(router fiber.Router, handler *admin.CardRealnameHandler, doc *openapi.Generator, basePath string) { + tags := []string{"卡实名记录"} + Register(router, doc, basePath, "GET", "/iot-cards/:id/realname-history", handler.CardHistory, RouteSpec{ + Summary: "查询单卡实名时间与历史", + Description: "current 为当前实名时间:上游返回 archiveTime 时以它为准(来源 upstream),否则为首次读到已实名的时间(first_observed)," + + "上线前已实名的卡沿用库里时间(migrated)。历史只在实名时间变化时记录,按时间倒序分页。卡按当前账号数据范围可见。", + Tags: tags, Input: new(dto.CardRealnameHistoryListRequest), Output: new(dto.CardRealnameHistoryResponse), Auth: true, + }) + Register(router, doc, basePath, "POST", "/realname-reset/batches", handler.ImportResetBatch, RouteSpec{ + Summary: "导入二次实名名单", + Description: "仅平台账号,需 iot_card:realname_reset 权限。资产号为 ICCID / 虚拟号 / IMEI,设备展开为其全部绑定卡,最多 1000 个。" + + "逐卡清除实名状态与实名时间;未实名、上游从未返回 archiveTime、已在等待的卡跳过并写明原因,不影响其他行。" + + "之后上游 archiveTime 晚于清除前时间才算二次实名完成。C 端按普通未实名展示。", + Tags: tags, Input: new(dto.RealnameResetImportRequest), Output: new(dto.RealnameResetBatchResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/realname-reset/batches", handler.ListResetBatches, RouteSpec{ + Summary: "二次实名批次列表", Description: "仅平台账号,按导入时间倒序分页。", + Tags: tags, Input: new(dto.RealnameResetBatchListRequest), Output: new(dto.RealnameResetBatchResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/realname-reset/batches/:id", handler.GetResetBatch, RouteSpec{ + Summary: "二次实名批次详情", Description: "仅平台账号;逐行结果按卡当前状态显示等待二次实名 / 已完成 / 已撤销 / 跳过 / 失败。", + Tags: tags, Input: new(dto.RealnameResetBatchIDRequest), Output: new(dto.RealnameResetBatchResponse), Auth: true, + }) + Register(router, doc, basePath, "POST", "/realname-reset/batches/:id/revert", handler.RevertResetBatch, RouteSpec{ + Summary: "撤销二次实名", Description: "仅平台账号,需 iot_card:realname_reset 权限。card_ids 为空时撤销整批仍在等待的卡;卡恢复已实名、实名时间恢复为清除前时间。", + Tags: tags, Input: new(dto.RealnameResetRevertRequest), Output: new(dto.RealnameResetRevertResponse), Auth: true, + }) +} diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 2fbfe7e8..5f9df108 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -16,6 +16,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset" + "github.com/break/junhong_cmp_fiber/internal/query/cardrealname" packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -247,14 +248,21 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. zap.Uints("card_ids", cardIDs), zap.Error(err)) } + currentRealname, realnameErr := cardrealname.LoadCurrent(ctx, s.db, cardIDs) + if realnameErr != nil { + return nil, realnameErr + } for _, c := range cards { + realnameAt, realnameSource := cardrealname.ResolveRealnameAt(currentRealname, c.ID, c.FirstRealnameAt) resp.Cards = append(resp.Cards, dto.BoundCardInfo{ CardID: c.ID, ICCID: c.ICCID, MSISDN: c.MSISDN, NetworkStatus: c.NetworkStatus, RealNameStatus: c.RealNameStatus, - RealNameAt: c.FirstRealnameAt, + RealNameAt: realnameAt, + RealNameAtSource: realnameSource, + RealNameAtSourceName: constants.RealnameAtSourceName(realnameSource), SlotPosition: slotMap[c.ID], IsCurrent: isCurrentMap[c.ID], CarrierID: c.CarrierID, @@ -288,11 +296,12 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. } // RealNameAt 取所有绑定卡中最早的实名时间 for _, c := range cards { - if c.FirstRealnameAt == nil { + realnameAt, _ := cardrealname.ResolveRealnameAt(currentRealname, c.ID, c.FirstRealnameAt) + if realnameAt == nil { continue } - if resp.RealNameAt == nil || c.FirstRealnameAt.Before(*resp.RealNameAt) { - t := *c.FirstRealnameAt + if resp.RealNameAt == nil || realnameAt.Before(*resp.RealNameAt) { + t := *realnameAt resp.RealNameAt = &t } } @@ -345,6 +354,11 @@ func (s *Service) resolveAssociatedPhones(ctx context.Context, assetType string, // buildCardResolveResponse 构建卡类型的资产解析响应 func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) { + currentRealname, err := cardrealname.LoadCurrent(ctx, s.db, []uint{card.ID}) + if err != nil { + return nil, err + } + realnameAt, realnameSource := cardrealname.ResolveRealnameAt(currentRealname, card.ID, card.FirstRealnameAt) resp := &dto.AssetResolveResponse{ AssetType: "card", AssetID: card.ID, @@ -359,7 +373,9 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, RealNameStatus: card.RealNameStatus, - RealNameAt: card.FirstRealnameAt, + RealNameAt: realnameAt, + RealNameAtSource: realnameSource, + RealNameAtSourceName: constants.RealnameAtSourceName(realnameSource), RealnamePolicy: card.RealnamePolicy, NetworkStatus: card.NetworkStatus, GatewayExtend: card.GatewayExtend, diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 6d067e19..40afecbb 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -1590,7 +1590,7 @@ func (s *Service) refreshCardDataFromGateway(ctx context.Context, iccid string, requestID := requestIDFromContext(ctx) decision, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ CardID: card.ID, - Verified: realnameResp.RealStatus, + Verified: realnameResp.RealStatus, UpstreamRealnameAt: realnameResp.UpstreamRealnameAt(), Metadata: carddomain.ObservationMetadata{ ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, diff --git a/internal/task/ingest_executor.go b/internal/task/ingest_executor.go index 6319ac47..7689b611 100644 --- a/internal/task/ingest_executor.go +++ b/internal/task/ingest_executor.go @@ -90,7 +90,7 @@ func (e *IngestExecutor) apply(ctx context.Context, msg IngestMessage) (bool, er return false, err case ingestKindRealname: _, err := e.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ - CardID: msg.CardID, Verified: msg.Realname.Verified, Metadata: msg.metadata(), + CardID: msg.CardID, Verified: msg.Realname.Verified, UpstreamRealnameAt: msg.Realname.UpstreamRealnameAt, Metadata: msg.metadata(), }) return false, err } diff --git a/internal/task/ingest_message.go b/internal/task/ingest_message.go index 1d81c709..80b79ff3 100644 --- a/internal/task/ingest_message.go +++ b/internal/task/ingest_message.go @@ -58,7 +58,8 @@ type IngestNetworkPayload struct { // IngestRealnamePayload 是实名观测内容。 type IngestRealnamePayload struct { - Verified bool `json:"verified"` + Verified bool `json:"verified"` + UpstreamRealnameAt *time.Time `json:"upstream_realname_at,omitempty"` } // metadata 还原观测元数据。 diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index ea96f5ec..503c4f7f 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -116,13 +116,13 @@ func (h *PollingRealnameHandler) Step(ctx context.Context, cardID uint) StepOutc Kind: ingestKindRealname, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID, ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling, Scene: constants.CardObservationSceneRealnamePolling, CorrelationID: attempt.IntegrationID, - TaskType: constants.TaskTypePollingRealname, Realname: &IngestRealnamePayload{Verified: result.RealStatus}, + TaskType: constants.TaskTypePollingRealname, Realname: &IngestRealnamePayload{Verified: result.RealStatus, UpstreamRealnameAt: result.UpstreamRealnameAt()}, }) { h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt)) return stepOK() } decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ - CardID: cardID, Verified: result.RealStatus, + CardID: cardID, Verified: result.RealStatus, UpstreamRealnameAt: result.UpstreamRealnameAt(), Metadata: carddomain.ObservationMetadata{ ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, Scene: constants.CardObservationSceneRealnamePolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, diff --git a/migrations/000275_add_card_realname_time.down.sql b/migrations/000275_add_card_realname_time.down.sql new file mode 100644 index 00000000..4e84f058 --- /dev/null +++ b/migrations/000275_add_card_realname_time.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS tb_iot_card_realname_reset_batch; +DROP TABLE IF EXISTS tb_iot_card_realname_history; +DROP TABLE IF EXISTS tb_iot_card_realname; diff --git a/migrations/000275_add_card_realname_time.up.sql b/migrations/000275_add_card_realname_time.up.sql new file mode 100644 index 00000000..dcd1156a --- /dev/null +++ b/migrations/000275_add_card_realname_time.up.sql @@ -0,0 +1,70 @@ +-- 卡实名时间与历史(add-realname-time-history) +-- tb_iot_card_realname:每卡一行,当前实名时间及来源、上游最近返回的 archiveTime、二次实名状态。 +-- tb_iot_card_realname_history:只在当前实名时间变化时追加。 +-- tb_iot_card_realname_reset_batch:二次实名导入批次。 + +CREATE TABLE IF NOT EXISTS tb_iot_card_realname ( + id BIGSERIAL PRIMARY KEY, + card_id BIGINT NOT NULL, + realname_at TIMESTAMPTZ, + realname_at_source VARCHAR(20) NOT NULL DEFAULT '', -- upstream | first_observed | migrated + upstream_realname_at TIMESTAMPTZ, -- 上游最近一次返回的 archiveTime + first_observed_at TIMESTAMPTZ, -- 首次读到已实名的时间 + reset_status VARCHAR(20) NOT NULL DEFAULT 'none', -- none | pending + reset_at TIMESTAMPTZ, + reset_baseline_at TIMESTAMPTZ, -- 清除前的上游实名时间 + reset_batch_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_iot_card_realname_card ON tb_iot_card_realname (card_id); +CREATE INDEX IF NOT EXISTS idx_iot_card_realname_reset ON tb_iot_card_realname (reset_batch_id) WHERE reset_status = 'pending'; + +CREATE TABLE IF NOT EXISTS tb_iot_card_realname_history ( + id BIGSERIAL PRIMARY KEY, + card_id BIGINT NOT NULL, + iccid VARCHAR(20) NOT NULL DEFAULT '', + before_at TIMESTAMPTZ, + after_at TIMESTAMPTZ, + after_source VARCHAR(20) NOT NULL DEFAULT '', + change_type VARCHAR(30) NOT NULL, + observation_source VARCHAR(30) NOT NULL DEFAULT '', + reset_batch_id BIGINT, + operator_id BIGINT NOT NULL DEFAULT 0, + observed_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_iot_card_realname_history_card ON tb_iot_card_realname_history (card_id, id DESC); + +CREATE TABLE IF NOT EXISTS tb_iot_card_realname_reset_batch ( + id BIGSERIAL PRIMARY KEY, + batch_no VARCHAR(40) NOT NULL, + import_file_key VARCHAR(500) NOT NULL DEFAULT '', + reason VARCHAR(500) NOT NULL DEFAULT '', + total_count INT NOT NULL DEFAULT 0, + succeeded_count INT NOT NULL DEFAULT 0, + skipped_count INT NOT NULL DEFAULT 0, + reverted_count INT NOT NULL DEFAULT 0, + completed_count INT NOT NULL DEFAULT 0, + items JSONB NOT NULL DEFAULT '[]', + operator_id BIGINT NOT NULL DEFAULT 0, + operator_name VARCHAR(100) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_iot_card_realname_reset_batch_no ON tb_iot_card_realname_reset_batch (batch_no); +CREATE INDEX IF NOT EXISTS idx_iot_card_realname_reset_batch_created ON tb_iot_card_realname_reset_batch (created_at DESC); + +-- 迁移补录:已实名且库里有实名时间的卡,沿用库里时间(来源 migrated),写一条迁移补录历史。可重复执行。 +INSERT INTO tb_iot_card_realname (card_id, realname_at, realname_at_source, first_observed_at) +SELECT c.id, c.first_realname_at, 'migrated', c.first_realname_at +FROM tb_iot_card c +WHERE c.deleted_at IS NULL AND c.real_name_status = 1 AND c.first_realname_at IS NOT NULL +ON CONFLICT (card_id) DO NOTHING; + +INSERT INTO tb_iot_card_realname_history (card_id, iccid, before_at, after_at, after_source, change_type, observation_source, observed_at) +SELECT r.card_id, c.iccid, NULL, r.realname_at, 'migrated', 'migrated', 'migration', NOW() +FROM tb_iot_card_realname r +JOIN tb_iot_card c ON c.id = r.card_id +WHERE r.realname_at_source = 'migrated' + AND NOT EXISTS (SELECT 1 FROM tb_iot_card_realname_history h WHERE h.card_id = r.card_id AND h.change_type = 'migrated'); diff --git a/openspec/changes/add-realname-time-history/tasks.md b/openspec/changes/add-realname-time-history/tasks.md index 18f2ec6d..92338c57 100644 --- a/openspec/changes/add-realname-time-history/tasks.md +++ b/openspec/changes/add-realname-time-history/tasks.md @@ -1,33 +1,33 @@ ## 1. 数据模型与迁移 -- [ ] 1.1 新增迁移:`tb_iot_card_realname`(card_id 唯一)、`tb_iot_card_realname_history`(索引 card_id,id desc)、`tb_iot_card_realname_reset_batch`,含 down 迁移 -- [ ] 1.2 新增 model 与常量(时间来源、变化类型、二次实名状态、权限码 `iot_card:realname_reset`),scan 结构显式 gorm column 标签 -- [ ] 1.3 迁移补录:已实名且 `first_realname_at` 非空的卡按 id 分批插入当前记录(migrated)与迁移历史,可重复执行;补测试验证幂等 +- [x] 1.1 新增迁移:`tb_iot_card_realname`(card_id 唯一)、`tb_iot_card_realname_history`(索引 card_id,id desc)、`tb_iot_card_realname_reset_batch`,含 down 迁移 +- [x] 1.2 新增 model 与常量(时间来源、变化类型、二次实名状态、权限码 `iot_card:realname_reset`),scan 结构显式 gorm column 标签 +- [x] 1.3 迁移补录:已实名且 `first_realname_at` 非空的卡按 id 分批插入当前记录(migrated)与迁移历史,可重复执行;补测试验证幂等 ## 2. 上游实名时间解析 -- [ ] 2.1 `RealnameStatusResp` 增加 `archiveTime`,解析为北京时间 `*time.Time`,null/空/格式错视为空;表驱动测试覆盖移动、联通、电信三种响应 -- [ ] 2.2 `RealnameObservation` 增加 `UpstreamRealnameAt`,轮询、观测序列、手动刷新入口透传 +- [x] 2.1 `RealnameStatusResp` 增加 `archiveTime`,解析为北京时间 `*time.Time`,null/空/格式错视为空;表驱动测试覆盖移动、联通、电信三种响应 +- [x] 2.2 `RealnameObservation` 增加 `UpstreamRealnameAt`,轮询、观测序列、手动刷新入口透传 ## 3. 当前实名时间与历史 -- [ ] 3.1 领域纯函数 `DecideRealnameTime`:上游优先 → 保持 → 首次读到;相同不写;未实名不写;二次实名新旧判定;表驱动测试覆盖 spec 全部场景 -- [ ] 3.2 `ApplyCardObservation` 事务内锁定 / 创建当前实名记录,按决定更新并追加历史;二次实名 pending 时拦截旧实名观测 +- [x] 3.1 领域纯函数 `DecideRealnameTime`:上游优先 → 保持 → 首次读到;相同不写;未实名不写;二次实名新旧判定;表驱动测试覆盖 spec 全部场景 +- [x] 3.2 `ApplyCardObservation` 事务内锁定 / 创建当前实名记录,按决定更新并追加历史;二次实名 pending 时拦截旧实名观测 - [ ] 3.3 集成测试:重复轮询不写历史、上游更正 migrated 时间、逆转后再实名不写历史 ## 4. 沉默期与展示切换 -- [ ] 4.1 `packagesilence/resolve.go` 卡实名时间改取当前实名时间;已实名无时间时写当前记录(first_observed)并写历史,不再补写 `first_realname_at` -- [ ] 4.2 资产详情、卡列表、导出"实名时间"改取当前实名时间;设备聚合口径不变 -- [ ] 4.3 新增 `GET /api/admin/iot-cards/:id/realname-history` 分页查询,返回来源与变化类型中文名 +- [x] 4.1 `packagesilence/resolve.go` 卡实名时间改取当前实名时间;已实名无时间时写当前记录(first_observed)并写历史(`first_realname_at` 照旧补写,供其他旧读取方兼容) +- [x] 4.2 资产详情、卡列表、导出"实名时间"改取当前实名时间;设备聚合口径不变 +- [x] 4.3 新增 `GET /api/admin/iot-cards/:id/realname-history` 分页查询,返回来源与变化类型中文名 - [ ] 4.4 回归测试:沉默期期内 / 截止后实名、已判定不重算 ## 5. 二次实名 -- [ ] 5.1 导入接口:解析 ICCID / 虚拟号 / IMEI(设备取全部绑定卡),逐行校验(不存在、未实名、上游从未返回 `archiveTime`、已在等待),形成批次与行结果 -- [ ] 5.2 清除执行:每卡独立事务,经人工纠偏通道置未实名,写 pending 基准与清除历史;不改 `first_realname_at`、不回退套餐 -- [ ] 5.3 撤销接口:等待中的卡恢复已实名与基准时间,写撤销历史 -- [ ] 5.4 批次列表、详情接口;路由注册、文档生成器 DTO 与权限 +- [x] 5.1 导入接口:解析 ICCID / 虚拟号 / IMEI(设备取全部绑定卡),逐行校验(不存在、未实名、上游从未返回 `archiveTime`、已在等待),形成批次与行结果 +- [x] 5.2 清除执行:每卡独立事务,经人工纠偏通道置未实名,写 pending 基准与清除历史;不改 `first_realname_at`、不回退套餐 +- [x] 5.3 撤销接口:等待中的卡恢复已实名与基准时间,写撤销历史 +- [x] 5.4 批次列表、详情接口;路由注册、文档生成器 DTO 与权限 - [ ] 5.5 测试:读到旧实名不覆盖、新实名完成、撤销、上游无 `archiveTime` 的卡被跳过、设备展开为全部卡;确认 C 端接口无二次实名字段 ## 6. 验证与部署 diff --git a/pkg/constants/card_realname.go b/pkg/constants/card_realname.go new file mode 100644 index 00000000..5756c0dc --- /dev/null +++ b/pkg/constants/card_realname.go @@ -0,0 +1,94 @@ +package constants + +// 卡当前实名时间来源。 +const ( + RealnameAtSourceUpstream = "upstream" // 上游返回的 archiveTime + RealnameAtSourceFirstObserved = "first_observed" // 首次读到已实名的时间 + RealnameAtSourceMigrated = "migrated" // 上线前库里已存的实名时间 +) + +// 卡实名时间历史变化类型。 +const ( + RealnameChangeFirst = "first" // 首次实名 + RealnameChangeUpstreamFix = "upstream_fix" // 上游实名时间更正 + RealnameChangeReset = "reset" // 二次实名清除 + RealnameChangeResetCompleted = "reset_completed" // 二次实名完成 + RealnameChangeResetReverted = "reset_reverted" // 二次实名撤销 + RealnameChangeMigrated = "migrated" // 迁移补录 +) + +// 二次实名状态。 +const ( + RealnameResetStatusNone = "none" + RealnameResetStatusPending = "pending" +) + +// 二次实名导入行结果。 +const ( + RealnameResetItemPending = "pending" // 已清除,等待二次实名 + RealnameResetItemSkipped = "skipped" // 跳过 + RealnameResetItemFailed = "failed" // 清除失败 + RealnameResetItemCompleted = "completed" // 已完成二次实名 + RealnameResetItemReverted = "reverted" // 已撤销 +) + +const ( + // RealnameResetBatchMaxRows 是二次实名单批最多导入行数。 + RealnameResetBatchMaxRows = 1000 + // RealnameResetBatchNoPrefix 是二次实名批次号前缀。 + RealnameResetBatchNoPrefix = "RRB" + // PermissionIotCardRealnameReset 是二次实名导入与撤销权限。 + PermissionIotCardRealnameReset = "iot_card:realname_reset" +) + +// RealnameAtSourceName 返回实名时间来源中文名。 +func RealnameAtSourceName(source string) string { + switch source { + case RealnameAtSourceUpstream: + return "上游返回" + case RealnameAtSourceFirstObserved: + return "首次读到" + case RealnameAtSourceMigrated: + return "迁移沿用" + default: + return "" + } +} + +// RealnameChangeTypeName 返回实名时间变化类型中文名。 +func RealnameChangeTypeName(changeType string) string { + switch changeType { + case RealnameChangeFirst: + return "首次实名" + case RealnameChangeUpstreamFix: + return "上游更正" + case RealnameChangeReset: + return "二次实名清除" + case RealnameChangeResetCompleted: + return "二次实名完成" + case RealnameChangeResetReverted: + return "二次实名撤销" + case RealnameChangeMigrated: + return "迁移补录" + default: + return changeType + } +} + +// RealnameResetItemName 返回二次实名行结果中文名。 +func RealnameResetItemName(result string) string { + switch result { + case RealnameResetItemPending: + return "等待二次实名" + case RealnameResetItemCompleted: + return "已完成二次实名" + case RealnameResetItemReverted: + return "已撤销" + case RealnameResetItemSkipped: + return "跳过" + case RealnameResetItemFailed: + return "失败" + default: + return result + } +}