Compare commits
4 Commits
0138a3fb9c
...
Iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f3a7741b | ||
|
|
4967f4df43 | ||
|
|
94f86ab217 | ||
|
|
76d0f38b9e |
@@ -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 {
|
||||
|
||||
177
internal/application/cardobservation/realname_reset.go
Normal file
177
internal/application/cardobservation/realname_reset.go
Normal file
@@ -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
|
||||
}
|
||||
96
internal/application/cardobservation/realname_time.go
Normal file
96
internal/application/cardobservation/realname_time.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
259
internal/application/realnamereset/service.go
Normal file
259
internal/application/realnamereset/service.go
Normal file
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 是应用层从持久化模型映射出的最小卡实名快照。
|
||||
|
||||
144
internal/domain/cardobservation/realname_time.go
Normal file
144
internal/domain/cardobservation/realname_time.go
Normal file
@@ -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
|
||||
}
|
||||
149
internal/domain/cardobservation/realname_time_test.go
Normal file
149
internal/domain/cardobservation/realname_time_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 是实名认证链接的响应
|
||||
|
||||
25
internal/gateway/upstream_time.go
Normal file
25
internal/gateway/upstream_time.go
Normal file
@@ -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
|
||||
}
|
||||
42
internal/gateway/upstream_time_test.go
Normal file
42
internal/gateway/upstream_time_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
128
internal/handler/admin/card_realname.go
Normal file
128
internal/handler/admin/card_realname.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
114
internal/model/dto/card_realname_dto.go
Normal file
114
internal/model/dto/card_realname_dto.go
Normal file
@@ -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:"失败说明"`
|
||||
}
|
||||
67
internal/model/iot_card_realname.go
Normal file
67
internal/model/iot_card_realname.go
Normal file
@@ -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" }
|
||||
42
internal/query/cardrealname/current.go
Normal file
42
internal/query/cardrealname/current.go
Normal file
@@ -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, ""
|
||||
}
|
||||
269
internal/query/cardrealname/query.go
Normal file
269
internal/query/cardrealname/query.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
38
internal/routes/card_realname.go
Normal file
38
internal/routes/card_realname.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 还原观测元数据。
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
migrations/000275_add_card_realname_time.down.sql
Normal file
3
migrations/000275_add_card_realname_time.down.sql
Normal file
@@ -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;
|
||||
70
migrations/000275_add_card_realname_time.up.sql
Normal file
70
migrations/000275_add_card_realname_time.up.sql
Normal file
@@ -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');
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-08
|
||||
71
openspec/changes/add-realname-time-history/design.md
Normal file
71
openspec/changes/add-realname-time-history/design.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## Context
|
||||
|
||||
- 实名状态唯一写入口是 `internal/application/cardobservation/apply.go` 的 `ApplyCardObservation`(轮询、手动刷新、回调、业务观测、人工纠偏都汇到这里),领域判定在 `internal/domain/cardobservation/realname.go`。
|
||||
- 网关 `RealnameStatusResp` 只有 `iccid`、`realStatus`。测试环境 worker 日志抽样(11-08,约 4900 条实名响应):移动 15 返回 `archiveTime`(已实名必带,未实名为 null);联通 31/22/50/2 返回 `extend` 文本无时间;电信 10/4 只有 `iccid`、`realStatus`。
|
||||
- `first_realname_at` 由首次判为已实名时写入观测时间,被沉默期判定、资产展示、导出、首次实名激活使用。
|
||||
- 实名状态 `real_name_status` 被停复机、C 端、沉默期、导出等 40 余处读取。
|
||||
- 约束:不新增停复机路径;`enable_polling=false` 永不停复机;不查询 `tb_audit_event*` / `tb_integration_log`;只部署测试环境。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 记录准确的当前实名时间与来源,只在变化时写历史。
|
||||
- 支持移动卡二次实名,C 端无感。
|
||||
- 沉默期改用当前实名时间。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不迁移 `real_name_status`,不删除 `first_realname_at`。
|
||||
- 不为上游不返回 `archiveTime` 的卡做二次实名(无实名时间无法区分新旧)。
|
||||
- 不重算已判定的沉默期套餐。
|
||||
- 不改实名逆转阈值、停复机规则。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 三张新表,不给 `tb_iot_card` 加列
|
||||
|
||||
- `tb_iot_card_realname`:`card_id` 唯一;`realname_at`、`realname_at_source`、`upstream_realname_at`(最近一次上游返回)、`first_observed_at`;`reset_status`(none / pending)、`reset_at`、`reset_baseline_at`、`reset_batch_id`;时间戳。
|
||||
- `tb_iot_card_realname_history`:只追加;`card_id`、`iccid`、`before_at`、`after_at`、`after_source`、`change_type`、`observation_source`、`reset_batch_id`、`operator_id`、`observed_at`、`created_at`;索引 `(card_id, id desc)`。
|
||||
- `tb_iot_card_realname_reset_batch`:`batch_no`、`import_file_key`、`reason`、`total/succeeded/skipped/reverted`、`items`(jsonb 行结果:行号、输入值、卡 ID、结果、原因)、`operator_id`。
|
||||
|
||||
理由:卡表是轮询高频更新的大表,二次实名状态与时间独立成表可避免加宽卡表、减少行锁竞争,并让历史与当前值语义清晰。备选"卡表加列"改动小,但二次实名状态字段多,否决。每卡状态在 `tb_iot_card_realname` 上,批次表不再拆明细表。
|
||||
|
||||
### 2. 观测携带可选上游实名时间
|
||||
|
||||
`RealnameStatusResp` 增加 `ArchiveTime *string`(`json:"archiveTime"`),解析为 `*time.Time`(`Asia/Shanghai`),解析失败视为空并打 warn 日志。`domain.RealnameObservation` 增加 `UpstreamRealnameAt *time.Time`,所有调用网关实名查询的入口(`polling_realname_handler.go`、`series_runner.go`、`iot_card/service.go` 手动刷新)透传;回调、人工入口为空。
|
||||
|
||||
### 3. 实名时间在同一事务内决定
|
||||
|
||||
在 `ApplyCardObservation` 锁卡事务内,读取 / 创建 `tb_iot_card_realname` 行(`SELECT ... FOR UPDATE`),由领域函数 `DecideRealnameTime(snapshot, observation, decision)` 输出:是否更新 `realname_at`、新值与来源、是否写历史、变化类型、是否拦截本次已实名(二次实名)。纯函数便于表驱动测试。
|
||||
|
||||
二次实名拦截发生在 `ApplyRealname` 之前:卡处于 pending 且观测为已实名时,若 `upstream > baseline` 则照常走已实名判定(StatusChanged=true,FirstVerified=false,因 `first_realname_at` 非空),否则把观测降级为"不改状态",只更新 `last_real_name_check_at`。
|
||||
|
||||
### 4. 迁移补录用 SQL 一次性插入
|
||||
|
||||
迁移文件中 `INSERT INTO tb_iot_card_realname (...) SELECT ... FROM tb_iot_card WHERE real_name_status=1 AND deleted_at IS NULL ON CONFLICT (card_id) DO NOTHING`,同时按 `first_realname_at IS NOT NULL` 插入 migrated 历史(用 `NOT EXISTS` 防重复)。数据量大时分批(按 id 区间)执行,避免长事务。
|
||||
|
||||
### 5. 沉默期与展示切换读取源
|
||||
|
||||
`packagesilence/resolve.go` 的 `realnameFacts` 改为 `LEFT JOIN tb_iot_card_realname` 取 `realname_at`;"已实名但时间为空"的补写改写 `tb_iot_card_realname`(来源 first_observed,写历史)而不是 `first_realname_at`。资产详情、卡列表、导出的"实名时间"同样改取 `realname_at`。只对上线后的判定生效,不做回算任务。
|
||||
|
||||
### 6. 二次实名执行
|
||||
|
||||
导入接口同步解析与校验(≤1000 行),逐卡独立事务执行清除;清除复用 `ApplyCardObservation` 的人工纠偏通道(Source=manual_override,置未实名)以保持实名变化事件、审计与停复机评估一致,并在同事务写 `tb_iot_card_realname` 的 pending 字段与历史。是否支持只看该卡 `upstream_realname_at` 是否非空(上游返回过 `archiveTime`),不按运营商写死;基准取 `upstream_realname_at`。
|
||||
|
||||
### 7. 权限与接口
|
||||
|
||||
- `GET /api/admin/iot-cards/:id/realname-history`(复用资产详情查看权限)。
|
||||
- `POST /api/admin/realname-reset/batches`(导入)、`GET /api/admin/realname-reset/batches`、`GET /api/admin/realname-reset/batches/:id`、`POST /api/admin/realname-reset/batches/:id/revert`(撤销整批或指定卡);新增权限码 `iot_card:realname_reset`。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [清除后实名管控账户会停机] → 用户已确认按普通未实名处理;测试环境网关只读,不会真实下发。
|
||||
- [上游 `archiveTime` 时区或格式变化] → 解析失败视为空,不影响状态,日志告警。
|
||||
- [迁移补录数据量大] → 分批插入、可重复执行。
|
||||
- [当前实名时间改为上游时间后,个别沉默期新判定结果与旧口径不同] → 用户确认采用新口径,已判定的不重算。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 部署迁移:建 3 张表 → 分批补录当前实名记录与迁移历史。
|
||||
2. 部署 api / worker:开始解析 `archiveTime` 并写当前记录与历史;沉默期与展示切换读取源。
|
||||
3. 管理端前端上线实名记录与二次实名页面,分配权限 `iot_card:realname_reset`。
|
||||
4. 回滚:回滚 api / worker 镜像即可恢复旧读取(`first_realname_at` 一直保留且仍按旧规则写入);新表可保留。
|
||||
37
openspec/changes/add-realname-time-history/proposal.md
Normal file
37
openspec/changes/add-realname-time-history/proposal.md
Normal file
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
卡上的 `first_realname_at` 是系统轮询"第一次发现已实名"的时间,不是用户真实实名时间(如卡 89860885192590574640:上游实名 2026-07-16 19:43:39,库里记 20:03:25)。移动的实名查询其实已返回实名时间 `archiveTime`,但网关响应结构没解析;同时实名没有任何可查的历史。另外部分卡因上游原因需要二次实名(后一次实名覆盖前一次),C 端对已实名卡直接拒绝发放实名链接,无法办理,也无法区分上游返回的是旧实名还是新实名。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 解析网关实名响应的 `archiveTime`,返回了就以它为准;没返回的一律按"无实名时间"的规则处理。
|
||||
- 新增「卡当前实名记录」表(每卡一行):当前实名时间及来源(上游返回 / 首次读到 / 迁移沿用)、上游最近返回时间、首次读到已实名时间、二次实名状态与基准。
|
||||
- 新增「卡实名时间历史」表:只在当前实名时间变化时追加一条,不是每次读到都写。
|
||||
- 实名时间口径:有上游时间用上游时间;没有则用首次读到已实名的时间;已实名的老卡沿用库里现存实名时间(迁移补录);已实名但库里无时间的卡留空,等下次读到再填。
|
||||
- 新增二次实名:管理端导入 ICCID / 虚拟号 / IMEI(设备则其全部绑定卡)形成批次,仅支持上游返回过 `archiveTime` 的卡(以 `archiveTime` 为准,不按运营商区分);系统清除卡实名状态与当前实名时间并记下清除前的上游实名时间;之后上游返回的实名时间不晚于清除前时间时不覆盖,晚于时才算二次实名完成。C 端按普通未实名展示,不提示"二次实名"。
|
||||
- 等待二次实名的卡按普通未实名卡处理:实名管控账户下按现有实名停机逻辑执行(关闭轮询的卡仍任何入口都不停复机),不新增停复机路径。
|
||||
- 沉默期判定与资产实名时间展示改用当前实名时间;已判定过的套餐不重算。
|
||||
- 管理端资产详情展示当前实名时间及来源、实名时间历史;二次实名批次导入与查询。
|
||||
- `real_name_status` 保留在卡表,不迁移;`first_realname_at` 保留不删。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `card-realname-time`: 卡当前实名时间与来源、实名时间历史、上游实名时间解析与写入规则、老数据迁移补录、管理端实名记录展示。
|
||||
- `card-realname-reset`: 二次实名批次导入、清除、新旧实名判定与完成、运营商支持范围、C 端无感展示、停机口径。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-silence-period`(本迭代 `add-package-silence-period` 引入,尚未归档):沉默期"实名与过期判定"中的卡实名时间改取当前实名时间。以 ADDED 需求补充,不修改未归档变更的原文。
|
||||
|
||||
## Impact
|
||||
|
||||
- 数据库:新增 3 张表(当前实名记录、实名时间历史、二次实名批次)与迁移补录脚本;`tb_iot_card` 不加列。
|
||||
- 网关:`internal/gateway/models.go` 实名响应结构新增 `archiveTime`。
|
||||
- 卡观测:`internal/domain/cardobservation/realname.go`、`internal/application/cardobservation/apply.go` 增加实名时间判定与二次实名基准比较。
|
||||
- 沉默期:`internal/application/packagesilence/resolve.go` 实名时间来源切换。
|
||||
- 资产查询 / 导出:`internal/service/asset/service.go`、`internal/handler/admin/asset.go`、`internal/exporter/iot_card_scene.go` 实名时间字段来源切换。
|
||||
- 新增管理端接口:资产实名记录查询、二次实名批次导入 / 列表 / 详情;新增权限码。
|
||||
- 管理端前端(`iteration/8-11`):资产详情"实名记录"、二次实名批次页面(另行在前端仓库落地)。
|
||||
- 只部署测试环境;测试环境网关只读开关保持开启。
|
||||
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 二次实名批次导入
|
||||
|
||||
有 `iot_card:realname_reset` 权限的运营 SHALL 可导入二次实名名单(xlsx / csv,一列:ICCID、虚拟号或 IMEI,单批最多 1000 行),填写原因后提交形成一个二次实名批次。系统逐行解析:ICCID 直接定位卡;虚拟号或 IMEI 定位设备,取设备当前全部绑定卡。以下行 MUST 跳过并记录原因,不影响其他行:资产不存在、卡未实名、该卡上游从未返回过实名时间 `archiveTime`(无法区分新旧实名,不支持二次实名)、卡已处于等待二次实名。是否支持只以上游是否返回过 `archiveTime` 为准,不按运营商写死。批次记录导入文件、总条数、成功数、跳过数及每行结果、操作人与时间。
|
||||
|
||||
#### Scenario: 导入设备
|
||||
|
||||
- **WHEN** 导入一台绑定 3 张移动已实名卡的设备 IMEI
|
||||
- **THEN** 3 张卡都进入等待二次实名
|
||||
|
||||
#### Scenario: 导入不支持的运营商
|
||||
|
||||
- **WHEN** 名单中有一张上游从未返回过 `archiveTime` 的已实名卡
|
||||
- **THEN** 该行跳过,原因"上游未返回实名时间,不支持二次实名",其他行照常处理
|
||||
|
||||
### Requirement: 二次实名清除
|
||||
|
||||
批次中每张合格卡 SHALL 在独立事务内执行清除:将卡 `real_name_status` 置为未实名;当前实名记录的二次实名状态置为"等待二次实名",记录清除时间、所属批次与清除前实名时间(基准);当前实名时间置空;写一条"二次实名清除"历史;按现有实名状态变化流程发出实名变化事件。已激活的套餐 MUST NOT 回退;`first_realname_at` MUST NOT 修改。
|
||||
|
||||
#### Scenario: 清除后资产显示未实名
|
||||
|
||||
- **WHEN** 卡(实名时间 07-16 19:43:39)被二次实名清除
|
||||
- **THEN** 卡显示未实名、实名时间为空,历史有一条清除记录(原 07-16 19:43:39 → 空),已生效套餐不受影响
|
||||
|
||||
### Requirement: 二次实名新旧判定
|
||||
|
||||
卡处于等待二次实名时,读到"已实名"观测 SHALL 以上游 `archiveTime` 为准判定:上游实名时间晚于基准时间(清除前的上游实名时间) → 二次实名完成,卡置为已实名,当前实名时间取上游时间(来源 upstream),清除等待状态,写"二次实名完成"历史;上游实名时间不晚于基准时间、或未返回上游实名时间 → MUST NOT 改实名状态、MUST NOT 写当前实名时间与历史,仅更新最近检查时间。卡处于等待二次实名时读到未实名观测按现有逻辑处理(保持未实名)。
|
||||
|
||||
#### Scenario: 读到旧实名
|
||||
|
||||
- **WHEN** 基准 07-16 19:43:39,清除后轮询返回 `realStatus=true, archiveTime=2026-07-16 19:43:39`
|
||||
- **THEN** 卡仍为未实名,不写历史
|
||||
|
||||
#### Scenario: 用户完成二次实名
|
||||
|
||||
- **WHEN** 基准 07-16 19:43:39,用户 11-09 10:00 重新实名,轮询返回 `archiveTime=2026-11-09 10:00:00`
|
||||
- **THEN** 卡变为已实名,当前实名时间 11-09 10:00:00,写一条二次实名完成历史,触发现有实名后续流程(沉默期判定、激活等)
|
||||
|
||||
### Requirement: 二次实名撤销
|
||||
|
||||
有 `iot_card:realname_reset` 权限的运营 SHALL 可对仍处于等待二次实名的卡撤销(单张或整批):卡恢复为已实名、当前实名时间恢复为基准时间,清除等待状态,写"二次实名撤销"历史。已完成二次实名的卡不能撤销。
|
||||
|
||||
#### Scenario: 导错撤销
|
||||
|
||||
- **WHEN** 运营撤销一张等待二次实名的卡
|
||||
- **THEN** 卡恢复已实名、实名时间恢复为清除前时间
|
||||
|
||||
### Requirement: C 端无感办理
|
||||
|
||||
C 端 MUST 按普通未实名卡展示等待二次实名的卡(显示"未实名"、正常发放实名链接),任何接口与页面 MUST NOT 出现"二次实名"相关字样或标记。
|
||||
|
||||
#### Scenario: 用户进入 C 端
|
||||
|
||||
- **WHEN** 等待二次实名卡的用户进入 H5 首页
|
||||
- **THEN** 显示未实名,可点击去实名,与普通未实名卡一致
|
||||
|
||||
### Requirement: 等待二次实名卡的停复机口径
|
||||
|
||||
等待二次实名的卡 SHALL 与普通未实名卡同等参与现有实名停复机判定:所属运营商账户开启实名管控且套餐有效时按现有逻辑停机。系统 MUST NOT 为二次实名新增停复机路径;`enable_polling = false` 的卡 MUST NOT 被任何入口停机或复机。测试环境网关只读开关保持开启。
|
||||
|
||||
#### Scenario: 实名管控账户下的二次实名卡
|
||||
|
||||
- **WHEN** 卡所属账户开启实名管控、套餐有效,卡被二次实名清除
|
||||
- **THEN** 按现有未实名停机逻辑处理,与普通未实名卡一致
|
||||
|
||||
#### Scenario: 关闭轮询的卡
|
||||
|
||||
- **WHEN** 被清除的卡 `enable_polling = false`
|
||||
- **THEN** 不发起任何停机或复机
|
||||
@@ -0,0 +1,91 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 上游实名时间解析
|
||||
|
||||
系统 SHALL 解析网关实名查询响应中的 `archiveTime`(格式 `YYYY-MM-DD HH:mm:ss`,按北京时间解释)作为上游实名时间。字段缺失、为 null、为空串或无法解析时 MUST 视为"上游未返回实名时间",不得报错、不得影响实名状态判定。`realStatus = false` 时即使返回了 `archiveTime` 也 MUST 忽略。
|
||||
|
||||
#### Scenario: 移动返回实名时间
|
||||
|
||||
- **WHEN** 网关返回 `{"archiveTime":"2026-07-16 19:43:39","iccid":"89860885192590574640","realStatus":true}`
|
||||
- **THEN** 本次观测的上游实名时间为 2026-07-16 19:43:39(+08:00)
|
||||
|
||||
#### Scenario: 联通、电信不返回实名时间
|
||||
|
||||
- **WHEN** 网关返回 `{"iccid":"8986112520903884400","realStatus":true}` 或带 `extend` 但无 `archiveTime`
|
||||
- **THEN** 本次观测没有上游实名时间,实名状态照常按 `realStatus` 判定
|
||||
|
||||
#### Scenario: 未实名时的空实名时间
|
||||
|
||||
- **WHEN** 网关返回 `{"archiveTime":null,"realStatus":false}`
|
||||
- **THEN** 不产生上游实名时间,按未实名观测处理
|
||||
|
||||
### Requirement: 卡当前实名记录
|
||||
|
||||
系统 SHALL 为每张卡维护至多一条当前实名记录,包含:当前实名时间、时间来源(`upstream` 上游返回 / `first_observed` 首次读到已实名 / `migrated` 迁移沿用库里时间)、上游最近一次返回的实名时间、首次读到已实名的时间、二次实名状态及基准(见 `card-realname-reset`)。卡的实名状态 `real_name_status` MUST 继续保存在卡表,当前实名记录不替代它。
|
||||
|
||||
#### Scenario: 查询卡当前实名时间
|
||||
|
||||
- **WHEN** 管理端查询一张已实名卡
|
||||
- **THEN** 返回当前实名时间与来源;无当前实名记录或时间为空时实名时间返回空
|
||||
|
||||
### Requirement: 当前实名时间的确定与更新
|
||||
|
||||
卡读到"已实名"观测(轮询、手动刷新、运营商回调、业务观测)并被采纳为已实名时,系统 SHALL 按以下顺序确定实名时间:
|
||||
|
||||
1. 观测带上游实名时间 `archiveTime` → 一律以它为准,来源 `upstream`;
|
||||
2. 否则当前记录已有实名时间 → 保持不变;
|
||||
3. 否则 → 取本次观测时间(首次读到已实名),来源 `first_observed`。
|
||||
|
||||
只有确定出的实名时间与当前实名时间不同时,系统 MUST 在同一事务内更新当前实名记录并追加一条实名时间历史;相同则 MUST NOT 写当前记录的实名时间、MUST NOT 写历史。上游最近返回时间可随观测更新但不产生历史。卡被判定为未实名(含连续逆转、人工改未实名)时当前实名时间保持不变,不写历史;之后再次被判定为已实名时按上述顺序重新确定,变化才写历史。
|
||||
|
||||
#### Scenario: 每次轮询读到相同实名时间
|
||||
|
||||
- **WHEN** 移动卡当前实名时间为 2026-07-16 19:43:39(来源 upstream),本次轮询上游仍返回 2026-07-16 19:43:39
|
||||
- **THEN** 不更新当前实名时间,不写历史
|
||||
|
||||
#### Scenario: 上游更正迁移时间
|
||||
|
||||
- **WHEN** 移动卡当前实名时间为迁移沿用的 2026-07-16 20:03:25(来源 migrated),本次轮询上游返回 2026-07-16 19:43:39
|
||||
- **THEN** 当前实名时间更新为 19:43:39、来源改为 upstream,并写一条历史(原 20:03:25 → 新 19:43:39)
|
||||
|
||||
#### Scenario: 不返回时间的运营商首次实名
|
||||
|
||||
- **WHEN** 电信卡此前未实名、无当前实名时间,本次轮询于 11-08 10:00 首次读到已实名
|
||||
- **THEN** 当前实名时间为 11-08 10:00、来源 first_observed,写一条历史;之后每次读到已实名都不再写
|
||||
|
||||
#### Scenario: 逆转为未实名后再次实名
|
||||
|
||||
- **WHEN** 电信卡当前实名时间为 09-01 10:00,被连续逆转判定为未实名,之后 11-08 12:00 再次读到已实名
|
||||
- **THEN** 逆转时不写历史;再次实名时当前实名时间仍为 09-01 10:00(规则 2),不写历史
|
||||
|
||||
### Requirement: 实名时间历史
|
||||
|
||||
系统 SHALL 在当前实名时间发生变化时追加一条实名时间历史,记录:卡、ICCID、原实名时间、新实名时间、新时间来源、变化类型(首次实名 / 上游更正 / 二次实名清除 / 二次实名完成 / 二次实名撤销 / 迁移补录)、观测来源(轮询 / 手动刷新 / 回调 / 业务观测 / 人工 / 批次)、关联二次实名批次、操作人(人工时)、发现时间。历史 MUST 只追加不修改。
|
||||
|
||||
#### Scenario: 历史条数不随轮询增长
|
||||
|
||||
- **WHEN** 一张卡实名后被轮询 1000 次,上游实名时间始终相同
|
||||
- **THEN** 该卡只有首次实名(或迁移补录)对应的历史,不随轮询增加
|
||||
|
||||
### Requirement: 实名时间老数据迁移
|
||||
|
||||
上线时系统 SHALL 对 `real_name_status = 已实名` 的卡补录当前实名记录:库里 `first_realname_at` 不为空的,当前实名时间取该值、来源 `migrated`,并写一条"迁移补录"历史;`first_realname_at` 为空的,当前实名时间留空、不写历史,等之后读到已实名时按"当前实名时间的确定与更新"规则填写。迁移 MUST 可重复执行且不产生重复记录。
|
||||
|
||||
#### Scenario: 已实名有时间的老卡
|
||||
|
||||
- **WHEN** 卡已实名、`first_realname_at = 2026-07-16 20:03:25`
|
||||
- **THEN** 当前实名时间为 20:03:25、来源 migrated,有一条迁移补录历史
|
||||
|
||||
#### Scenario: 已实名无时间的老卡
|
||||
|
||||
- **WHEN** 卡已实名、`first_realname_at` 为空,之后移动轮询返回上游时间 2026-08-01 09:00:00
|
||||
- **THEN** 迁移时不写;轮询后当前实名时间为 2026-08-01 09:00:00、来源 upstream,写一条历史
|
||||
|
||||
### Requirement: 资产实名时间展示
|
||||
|
||||
管理端资产详情、卡列表与导出中的"实名时间" SHALL 取当前实名时间;设备的实名时间沿用现有聚合口径(已绑定且已实名卡中 `max(卡实名时间, 绑定时间)` 的最小值),其中卡实名时间改取当前实名时间。管理端 SHALL 提供按卡查询实名时间历史的接口(分页、按时间倒序),资产详情展示当前实名时间、来源与历史。
|
||||
|
||||
#### Scenario: 资产详情查看实名记录
|
||||
|
||||
- **WHEN** 运营打开卡 89860885192590574640 的资产详情
|
||||
- **THEN** 看到当前实名时间、来源(上游返回 / 首次读到 / 迁移沿用),以及实名时间变化历史
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 沉默期判定使用当前实名时间
|
||||
|
||||
沉默期"实名与过期判定"中卡的实名时间 SHALL 取卡当前实名记录的当前实名时间(见 `card-realname-time`),不再取 `first_realname_at`;设备聚合口径不变,其中卡实名时间同样取当前实名时间。卡已实名但当前实名时间为空时 MUST 按原规则视为期内已实名,并以判定时间作为首次读到时间写入当前实名记录(来源 `first_observed`,写历史)。本规则只作用于上线后发生的判定;已经是 `ended` 或 `expired` 的套餐 MUST NOT 因实名时间来源变化而重算。
|
||||
|
||||
#### Scenario: 上游实名时间早于发现时间
|
||||
|
||||
- **WHEN** 套餐截止日 11-08 23:59:59,移动卡上游实名时间 11-08 23:50,系统 11-09 00:10 才读到
|
||||
- **THEN** 判定为期内已实名,沉默期结束
|
||||
|
||||
#### Scenario: 已判定的套餐不重算
|
||||
|
||||
- **WHEN** 某套餐上线前已按旧口径标记为 `expired`,上线后其卡的当前实名时间被上游更正为截止日之前
|
||||
- **THEN** 该套餐保持 `expired`,不自动改判
|
||||
38
openspec/changes/add-realname-time-history/tasks.md
Normal file
38
openspec/changes/add-realname-time-history/tasks.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## 1. 数据模型与迁移
|
||||
|
||||
- [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. 上游实名时间解析
|
||||
|
||||
- [x] 2.1 `RealnameStatusResp` 增加 `archiveTime`,解析为北京时间 `*time.Time`,null/空/格式错视为空;表驱动测试覆盖移动、联通、电信三种响应
|
||||
- [x] 2.2 `RealnameObservation` 增加 `UpstreamRealnameAt`,轮询、观测序列、手动刷新入口透传
|
||||
|
||||
## 3. 当前实名时间与历史
|
||||
|
||||
- [x] 3.1 领域纯函数 `DecideRealnameTime`:上游优先 → 保持 → 首次读到;相同不写;未实名不写;二次实名新旧判定;表驱动测试覆盖 spec 全部场景
|
||||
- [x] 3.2 `ApplyCardObservation` 事务内锁定 / 创建当前实名记录,按决定更新并追加历史;二次实名 pending 时拦截旧实名观测
|
||||
- [x] 3.3 集成测试:重复轮询不写历史、上游更正 migrated 时间、逆转后再实名不写历史
|
||||
|
||||
## 4. 沉默期与展示切换
|
||||
|
||||
- [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. 二次实名
|
||||
|
||||
- [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. 验证与部署
|
||||
|
||||
- [x] 6.1 `go build ./...`、`go vet`、相关包测试通过
|
||||
- [ ] 6.2 部署测试环境(网关只读保持开启),用 89860885192590574640 验证当前实名时间被更正为 2026-07-16 19:43:39 且只写一条历史
|
||||
- [ ] 6.3 测试环境演练二次实名清除与撤销,确认 C 端显示未实名、无二次实名字样
|
||||
- [x] 6.4 更新 `docs/` 前端对接说明(实名记录、二次实名接口与权限)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-08
|
||||
63
openspec/changes/add-silence-batch-extension/design.md
Normal file
63
openspec/changes/add-silence-batch-extension/design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
- 现有特殊延期 `SubmitSpecial`(`internal/application/packagesilence/extension_submit.go`)输入为 `items[{package_usage_id, days}]`,`validateSpecialItems` 只允许 `expired` 套餐,任一行不合格整批拒绝;审批字段只带首条资产与 `item_count`。
|
||||
- 执行 `executeBatch` → `executeExtensionInTx` 逐条独立事务;校验套餐仍为 `expired`。
|
||||
- 批次表已有 `import_file_key`、计数字段;审批附件由 `ApprovalAttachmentUploader` 从对象存储 Key 下载后上传企微。
|
||||
- 已有 `excelize`(`pkg/utils/excel.go`)与对象存储服务。
|
||||
- 管理端批量入口在 `SilenceExtendDialog.vue`(CSV 填 `package_usage_id,days`),无批次页面。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** 按资产号、统一天数的批量特殊延期;预检与部分提交;待实名套餐过期前延期;> 5 个资产的审批 xlsx 附件;批次查询。
|
||||
|
||||
**Non-Goals:** 不改在线付费、线下付费延期;不改单资产特殊延期接口;不改审批回调分发;批量不支持线下付费延期。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 新接口而非改造旧接口
|
||||
|
||||
新增 `POST /api/admin/package-silence/batch-extensions/precheck` 与 `POST /api/admin/package-silence/batch-extensions`,入参 `{identifiers[], days, extension_type, reason, basis, evidence_refs, amount, 线下收款信息..., import_file_key}`。提交内部把可延期明细转换为现有 `specialRow` 后复用批次创建、审批创建、执行流程。旧 `SubmitSpecial` 保持兼容(单资产与旧前端)。理由:输入模型(资产号 + 统一天数)与旧接口(套餐 ID + 逐行天数)不同,改旧接口会破坏现有单资产弹窗。
|
||||
|
||||
### 2. 资产展开
|
||||
|
||||
复用资产标识解析(`asset_identifier_store` / `ResolveIdentifier`):ICCID → 卡,卡有有效设备绑定则换成设备;虚拟号 / IMEI → 设备。以 `(asset_type, asset_id)` 去重。再查询资产名下 `deleted_at IS NULL AND silence_status IN ('expired','waiting')` 的套餐。
|
||||
|
||||
### 3. 预检复用资格计算,放开 waiting
|
||||
|
||||
把 `validateSpecialItems` 的逐行判定抽成 `checkSpecialUsage(usage, days, free, allowWaiting)`,批量路径 `allowWaiting=true`;返回结构化原因而不是累积错误。预检与提交共用同一函数,提交时服务端重跑,保证与预检一致或更严格(期间状态变化以提交时为准)。
|
||||
|
||||
### 4. 过期前延期执行
|
||||
|
||||
明细增加来源沉默期状态快照 `source_silence_status`。`executeExtensionInTx` 的状态校验从"必须 expired"改为"必须 expired,或明细来源为 waiting 且当前仍为 waiting/expired";新截止日统一 `SilenceDeadline(当前截止日期, 延长天数)`;`waiting` 套餐延期后状态不变(不调用激活),`expired` 套餐沿用"改为 waiting 并重新判定"。`ended`、失效、退款 → 该条失败。
|
||||
|
||||
### 5. 明细 xlsx 生成时机与存储
|
||||
|
||||
资产数 > 5 时,在业务事务之前用 excelize 生成 xlsx,上传对象存储(Key 前缀 `silence/batch-detail/{batch_no}.xlsx`),批次新增 `detail_file_key` 记录。审批字段新增 `ApprovalFieldSilenceDetailKeys = "detail_file_keys"`(FileList),在 `scene.go` 沉默期业务字段中登记(非必须映射)。上传失败直接拒绝提交,不建批次。事务内不调用外部接口,符合现有约束。≤ 5 时 `asset_identifier` 字段写全部资产号(顿号分隔)。
|
||||
|
||||
未映射"延期明细"时附注批次号到原因:由表单构建阶段判断映射是否存在不便,改为提交时总是在原因后附注"(批次 {batch_no},共 N 个资产)"于 > 5 场景,规则简单且无副作用。
|
||||
|
||||
### 6. 跳过明细存储
|
||||
|
||||
批次新增 `skipped_items jsonb`(资产号、资产类型、套餐、原因)与 `skipped_count`;详情接口返回。
|
||||
|
||||
### 7. 管理端
|
||||
|
||||
新增「批量延期」弹窗(入口:沉默期管理 / 延期批次页):填写统一信息 → 选择 xlsx / csv(一列资产号,前端解析,≤1000)→ 调预检展示可 / 不可延期表 → 确认提交。新增「延期批次」列表与详情页,详情可下载明细 xlsx(`detail_file_key` 签名下载,≤ 5 个资产时由后端即时生成)。旧弹窗中的 CSV 批量入口移除。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [过期前延期改变既有"只能延期已过期"规则] → 仅批量特殊延期路径放开,单资产与付费延期不变;明细记录来源状态便于审计。
|
||||
- [预检与提交之间状态变化] → 提交时服务端重跑,以提交时结果为准,前端提示差异条数。
|
||||
- [1000 个资产展开后套餐数更多] → 明细上限按套餐 3000 条控制,超出拒绝并提示拆分。
|
||||
- [企微附件大小] → 1000 资产 xlsx 约数十 KB,远低于限制。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 迁移:批次表加 `detail_file_key`、`skipped_items`、`skipped_count`;明细表加 `source_silence_status`(默认 `expired`)。
|
||||
2. 部署后端;企微模板加"延期明细"附件控件,管理端场景配置补映射(可后补)。
|
||||
3. 部署管理端前端。
|
||||
4. 回滚:回滚镜像;新增字段有默认值,不影响旧逻辑。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 批量是否需要按代理 / 店铺范围限制可延期资产(目前沿用延期权限的数据范围)。
|
||||
32
openspec/changes/add-silence-batch-extension/proposal.md
Normal file
32
openspec/changes/add-silence-batch-extension/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
现有批量特殊延期藏在单个资产的延期弹窗里,导入 CSV 要填系统内部的 `package_usage_id` 且每行单独填天数,运营无法使用;名单有一条不合格就整批拒绝;企微审批单只显示首条资产和条数,审批人看不到 100 个资产的明细;管理端也没有批次查询页面。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 管理端新增独立的「批量延期」弹窗:延期天数、延期类型、原因、依据、附件只填一次,所有资产统一天数。
|
||||
- 导入文件只有一列:ICCID / 虚拟号 / IMEI;ICCID 对应卡绑定在设备上时按设备处理,同一资产重复只算一次。
|
||||
- 资产名下所有处于沉默期的套餐(已过期、待实名)全部延期;**新增"过期前延期"**:待实名套餐也可通过特殊延期把截止日延后(原截止日 + 天数),状态保持待实名。
|
||||
- 新增预检:逐资产逐套餐列出可延期 / 不可延期及原因、原截止日、新截止日;提交时只提交可延期的,不可延期的记入批次结果(不再整批拒绝)。
|
||||
- 企微审批:资产数 ≤ 5 时审批单"资产"列出全部资产号;> 5 时系统生成 xlsx 明细作为附件上传到审批单(新增业务字段"延期明细",模板增加附件控件);明细文件同时可在管理端下载。
|
||||
- 管理端新增延期批次列表与详情页:审批状态、成功 / 失败 / 跳过条数、逐条结果、下载明细。
|
||||
- 原有按 `package_usage_id` 的单资产特殊延期保持不变。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-silence-extension`(本迭代 `add-package-silence-period` 引入,尚未归档):新增按资产号的批量特殊延期、预检、过期前延期、审批明细附件、批次查询。以 ADDED 需求补充,不修改未归档变更的原文。
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端:`internal/application/packagesilence/`(新增批量资产解析、预检、过期前延期执行、明细 xlsx 生成)、审批字段常量与企微场景字段登记(`internal/application/wecom/scene.go`)、DTO 与路由。
|
||||
- 依赖:复用已引入的 `github.com/xuri/excelize/v2` 与对象存储;企微附件复用现有审批附件上传。
|
||||
- 数据库:延期批次增加明细文件 Key、跳过明细(jsonb)字段;延期明细允许来源状态为待实名。
|
||||
- 企微:沉默期审批模板新增"延期明细"附件控件;场景配置新增映射。
|
||||
- 管理端前端(`iteration/8-11`):批量延期弹窗、批次列表与详情页(另在前端仓库落地)。
|
||||
- 只部署测试环境。
|
||||
@@ -0,0 +1,88 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按资产号批量特殊延期
|
||||
|
||||
有 `package_silence:extend_batch` 权限的运营 SHALL 可按资产号提交批量特殊延期:一次填写延期类型(免费 / 优惠价 / 客诉补偿 / 系统异常补偿 / 特殊客户政策)、统一延期天数、原因、依据、申请附件(优惠价有收款时另填线下收款信息),资产名单为 ICCID / 虚拟号 / IMEI(单批最多 1000 个)。系统 MUST 按以下规则展开为套餐明细:
|
||||
|
||||
- ICCID 对应卡绑定在设备上时按设备处理;同一资产在名单中重复出现只算一次;
|
||||
- 资产名下 `silence_status` 为 `expired` 或 `waiting` 的套餐全部纳入;
|
||||
- 所有明细使用同一延期天数(按 30 天向上取整计费 / 延长)。
|
||||
|
||||
#### Scenario: ICCID 对应设备卡
|
||||
|
||||
- **WHEN** 名单中有设备 A 的一张绑定卡 ICCID,同时有设备 A 的 IMEI
|
||||
- **THEN** 设备 A 只算一个资产,其名下沉默期套餐只延期一次
|
||||
|
||||
#### Scenario: 一个资产多笔套餐
|
||||
|
||||
- **WHEN** 设备 B 有 1 笔已过期、1 笔待实名、1 笔已结束沉默期的套餐
|
||||
- **THEN** 已过期与待实名两笔纳入延期,已结束的不纳入
|
||||
|
||||
### Requirement: 批量延期预检
|
||||
|
||||
系统 SHALL 提供预检接口,输入与提交相同,返回逐资产、逐套餐结果:资产号、资产类型、套餐名称、沉默期状态、原截止日、新截止日、是否可延期及不可延期原因。不可延期原因包括:资产不存在或无权限、资产无沉默期套餐、套餐已有未结束的延期订单或申请、免费延期次数已用完、延期天数不足最小月数。预检 MUST NOT 产生任何数据变更。
|
||||
|
||||
#### Scenario: 名单含不存在资产
|
||||
|
||||
- **WHEN** 名单 100 个资产,其中 3 个不存在、2 个无沉默期套餐
|
||||
- **THEN** 预检返回 95 个资产的可延期明细与 5 个不可延期资产及原因
|
||||
|
||||
### Requirement: 只提交可延期明细
|
||||
|
||||
提交时系统 SHALL 在服务端重新执行预检,只将可延期的套餐明细写入批次;不可延期的资产与套餐 MUST 记入批次的跳过明细(含原因),不得导致整批拒绝。可延期明细为 0 时 MUST 拒绝提交。审批、免审批与执行规则沿用现有特殊延期(政策 `batch` 免审批场景决定是否审批,审批通过后逐条执行,部分失败不影响其他行)。
|
||||
|
||||
#### Scenario: 部分不可延期
|
||||
|
||||
- **WHEN** 提交 100 个资产,预检 95 个可延期
|
||||
- **THEN** 批次含 95 个资产的明细,5 个记为跳过及原因,审批与执行只针对 95 个
|
||||
|
||||
#### Scenario: 全部不可延期
|
||||
|
||||
- **WHEN** 名单中没有任何可延期套餐
|
||||
- **THEN** 拒绝提交,提示没有可延期的套餐
|
||||
|
||||
### Requirement: 过期前延期
|
||||
|
||||
批量特殊延期中 `silence_status = waiting` 的套餐 SHALL 可执行延期:新截止日 = `SilenceDeadline(原截止日期, 延长天数)`,`silence_status` 保持 `waiting`,累计延期天数、延期次数、免费次数(免费延期)按现有规则累加,写明细、流水与审计。执行时 MUST 重读套餐:仍为 `waiting` 或已变为 `expired` 的照常延期;已变为 `ended`(期内已实名)或套餐已失效、退款的,该条记失败并注明原因,不影响其他行。
|
||||
|
||||
#### Scenario: 待实名套餐延期
|
||||
|
||||
- **WHEN** 待实名套餐截止日 11-20,批量延期 30 天
|
||||
- **THEN** 截止日变为 12-20,状态仍为待实名
|
||||
|
||||
#### Scenario: 审批期间已实名
|
||||
|
||||
- **WHEN** 待实名套餐在审批期间完成实名、沉默期结束
|
||||
- **THEN** 审批通过后该条记失败"沉默期已结束,无需延期",其他条照常执行
|
||||
|
||||
### Requirement: 审批单资产明细
|
||||
|
||||
批量特殊延期提交企微审批时,系统 SHALL 按资产数决定明细呈现:资产数 ≤ 5 时审批单"资产"字段列出全部资产号(顿号分隔);资产数 > 5 时"资产"字段为首个资产号并注明"等 N 个",同时系统 MUST 生成 xlsx 明细文件存入对象存储,作为新业务字段"延期明细"(附件)上传到审批单。明细列:序号、资产号、资产类型、套餐名称、沉默期状态、原截止日、延期天数、新截止日。明细文件 MUST 在审批创建前生成,生成失败则拒绝提交且不产生批次。
|
||||
|
||||
#### Scenario: 100 个资产的审批单
|
||||
|
||||
- **WHEN** 批量延期 100 个资产提交审批
|
||||
- **THEN** 审批单"资产"显示"首个资产号 等 100 个",附件含 100 个资产全部套餐明细的 xlsx
|
||||
|
||||
#### Scenario: 3 个资产的审批单
|
||||
|
||||
- **WHEN** 批量延期 3 个资产
|
||||
- **THEN** 审批单"资产"列出 3 个资产号,不生成附件
|
||||
|
||||
### Requirement: 沉默期审批场景新增延期明细字段
|
||||
|
||||
沉默期审批业务类型 SHALL 新增可映射业务字段"延期明细"(附件列表),企微场景配置可将其映射到模板附件控件;该字段非必须映射,未映射时 > 5 个资产的审批单只显示首个资产与条数并在"原因"后附注"明细见管理端批次 {批次号}"。
|
||||
|
||||
#### Scenario: 场景未映射延期明细
|
||||
|
||||
- **WHEN** 场景配置未映射"延期明细",提交 20 个资产的批量延期
|
||||
- **THEN** 审批正常提交,原因字段附注批次号,管理端可下载明细
|
||||
|
||||
### Requirement: 延期批次查询
|
||||
|
||||
管理端 SHALL 提供延期批次列表(按批次号、路径、延期类型、审批状态、提交时间筛选,分页)与批次详情(基本信息、审批状态、成功 / 失败 / 跳过条数、逐条明细与失败原因、跳过资产及原因、下载明细 xlsx)。数据范围沿用现有延期权限。
|
||||
|
||||
#### Scenario: 查看批次结果
|
||||
|
||||
- **WHEN** 运营打开一个已执行的批量延期批次
|
||||
- **THEN** 看到 95 条中成功 93、失败 2 及原因,以及跳过的 5 个资产及原因,可下载明细
|
||||
37
openspec/changes/add-silence-batch-extension/tasks.md
Normal file
37
openspec/changes/add-silence-batch-extension/tasks.md
Normal file
@@ -0,0 +1,37 @@
|
||||
## 1. 数据模型
|
||||
|
||||
- [ ] 1.1 迁移:批次表加 `detail_file_key`、`skipped_items`(jsonb)、`skipped_count`;延期明细加 `source_silence_status`(默认 expired),含 down
|
||||
- [ ] 1.2 model、常量:审批字段 `detail_file_keys`、明细上限、跳过原因编码
|
||||
|
||||
## 2. 资产展开与预检
|
||||
|
||||
- [ ] 2.1 资产号解析与去重:ICCID→卡(绑定设备则换设备)、虚拟号/IMEI→设备;查询 expired / waiting 套餐
|
||||
- [ ] 2.2 抽取 `checkSpecialUsage`(支持 allowWaiting),旧 `validateSpecialItems` 改为调用它且行为不变
|
||||
- [ ] 2.3 预检接口 DTO、handler、路由(`package_silence:extend_batch`),返回逐资产逐套餐结果
|
||||
- [ ] 2.4 表驱动测试:设备去重、多套餐、无沉默期套餐、免费次数用完、已有进行中申请
|
||||
|
||||
## 3. 提交与执行
|
||||
|
||||
- [ ] 3.1 提交接口:服务端重跑预检,只写可延期明细,跳过明细入批次;0 条拒绝
|
||||
- [ ] 3.2 复用批次创建、免审批判定与审批创建;审批字段 ≤5 列全部资产号、>5 写首个 + 条数并附注批次号
|
||||
- [ ] 3.3 >5 时事务前生成 xlsx 明细并上传对象存储,记录 `detail_file_key`,作为 `detail_file_keys` 审批字段;失败拒绝提交
|
||||
- [ ] 3.4 `executeExtensionInTx` 支持来源 waiting 的明细:仍 waiting 只延截止日,expired 走原流程,ended/失效/退款记失败
|
||||
- [ ] 3.5 企微场景字段登记"延期明细"(FileList,非必须映射)
|
||||
- [ ] 3.6 测试:部分跳过、全部跳过、waiting 延期、审批期间已实名失败、旧单资产特殊延期回归
|
||||
|
||||
## 4. 批次查询
|
||||
|
||||
- [ ] 4.1 批次列表筛选(批次号、路径、延期类型、审批状态、时间)与详情返回跳过明细、计数
|
||||
- [ ] 4.2 明细下载接口:有 `detail_file_key` 签名下载,否则即时生成
|
||||
|
||||
## 5. 管理端前端(admin `iteration/8-11`)
|
||||
|
||||
- [ ] 5.1 批量延期弹窗:统一天数/类型/原因/依据/附件,导入一列资产号(xlsx/csv,≤1000),预检结果表,确认提交
|
||||
- [ ] 5.2 延期批次列表与详情页,下载明细;菜单与权限
|
||||
- [ ] 5.3 移除单资产弹窗中的旧 CSV 批量入口
|
||||
|
||||
## 6. 验证与部署
|
||||
|
||||
- [ ] 6.1 `go build ./...`、`go vet`、相关测试通过;前端构建与测试通过
|
||||
- [ ] 6.2 部署测试环境,用 6 个以上过期 / 待实名设备验证预检、提交、xlsx 附件生成(企微场景配置后验证审批单附件)
|
||||
- [ ] 6.3 更新 `docs/` 前端对接说明与企微模板配置说明
|
||||
94
pkg/constants/card_realname.go
Normal file
94
pkg/constants/card_realname.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||
PollingMonitoring: admin.NewPollingMonitoringHandler(nil),
|
||||
PollingAlert: admin.NewPollingAlertHandler(nil),
|
||||
CardRealname: admin.NewCardRealnameHandler(nil, nil, nil),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(nil),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
|
||||
PollingPriority: admin.NewPriorityPollingHandler(nil, nil, nil),
|
||||
|
||||
Reference in New Issue
Block a user