fix: 换货迁移按套餐是否已开始与新资产占位决定主套餐状态
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m0s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m0s
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活 - 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量 - 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐 - 换货审计记录套餐状态、优先级、用量的前后值 OpenSpec: fix-exchange-migration-package-state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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{}{}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "迁移套餐使用记录失败")
|
||||
}
|
||||
@@ -35,7 +37,7 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order
|
||||
if err = markTrafficBaselinePendingWithTx(ctx, tx, 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) {
|
||||
@@ -104,39 +106,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))
|
||||
}
|
||||
|
||||
// markTrafficBaselinePendingWithTx 给换货新卡(设备换货时为新设备当前绑定的每张卡)打"待建起点"标记:
|
||||
|
||||
289
internal/service/exchange/package_migration.go
Normal file
289
internal/service/exchange/package_migration.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-04
|
||||
@@ -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
|
||||
|
||||
无。
|
||||
@@ -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 处理。
|
||||
@@ -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** 系统返回推算结果,不返回数据异常
|
||||
@@ -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 属于已开始、新卡无当前主套餐,现状与本规则一致,不修正。
|
||||
Reference in New Issue
Block a user