diff --git a/internal/service/exchange/audit.go b/internal/service/exchange/audit.go index a866aa8d..dce8c1e1 100644 --- a/internal/service/exchange/audit.go +++ b/internal/service/exchange/audit.go @@ -216,7 +216,7 @@ func (s *Service) buildCardExchangeCompletionResources( return nil, err } resources = append(resources, transactionResources...) - usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldCard.ID, newCard.ID) + usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, migration.PackageUsageChanges, oldCard.ID, newCard.ID) if err != nil { return nil, err } @@ -377,24 +377,46 @@ func loadExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchange return resources, nil } -func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldCardID, newCardID uint) ([]audit.ResourceInput, error) { - return loadExchangePackageUsageResources(ctx, tx, ids, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡") +func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, oldCardID, newCardID uint) ([]audit.ResourceInput, error) { + return loadExchangePackageUsageResources(ctx, tx, ids, changes, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡") } -func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) { - if len(ids) == 0 { +func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) { + migrated := make(map[uint]bool, len(ids)) + allIDs := make([]uint, 0, len(ids)+len(changes)) + for _, id := range ids { + migrated[id] = true + allIDs = append(allIDs, id) + } + for id := range changes { + if !migrated[id] { + allIDs = append(allIDs, id) + } + } + if len(allIDs) == 0 { return nil, nil } var rows []model.PackageUsage - if err := tx.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil { + if err := tx.WithContext(ctx).Where("id IN ?", allIDs).Order("id ASC").Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益失败") } orderIDs := make(map[uint]struct{}, len(rows)) packageIDs := make(map[uint]struct{}, len(rows)) resources := make([]audit.ResourceInput, 0, len(rows)*3) for _, row := range rows { - resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role, - map[string]any{assetIDField: oldAssetID}, map[string]any{assetIDField: newAssetID}) + beforeData, afterData := map[string]any{}, map[string]any{} + if migrated[row.ID] { + beforeData[assetIDField], afterData[assetIDField] = oldAssetID, newAssetID + } + if change := changes[row.ID]; change != nil { + for key, value := range change.Before { + beforeData[key] = value + } + for key, value := range change.After { + afterData[key] = value + } + } + resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role, beforeData, afterData) resource.SubjectVisibility = constants.AuditSubjectInternalOnly resources = append(resources, resource) orderIDs[row.OrderID] = struct{}{} diff --git a/internal/service/exchange/device_audit.go b/internal/service/exchange/device_audit.go index b86bac0e..46340dda 100644 --- a/internal/service/exchange/device_audit.go +++ b/internal/service/exchange/device_audit.go @@ -229,7 +229,7 @@ func (s *Service) buildDeviceExchangeCompletionResources( return nil, err } resources = append(resources, transactions...) - usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldDevice.ID, newDevice.ID) + usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, migration.PackageUsageChanges, oldDevice.ID, newDevice.ID) if err != nil { return nil, err } @@ -337,8 +337,8 @@ func loadDeviceExchangeTransactionResources(ctx context.Context, tx *gorm.DB, ex return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleDeviceExchangeWalletTransaction, "设备") } -func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) { - return loadExchangePackageUsageResources(ctx, tx, ids, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备") +func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, changes map[uint]*packageUsageStateChange, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) { + return loadExchangePackageUsageResources(ctx, tx, ids, changes, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备") } func (s *Service) recordExchangeOrderFailure(ctx context.Context, cardActionCode, summary string, order *model.ExchangeOrder, businessErr error) { diff --git a/internal/service/exchange/migration.go b/internal/service/exchange/migration.go index 51abcf40..74c47622 100644 --- a/internal/service/exchange/migration.go +++ b/internal/service/exchange/migration.go @@ -15,6 +15,8 @@ import ( type exchangeMigrationResult struct { Balance int64 PackageUsageIDs []uint + // PackageUsageChanges 记录因迁移规则改变状态、优先级或用量的套餐使用记录(含新资产原有主套餐) + PackageUsageChanges map[uint]*packageUsageStateChange } func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (*exchangeMigrationResult, error) { @@ -22,7 +24,7 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order if err != nil { return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败") } - usageIDs, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset) + usageIDs, usageChanges, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset) if err != nil { return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败") } @@ -32,7 +34,7 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil { return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败") } - return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil + return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs, PackageUsageChanges: usageChanges}, nil } func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) { @@ -101,39 +103,79 @@ func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, return migrationBalance, nil } -func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, error) { - query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}) - if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { - query = query.Where("iot_card_id = ?", oldAsset.AssetID) - } else { - query = query.Where("device_id = ?", oldAsset.AssetID) +// migratePackageUsageWithTx 把旧资产待生效、生效中、已用完的套餐使用记录迁到新资产, +// 并按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定主套餐迁移后的状态与排队顺序。 +func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, map[uint]*packageUsageStateChange, error) { + column := carrierColumn(oldAsset.AssetType) + oldUsages, err := lockLiveUsagesWithTx(ctx, tx, column, oldAsset.AssetID, false) + if err != nil { + return nil, nil, err + } + if len(oldUsages) == 0 { + return nil, nil, nil + } + newMains, err := lockLiveUsagesWithTx(ctx, tx, carrierColumn(newAsset.AssetType), newAsset.AssetID, true) + if err != nil { + return nil, nil, err } - var usageIDs []uint - if err := query.Pluck("id", &usageIDs).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") + usageIDs := make([]uint, 0, len(oldUsages)) + oldMains := make([]*model.PackageUsage, 0, len(oldUsages)) + for _, usage := range oldUsages { + usageIDs = append(usageIDs, usage.ID) + if usage.MasterUsageID == nil { + oldMains = append(oldMains, usage) + } } - if len(usageIDs) == 0 { + if err = tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs). + Updates(map[string]any{column: newAsset.AssetID, "updated_at": time.Now()}).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败") + } + if err = tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败") + } + + changes, err := s.arrangeMigratedMainsWithTx(ctx, tx, oldMains, newMains, newAsset) + if err != nil { + return nil, nil, err + } + return usageIDs, changes, nil +} + +// arrangeMigratedMainsWithTx 决定迁入主套餐状态并重排新资产主套餐优先级,保证同一载体最多一个当前主套餐。 +func (s *Service) arrangeMigratedMainsWithTx(ctx context.Context, tx *gorm.DB, oldMains, newMains []*model.PackageUsage, newAsset *resolvedExchangeAsset) (map[uint]*packageUsageStateChange, error) { + if len(oldMains) == 0 { return nil, nil } - - updates := map[string]any{"updated_at": time.Now()} - if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { - updates["iot_card_id"] = newAsset.AssetID - } else { - updates["device_id"] = newAsset.AssetID + now := time.Now() + dailyRecords, err := loadDailyRecordUsageIDsWithTx(ctx, tx, oldMains) + if err != nil { + return nil, err } - - if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败") + newHasCurrent := false + for _, usage := range newMains { + if isCurrentMainUsage(usage, now) { + newHasCurrent = true + break + } } - - if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败") + var realnameChecked, realnameOK bool + realnameSatisfied := func() (bool, error) { + if !realnameChecked { + ok, checkErr := newAssetRealnameSatisfiedWithTx(ctx, tx, newAsset) + if checkErr != nil { + return false, checkErr + } + realnameChecked, realnameOK = true, ok + } + return realnameOK, nil } - - return usageIDs, nil + plans, promoted, err := planMigratedMains(oldMains, dailyRecords, newHasCurrent, now, realnameSatisfied) + if err != nil { + return nil, err + } + return applyMigratedMainPlansWithTx(ctx, tx, plans, buildMainQueueOrder(newMains, promoted, plans, now)) } func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { diff --git a/internal/service/exchange/package_migration.go b/internal/service/exchange/package_migration.go new file mode 100644 index 00000000..7e010a09 --- /dev/null +++ b/internal/service/exchange/package_migration.go @@ -0,0 +1,289 @@ +package exchange + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// packageUsageStateChange 记录换货迁移中套餐使用记录状态字段的前后值,供换货审计使用。 +type packageUsageStateChange struct { + Before map[string]any + After map[string]any +} + +// migratedMainPlan 是旧资产主套餐迁移后的目标状态。 +type migratedMainPlan struct { + usage *model.PackageUsage + updates map[string]any // 为空表示状态字段原样保留 + pending bool // 迁移后为待生效,参与排队 +} + +func carrierColumn(assetType string) string { + if assetType == constants.ExchangeAssetTypeIotCard { + return "iot_card_id" + } + return "device_id" +} + +func liveUsageStatuses() []int { + return []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted} +} + +// lockLiveUsagesWithTx 锁定载体上待生效、生效中、已用完的套餐使用记录,按优先级、创建时间、标识排序。 +func lockLiveUsagesWithTx(ctx context.Context, tx *gorm.DB, column string, assetID uint, mainOnly bool) ([]*model.PackageUsage, error) { + query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where(column+" = ?", assetID). + Where("status IN ?", liveUsageStatuses()) + if mainOnly { + query = query.Where("master_usage_id IS NULL") + } + var rows []*model.PackageUsage + if err := query.Order("priority ASC, created_at ASC, id ASC").Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐使用记录失败") + } + return rows, nil +} + +// isCurrentMainUsage 与 FindCurrentMainPackageForQueue 口径一致:生效中或已用完且未到期。 +func isCurrentMainUsage(usage *model.PackageUsage, now time.Time) bool { + if usage.Status != constants.PackageUsageStatusActive && usage.Status != constants.PackageUsageStatusDepleted { + return false + } + return usage.ExpiresAt == nil || usage.ExpiresAt.After(now) +} + +func isExpiredOccupyingUsage(usage *model.PackageUsage, now time.Time) bool { + return (usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted) && + !isCurrentMainUsage(usage, now) +} + +// isUsageStarted 判断套餐是否已开始:有生效时间、有到期时间、有用量、有日流量记录或已用完。 +// 老系统迁移数据可能只有固定到期时间、没有生效时间,按已开始处理以保留原到期日。 +func isUsageStarted(usage *model.PackageUsage, hasDailyRecord bool) bool { + return usage.ActivatedAt != nil || usage.ExpiresAt != nil || usage.DataUsageMB > 0 || hasDailyRecord || + usage.Status == constants.PackageUsageStatusDepleted +} + +func loadDailyRecordUsageIDsWithTx(ctx context.Context, tx *gorm.DB, usages []*model.PackageUsage) (map[uint]bool, error) { + result := make(map[uint]bool) + if len(usages) == 0 { + return result, nil + } + ids := make([]uint, 0, len(usages)) + for _, usage := range usages { + ids = append(ids, usage.ID) + } + var withRecords []uint + if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}). + Where("package_usage_id IN ?", ids).Distinct().Pluck("package_usage_id", &withRecords).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐日流量记录失败") + } + for _, id := range withRecords { + result[id] = true + } + return result, nil +} + +// resetToPendingUpdates 生成"改为待生效并清空生效周期"的字段更新。 +func resetToPendingUpdates(pendingRealname, clearUsage bool) map[string]any { + updates := map[string]any{ + "status": constants.PackageUsageStatusPending, + "activated_at": nil, + "expires_at": nil, + "last_reset_at": nil, + "next_reset_at": nil, + "pending_realname_activation": pendingRealname, + } + if clearUsage { + updates["data_usage_mb"] = int64(0) + } + return updates +} + +// isCleanPendingUsage 判断记录是否已是目标待生效状态,避免无变化的写入与审计。 +func isCleanPendingUsage(usage *model.PackageUsage, pendingRealname bool) bool { + return usage.Status == constants.PackageUsageStatusPending && usage.PendingRealnameActivation == pendingRealname && + usage.ActivatedAt == nil && usage.ExpiresAt == nil && usage.LastResetAt == nil && usage.NextResetAt == nil +} + +// planMigratedMains 按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定旧资产主套餐的迁移状态。 +// realnameSatisfied 仅在存在未开始的主套餐时被调用。 +func planMigratedMains(oldMains []*model.PackageUsage, dailyRecords map[uint]bool, newHasCurrent bool, now time.Time, + realnameSatisfied func() (bool, error)) ([]migratedMainPlan, *model.PackageUsage, error) { + plans := make([]migratedMainPlan, 0, len(oldMains)) + var promoted *model.PackageUsage + for _, usage := range oldMains { + if isExpiredOccupyingUsage(usage, now) { + plans = append(plans, migratedMainPlan{usage: usage}) + continue + } + if !isUsageStarted(usage, dailyRecords[usage.ID]) { + satisfied, err := realnameSatisfied() + if err != nil { + return nil, nil, err + } + var updates map[string]any + if !isCleanPendingUsage(usage, !satisfied) { + updates = resetToPendingUpdates(!satisfied, false) + } + plans = append(plans, migratedMainPlan{usage: usage, updates: updates, pending: true}) + continue + } + if !newHasCurrent && promoted == nil && isCurrentMainUsage(usage, now) { + promoted = usage + plans = append(plans, migratedMainPlan{usage: usage}) + continue + } + plans = append(plans, migratedMainPlan{usage: usage, updates: resetToPendingUpdates(false, true), pending: true}) + } + return plans, promoted, nil +} + +// buildMainQueueOrder 迁移后新资产主套餐顺序:当前主套餐 → 新资产原有待生效 → 迁入的待生效。 +func buildMainQueueOrder(newMains []*model.PackageUsage, promoted *model.PackageUsage, plans []migratedMainPlan, now time.Time) []*model.PackageUsage { + ordered := make([]*model.PackageUsage, 0, len(newMains)+len(plans)) + for _, usage := range newMains { + if isCurrentMainUsage(usage, now) { + ordered = append(ordered, usage) + } + } + if promoted != nil { + ordered = append(ordered, promoted) + } + for _, usage := range newMains { + if usage.Status == constants.PackageUsageStatusPending { + ordered = append(ordered, usage) + } + } + for _, plan := range plans { + if plan.pending { + ordered = append(ordered, plan.usage) + } + } + return ordered +} + +func usageStateSnapshot(usage *model.PackageUsage) map[string]any { + return map[string]any{ + "status": usage.Status, "priority": usage.Priority, "data_usage_mb": usage.DataUsageMB, + "activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, + "last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt, + "pending_realname_activation": usage.PendingRealnameActivation, + } +} + +func recordStateChange(changes map[uint]*packageUsageStateChange, usage *model.PackageUsage, updates map[string]any) { + change, ok := changes[usage.ID] + if !ok { + change = &packageUsageStateChange{Before: usageStateSnapshot(usage), After: usageStateSnapshot(usage)} + changes[usage.ID] = change + } + for key, value := range updates { + change.After[key] = value + } +} + +func applyUsageUpdatesWithTx(ctx context.Context, tx *gorm.DB, id uint, updates map[string]any) error { + values := make(map[string]any, len(updates)+1) + for key, value := range updates { + values[key] = value + } + values["updated_at"] = time.Now() + if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ?", id).Updates(values).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新迁移套餐状态失败") + } + return nil +} + +// applyMigratedMainPlansWithTx 写入迁移主套餐的目标状态并重排新资产主套餐优先级。 +func applyMigratedMainPlansWithTx(ctx context.Context, tx *gorm.DB, plans []migratedMainPlan, ordered []*model.PackageUsage) (map[uint]*packageUsageStateChange, error) { + changes := make(map[uint]*packageUsageStateChange) + pendingUpdates := make(map[uint]map[string]any) + for _, plan := range plans { + if len(plan.updates) > 0 { + pendingUpdates[plan.usage.ID] = plan.updates + recordStateChange(changes, plan.usage, plan.updates) + } + } + for index, usage := range ordered { + priority := index + 1 + if usage.Priority == priority { + continue + } + updates := pendingUpdates[usage.ID] + if updates == nil { + updates = make(map[string]any) + pendingUpdates[usage.ID] = updates + } + updates["priority"] = priority + recordStateChange(changes, usage, map[string]any{"priority": priority}) + } + for id, updates := range pendingUpdates { + if err := applyUsageUpdatesWithTx(ctx, tx, id, updates); err != nil { + return nil, err + } + } + return changes, nil +} + +// newAssetRealnameSatisfiedWithTx 判断新资产是否满足实名条件。 +// 口径同 StopResumeService.isRealnameOK:行业卡、已实名、或适用实名策略为无需实名;设备看自身策略与已绑定卡。 +func newAssetRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, asset *resolvedExchangeAsset) (bool, error) { + if asset.AssetType == constants.ExchangeAssetTypeIotCard { + return cardRealnameSatisfiedWithTx(ctx, tx, asset.AssetID) + } + return deviceRealnameSatisfiedWithTx(ctx, tx, asset.AssetID) +} + +func cardRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) { + var card model.IotCard + if err := tx.WithContext(ctx).Select("id", "card_category", "real_name_status", "realname_policy", "is_standalone"). + First(&card, cardID).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新卡实名条件失败") + } + if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified { + return true, nil + } + if card.IsStandalone { + return card.RealnamePolicy == constants.RealnamePolicyNone, nil + } + var device model.Device + err := tx.WithContext(ctx).Select("devices.realname_policy").Table("tb_device AS devices"). + Joins("JOIN tb_device_sim_binding AS bindings ON bindings.device_id = devices.id"). + Where("bindings.iot_card_id = ? AND bindings.bind_status = ? AND bindings.deleted_at IS NULL", cardID, constants.BindStatusBound). + Where("devices.deleted_at IS NULL"). + Take(&device).Error + if err == gorm.ErrRecordNotFound { + return card.RealnamePolicy == constants.RealnamePolicyNone, nil + } + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新卡绑定设备实名策略失败") + } + return device.RealnamePolicy == constants.RealnamePolicyNone, nil +} + +func deviceRealnameSatisfiedWithTx(ctx context.Context, tx *gorm.DB, deviceID uint) (bool, error) { + var device model.Device + if err := tx.WithContext(ctx).Select("id", "realname_policy").First(&device, deviceID).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新设备实名条件失败") + } + if device.RealnamePolicy == constants.RealnamePolicyNone { + return true, nil + } + var count int64 + if err := tx.WithContext(ctx).Model(&model.IotCard{}). + Joins("JOIN tb_device_sim_binding AS bindings ON bindings.iot_card_id = tb_iot_card.id"). + Where("bindings.device_id = ? AND bindings.bind_status = ? AND bindings.deleted_at IS NULL", deviceID, constants.BindStatusBound). + Where("tb_iot_card.card_category = ? OR tb_iot_card.real_name_status = ?", constants.CardCategoryIndustry, constants.RealNameStatusVerified). + Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询新设备绑定卡实名条件失败") + } + return count > 0, nil +} diff --git a/openspec/changes/fix-exchange-migration-package-state/.openspec.yaml b/openspec/changes/fix-exchange-migration-package-state/.openspec.yaml new file mode 100644 index 00000000..4ccb2b41 --- /dev/null +++ b/openspec/changes/fix-exchange-migration-package-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-04 diff --git a/openspec/changes/fix-exchange-migration-package-state/design.md b/openspec/changes/fix-exchange-migration-package-state/design.md new file mode 100644 index 00000000..9c58d355 --- /dev/null +++ b/openspec/changes/fix-exchange-migration-package-state/design.md @@ -0,0 +1,97 @@ +## Context + +`migratePackageUsageWithTx`(`internal/service/exchange/migration.go:104`)在换货完成事务内,把旧资产全部待生效、生效中、已用完的套餐使用记录批量改载体(`iot_card_id` / `device_id`),其余字段不动,日流量记录只刷新 `updated_at`。 + +相关现状: + +- 当前主套餐判断:`packagepkg.FindCurrentMainPackageForQueue` / `HasCurrentMainPackageForQueue`(`internal/service/package/addon_main_package.go`),口径为生效中或已用完、未到期。订单、自动续费、自动购包都用它决定新主套餐是否排队。 +- 待生效主套餐的接续激活: + - 前一个到期:到期任务 `PackageActivationHandler.processExpiredPackage` → 激活队首。 + - 无占位主套餐:`findAndActivateOrphanPackages`(`internal/polling/package_activation_handler.go:168`)定时扫描"有待生效、无生效中或已用完主套餐"的载体,取队首(优先级、创建时间、标识)提交 `ActivateSpecificPackage`;后者对标记等实名激活的套餐检查实名,未标记的直接激活,激活时从当天起重新计算有效期与重置时间,并处理复机、轮询、审计等副作用。 + - 实名后:`ActivateByRealname` 激活标记等实名激活的套餐。 +- `activatePendingUsage` 激活时不重置 `data_usage_mb`。 +- 实名条件口径:`StopResumeService.isRealnameOK`(`internal/service/iot_card/stop_resume_service.go:332`)认行业卡、已实名、无需实名策略;套餐激活侧的 `isCarrierRealnamed` 只认已实名(另开 Change 处理,本 Change 不改)。 +- 预计到期推算 `internal/query/packageexpiry/query.go` 在多个生效/已用完主套餐、待生效排在当前之前时返回数据异常。 + +## Goals / Non-Goals + +**Goals:** + +- 换货迁移后新资产最多一个当前主套餐,主套餐优先级连续、无重复。 +- 未开始的套餐迁移后不直接生效;新资产满足实名条件时由既有机制接续激活。 +- 已开始的套餐在新资产无占位时无感知继续使用。 + +**Non-Goals:** + +- 不改换货接口、不拦截换货、不改钱包余额、累计充值、标签迁移。 +- 不改订单、自动续费、自动购包、实名激活的实名判断。 +- 不在代码里修复存量数据。 +- 不处理加油包与主套餐的重新挂靠。 + +## Decisions + +### D1 "已开始"的判定 + +满足任一即已开始:`activated_at` 非空、`expires_at` 非空、`data_usage_mb > 0`、存在日流量记录、状态为已用完。 + +- 老系统迁移数据存在"状态生效中、生效时间为空、到期时间固定"的记录(2026-11-04 换货单 1095–1104、1108)。固定到期日是老系统卖给客户的权益,换货不应改写,因此有到期时间即视为已开始;真正未开始的只剩从未激活的待生效套餐。 +- 换货单 1107 的套餐生效时间为空但有 10328 MB 用量,需要用量与日流量兜底。 + +备选一:只看 `activated_at`。会把 1107 判为未开始、清空进度,拒绝。 +备选二:不看 `expires_at`。会把老系统固定到期日的套餐改为待生效、实名后重算 12 个月,改变客户已有到期日,拒绝。 + +### D2 实名条件判断放在换货服务内 + +新增换货侧私有函数,按 spec 口径判断新资产是否满足实名条件:卡看行业卡、实名状态、适用实名策略;设备看设备策略与已绑定卡。 + +- 只影响等实名激活标记,口径需与停复机一致。 +- `isRealnameOK` 是 `StopResumeService` 私有方法且只接受卡;为一处调用把它导出或抽公共包,会顺带改动停复机模块,违反"不主动重构"。 + +备选:复用套餐侧 `isCarrierRealnamed`。不认行业卡,迁到行业卡的套餐会永远等实名,拒绝。 + +### D3 换货事务内不激活,交给孤儿扫描与实名激活 + +未开始与排队的主套餐只写为待生效和正确的等实名激活标记。 + +- 新资产无占位主套餐且无需等实名:孤儿扫描下一轮提交 `ActivateSpecificPackage`,统一走激活副作用(审计、观测、优先轮询、复机)。 +- 需要等实名:新资产实名后由 `ActivateByRealname` 激活。 + +备选:换货服务注入 `ActivationService` 在事务后立即激活。要新增依赖与失败补偿,且孤儿扫描已覆盖同一场景,拒绝。代价是激活最多延迟一个扫描周期。 + +### D4 排队的已开始套餐清零用量、重算完整周期 + +与 M15 排队购买一致:排队主套餐轮到时从激活当天起算完整周期。用量清零是因为 `activatePendingUsage` 不重置用量,不清零会把旧周期用量带入新周期。换货前用量保留在日流量记录中作为历史。与 2026-11-04 生产手工修正(46981)结果一致。 + +备选:保留剩余天数。需要新增"剩余时长"字段并改激活逻辑,影响面超出本 Change,拒绝。 + +### D5 统一重排优先级 + +迁移后对新资产全部未结束主套餐(待生效、生效中、已用完且未到期)按"当前主套餐 → 新资产原有待生效 → 迁入待生效"重排为 1..n。 + +- 现有新购排队写 `max + 1`,迁移场景下新资产原有待生效与迁入的生效主套餐可能都是 1,只追加无法消除重复。 +- 孤儿扫描与预计到期推算都按优先级、创建时间、标识排序,连续编号后结果确定。 + +### D6 锁与事务 + +在换货完成事务内先对新、旧资产的主套餐记录 `SELECT ... FOR UPDATE`,再判断与写入,避免与同时进行的购买、到期接续交叉。沿用换货现有事务,失败整体回滚并按既有规则记录迁移失败。 + +### D7 审计 + +状态、优先级或用量发生变化的记录,通过换货服务现有 `auditWriter` 写套餐使用记录变更审计,前后值包含状态、优先级、用量、生效、到期、重置时间与等实名激活标记,附换货单号。仅改载体、其余不变的记录不额外写审计。 + +## Risks / Trade-offs + +- [激活延迟] 无需等实名的未开始套餐要等孤儿扫描下一轮才生效 → 扫描周期分钟级;换货后新资产本无占位套餐,客户影响小。 +- [旧资产已过期未处理的主套餐] 原样迁移后,到期任务处理前新资产可能短暂出现两条状态生效中的记录 → 其中一条已过期,到期任务会处理;扣量与推算以未到期为准。 +- [实名口径两处维护] 换货侧与停复机侧各有一份 → 在函数注释注明口径来源,后续"统一实名判断"Change 合并。 +- [清零用量] 排队套餐换货前的周期内用量不再显示在套餐上 → 日流量记录保留,可追溯。 + +## Migration Plan + +- 无数据库迁移,无配置变化。 +- 按维护者生产发布流程手工发布;回滚即回退二进制。 +- 存量不修正:2026-11-04 换货单 1095–1104、1108 的 11 条主套餐有固定到期日,按 D1 属于已开始,现状即为本规则下的结果。 + +## Open Questions + +无。 diff --git a/openspec/changes/fix-exchange-migration-package-state/proposal.md b/openspec/changes/fix-exchange-migration-package-state/proposal.md new file mode 100644 index 00000000..7cf9923c --- /dev/null +++ b/openspec/changes/fix-exchange-migration-package-state/proposal.md @@ -0,0 +1,37 @@ +## Why + +换货迁移套餐时(`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx`),系统只把套餐使用记录的载体改成新资产,状态、优先级、用量、有效期原样保留,不看套餐是否真正开始过,也不看新资产上已有的套餐。生产已出现后果: + +- 2026-11-03 换货单 EXC20261103174239616885:新卡 89860624630055040571 自己有生效主套餐,迁过来的主套餐也是生效中,一张卡两个生效主套餐,流量重复扣减,预计到期时间显示"数据异常"(已手工修正,见 `docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md`)。 + +业务原则:换货只是换载体,不改变套餐所处的阶段;同一载体同时只能有一个生效主套餐(《八月迭代后端修正技术方案汇总》3.15 M15);新资产已有生效套餐时换货不拦截、叠加数据(同文 3.12)。 + +## What Changes + +- 换货迁移主套餐时,按以下顺序决定迁移后的状态: + 1. **套餐是否已开始**:有生效时间、或有到期时间、或有用量、或有日流量记录、或状态为已用完,视为已开始;否则视为未开始。老系统迁移数据中"生效中、无生效时间、有固定到期日"的套餐按已开始处理,保留原到期日。 + 2. **未开始的主套餐**:一律以待生效迁移,清空生效时间、到期时间与重置时间;新资产不满足实名条件时标记为等实名激活。实名条件与停复机口径一致:行业卡、已实名、或实名策略为无需实名,任一满足即视为满足。 + 3. **已开始的主套餐**:新资产上没有当前主套餐(生效中或已用完、且未到期)时,原样迁移、继续生效;新资产已有当前主套餐时,改为待生效排在后面,清空生效时间、到期时间与重置时间,已用量清零,等轮到时从激活当天起重新计算完整周期。 +- 迁移后新资产上全部未结束主套餐按"当前主套餐 → 新资产原有待生效 → 迁入的主套餐(按原顺序)"统一重排优先级,不再出现优先级重复或待生效排在当前套餐之前。 +- 加油包仍原样迁移,继续挂在原主套餐下。 +- 未开始或排队的主套餐迁移后不在换货事务内激活;新资产无占位主套餐且满足激活条件时,由现有孤儿套餐扫描或实名激活流程接续生效。 +- 换货迁移导致状态、优先级或用量变化的套餐记录写审计,保留前后值。 +- 不拦截换货,不改换货接口入参与出参。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `order-refund-exchange`:新增"换货迁移后的主套餐状态"要求,规定迁移主套餐时按"是否已开始、新资产实名条件、新资产是否已有当前主套餐"决定状态与排队顺序。 + +## Impact + +- 代码:`internal/service/exchange/migration.go`(`migratePackageUsageWithTx` 改为按规则逐条处理主套餐、重排优先级);新增换货侧的实名条件判断(行业卡、已实名、无需实名,口径同 `internal/service/iot_card/stop_resume_service.go` 的 `isRealnameOK`);当前主套餐判断沿用 `internal/service/package` 的 `FindCurrentMainPackageForQueue` 口径。 +- 接口:无变化。 +- 数据库:无迁移。 +- 存量数据:不修正。2026-11-04 换货单 1095–1104、1108 迁入的 11 条主套餐有固定到期日,按本规则属于已开始、新卡无当前主套餐时原样迁移,与现状一致;新卡未实名且已停机,未产生用量。 +- 不涉及:其他入口"等实名激活"判断不认行业卡与无需实名的问题(订单、自动续费、自动购包、实名激活),另开 Change 处理。 diff --git a/openspec/changes/fix-exchange-migration-package-state/specs/order-refund-exchange/spec.md b/openspec/changes/fix-exchange-migration-package-state/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..212360ed --- /dev/null +++ b/openspec/changes/fix-exchange-migration-package-state/specs/order-refund-exchange/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: 换货迁移后的主套餐状态 + +选择业务数据迁移的换货完成时,系统 SHALL 逐条决定旧资产主套餐使用记录(`master_usage_id` 为空,状态为待生效、生效中或已用完)迁移到新资产后的状态,保证新资产同一时刻最多一个生效中或已用完的未到期主套餐。 + +- **已开始**:主套餐有生效时间、或有到期时间、或已用量大于 0、或存在日流量记录、或状态为已用完,视为已开始;否则视为未开始。 +- **实名条件**:新资产为卡时,卡为行业卡、或已实名、或其适用实名策略(已绑定设备时取设备策略,否则取卡策略)为无需实名,视为满足;新资产为设备时,设备实名策略为无需实名、或任一已绑定卡为行业卡或已实名,视为满足。 +- **当前主套餐**:载体上状态为生效中或已用完、且未到期(到期时间为空或晚于当前时间)的主套餐。 + +规则: + +1. 未开始的主套餐 MUST 以待生效迁移,生效时间、到期时间、上次重置时间、下次重置时间 MUST 清空;新资产不满足实名条件时 MUST 标记为等实名激活,满足时 MUST NOT 标记。 +2. 已开始的主套餐,若新资产迁移前没有当前主套餐,且它是旧资产按优先级、创建时间、标识排序的第一条已开始的当前主套餐,SHALL 原样迁移,状态、用量、有效期与重置时间不变。 +3. 其余已开始的主套餐 MUST 以待生效迁移,生效时间、到期时间、上次重置时间、下次重置时间 MUST 清空,已用量 MUST 清零,MUST NOT 标记为等实名激活;日流量记录 MUST 保留为历史。 +4. 迁移完成后,新资产全部待生效、生效中、已用完的主套餐 SHALL 按"当前主套餐 → 新资产原有待生效主套餐(保持原顺序)→ 旧资产迁入的待生效主套餐(保持原顺序)"重新编排优先级为 1 起连续整数。 +5. 旧资产上已到期但尚未被到期任务处理的主套餐 SHALL 原样迁移,不参与排队与优先级编排。 +6. 加油包 SHALL 原样迁移并保持与原主套餐的关联。 +7. 换货 MUST NOT 因新资产已有钱包余额或套餐而被拒绝;换货事务内 MUST NOT 激活待生效主套餐。 +8. 因本规则改变状态、优先级或用量的每条套餐使用记录 SHALL 写入审计,包含变更前后值与换货单号。 + +#### Scenario: 新资产无套餐,迁入在用主套餐 + +- **GIVEN** 旧资产有一条生效中、已用量大于 0 的主套餐和一条挂在其下的加油包,新资产没有任何套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 主套餐在新资产上仍为生效中,用量、到期时间与重置时间不变,优先级为 1;加油包原样迁移 + +#### Scenario: 新资产已有生效主套餐,迁入在用主套餐 + +- **GIVEN** 新资产有一条生效中的主套餐,旧资产有一条生效中、已用量大于 0 的主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 新资产原主套餐保持生效、优先级为 1;迁入主套餐变为待生效、优先级为 2,生效时间、到期时间与重置时间为空,已用量为 0,未标记等实名激活,原日流量记录保留 + +#### Scenario: 新资产已用完但未到期 + +- **GIVEN** 新资产主套餐为已用完且到期时间晚于当前时间,旧资产有生效中的已开始主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 迁入主套餐变为待生效并排在新资产主套餐之后 + +#### Scenario: 新资产主套餐已过期但未被处理 + +- **GIVEN** 新资产主套餐状态为生效中但到期时间早于当前时间,旧资产有生效中的已开始主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 新资产视为没有当前主套餐,迁入主套餐保持生效中 + +#### Scenario: 未开始的主套餐迁到未实名普通卡 + +- **GIVEN** 旧资产主套餐为待生效,没有生效时间、到期时间,已用量为 0、没有日流量记录;新资产为未实名普通卡,实名策略为购买后实名 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 迁入主套餐变为待生效并标记等实名激活,生效时间、到期时间与重置时间为空,换货事务内不激活 + +#### Scenario: 老系统迁移的固定到期日套餐 + +- **GIVEN** 旧资产主套餐状态为生效中,没有生效时间、已用量为 0,但有固定到期时间;新资产没有任何套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 该主套餐视为已开始,在新资产上原样保持生效中,到期时间不变 + +#### Scenario: 未开始的主套餐迁到行业卡 + +- **GIVEN** 旧资产主套餐未开始,新资产为未实名的行业卡且没有当前主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 迁入主套餐变为待生效且不标记等实名激活,之后由孤儿套餐扫描接续激活,有效期从激活当天起算 + +#### Scenario: 新资产只有待生效主套餐 + +- **GIVEN** 新资产只有一条优先级为 1 的待生效主套餐,旧资产有一条生效中的已开始主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 迁入主套餐保持生效中、优先级为 1,新资产原待生效主套餐优先级变为 2,不出现优先级重复 + +#### Scenario: 旧资产有多条主套餐 + +- **GIVEN** 新资产有一条生效中的主套餐,旧资产有一条生效中的已开始主套餐和一条待生效主套餐 +- **WHEN** 换货选择业务数据迁移并确认完成 +- **THEN** 两条迁入主套餐均为待生效,按旧资产原顺序依次排在新资产主套餐之后,优先级分别为 2、3 + +#### Scenario: 迁移后预计到期时间可计算 + +- **WHEN** 任一上述场景换货完成后查询新资产预计到期时间 +- **THEN** 系统返回推算结果,不返回数据异常 diff --git a/openspec/changes/fix-exchange-migration-package-state/tasks.md b/openspec/changes/fix-exchange-migration-package-state/tasks.md new file mode 100644 index 00000000..1c21a0f2 --- /dev/null +++ b/openspec/changes/fix-exchange-migration-package-state/tasks.md @@ -0,0 +1,23 @@ +## 1. 迁移规则实现 + +- [x] 1.1 `internal/service/exchange/` 新增换货侧实名条件判断:卡为行业卡、已实名,或适用实名策略(已绑定设备取设备策略,否则取卡策略)为无需实名;设备为无需实名策略,或任一已绑定卡为行业卡或已实名。注释注明口径同 `StopResumeService.isRealnameOK`。 +- [x] 1.2 新增"套餐是否已开始"判断:`activated_at` 非空、`expires_at` 非空、`data_usage_mb > 0`、存在日流量记录、状态为已用完任一满足。 +- [x] 1.3 改造 `migratePackageUsageWithTx`:事务内锁定新、旧资产主套餐记录;按 `FindCurrentMainPackageForQueue` 同一口径(生效中或已用完且未到期)在已锁定记录上判断新资产迁移前是否有当前主套餐;加油包与旧资产已到期主套餐原样改载体。 +- [x] 1.4 按 spec 规则 1–3 逐条处理旧资产主套餐:未开始 → 待生效、清空时间、按实名条件设置等实名激活标记;首条已开始的当前主套餐在新资产无当前主套餐时原样迁移;其余已开始 → 待生效、清空时间、用量清零、不标记等实名激活。 +- [x] 1.5 迁移后按"当前主套餐 → 新资产原有待生效 → 迁入待生效"把新资产未结束主套餐优先级重排为 1..n。 +- [x] 1.6 对状态、优先级或用量变化的记录通过换货 `auditWriter` 写审计(前后值、换货单号)。 +- [x] 1.7 返回的迁移套餐记录标识列表保持不变;日流量记录只保留,不删除。 + +## 2. 构建与文档 + +- [x] 2.1 `gofmt`、`go build ./cmd/api ./cmd/worker`。 +- [ ] 2.2 `openspec validate --all`、`./scripts/context-health.sh`。 + +## 3. 测试环境验证(AGENTS.md 规定自动化测试 N/A,不写单元测试) + +- [ ] 3.1 按 ENG-TEST-001 在维护者指定测试环境造数,逐一验证 spec 全部场景:新资产无套餐;新资产已有生效;新资产已用完未到期;新资产已过期未处理;未开始迁到未实名普通卡;老系统固定到期日套餐原样迁移;未开始迁到行业卡(孤儿扫描后激活);新资产只有待生效;旧资产多条主套餐。 +- [ ] 3.2 每个场景换货后查询新资产预计到期时间不为数据异常,审计记录前后值正确。 + +## 4. 存量数据 + +- [x] 4.1 核对 2026-11-04 换货单 1095–1104、1108 共 11 条主套餐:均有固定到期日,按 D1 属于已开始、新卡无当前主套餐,现状与本规则一致,不修正。