Files
junhong_cmp_fiber/internal/service/exchange/migration.go
邹进 96ff9c1e35
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m0s
fix: 换货迁移按套餐是否已开始与新资产占位决定主套餐状态
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活
- 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量
- 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐
- 换货审计记录套餐状态、优先级、用量的前后值

OpenSpec: fix-exchange-migration-package-state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:25:06 +09:00

249 lines
11 KiB
Go

package exchange
import (
"context"
"time"
"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/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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) {
migrationBalance, err := s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
}
usageIDs, usageChanges, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
if err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
}
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
}
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
}
if err = markTrafficBaselinePendingWithTx(ctx, tx, newAsset); err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "标记新资产流量待建起点失败")
}
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) {
var oldWallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).First(&oldWallet).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产钱包失败")
}
}
if oldWallet.ID > 0 && oldWallet.FrozenBalance > 0 {
return 0, errors.New(errors.CodeExchangeMigrationFailed, "旧资产钱包存在冻结余额,请先处理冻结业务")
}
var newWallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID).First(&newWallet).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产钱包失败")
}
shopTag := uint(0)
if newAsset.ShopID != nil {
shopTag = *newAsset.ShopID
}
newWallet = model.AssetWallet{ResourceType: newAsset.AssetType, ResourceID: newAsset.AssetID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}
if err = tx.WithContext(ctx).Create(&newWallet).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "创建新资产钱包失败")
}
}
migrationBalance := oldWallet.Balance
if migrationBalance <= 0 {
return 0, nil
}
beforeBalance := newWallet.Balance
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", oldWallet.ID).Updates(map[string]any{"balance": 0, "updated_at": time.Now()}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产钱包余额失败")
}
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", newWallet.ID).Updates(map[string]any{"balance": gorm.Expr("balance + ?", migrationBalance), "updated_at": time.Now()}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "增加新资产钱包余额失败")
}
refType := constants.ReferenceTypeExchange
remark := "换货余额迁移"
if err := tx.WithContext(ctx).Create(&model.AssetWalletTransaction{
AssetWalletID: newWallet.ID,
ResourceType: newAsset.AssetType,
ResourceID: newAsset.AssetID,
UserID: middleware.GetUserIDFromContext(ctx),
TransactionType: constants.AssetTransactionTypeExchange,
Amount: migrationBalance,
BalanceBefore: beforeBalance,
BalanceAfter: beforeBalance + migrationBalance,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceNo: &order.ExchangeNo,
Remark: &remark,
Creator: middleware.GetUserIDFromContext(ctx),
ShopIDTag: newWallet.ShopIDTag,
EnterpriseIDTag: newWallet.EnterpriseIDTag,
}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "写入迁移钱包流水失败")
}
return migrationBalance, nil
}
// 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
}
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 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
}
now := time.Now()
dailyRecords, err := loadDailyRecordUsageIDsWithTx(ctx, tx, oldMains)
if err != nil {
return nil, err
}
newHasCurrent := false
for _, usage := range newMains {
if isCurrentMainUsage(usage, now) {
newHasCurrent = true
break
}
}
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
}
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 给换货新卡(设备换货时为新设备当前绑定的每张卡)打"待建起点"标记:
// 新卡第一次通过采集队列写入时只建起点不扣费,出库前已有的读数不算给迁过来的套餐。不调用运营商接口。
func markTrafficBaselinePendingWithTx(ctx context.Context, tx *gorm.DB, newAsset *resolvedExchangeAsset) error {
query := tx.WithContext(ctx).Model(&model.IotCard{})
if newAsset.AssetType == constants.ExchangeAssetTypeIotCard {
query = query.Where("id = ?", newAsset.AssetID)
} else {
query = query.Where("id IN (?)", tx.Model(&model.DeviceSimBinding{}).Select("iot_card_id").
Where("device_id = ? AND bind_status = ?", newAsset.AssetID, constants.BindStatusBound))
}
return query.Update("traffic_baseline_pending", true).Error
}
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
if oldAsset.Card == nil {
return errors.New(errors.CodeAssetNotFound)
}
if err := tx.Model(&model.IotCard{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
"accumulated_recharge_by_series": oldAsset.Card.AccumulatedRechargeBySeriesJSON,
"first_recharge_triggered_by_series": oldAsset.Card.FirstRechargeTriggeredBySeriesJSON,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧卡累计字段失败")
}
return nil
}
if oldAsset.Device == nil {
return errors.New(errors.CodeAssetNotFound)
}
if err := tx.Model(&model.Device{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
"accumulated_recharge_by_series": oldAsset.Device.AccumulatedRechargeBySeriesJSON,
"first_recharge_triggered_by_series": oldAsset.Device.FirstRechargeTriggeredBySeriesJSON,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧设备累计字段失败")
}
return nil
}
func (s *Service) copyResourceTagsWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
var tags []*model.ResourceTag
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).Find(&tags).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资源标签失败")
}
var creator = middleware.GetUserIDFromContext(ctx)
for _, item := range tags {
if item == nil {
continue
}
record := &model.ResourceTag{
ResourceType: newAsset.AssetType,
ResourceID: newAsset.AssetID,
TagID: item.TagID,
EnterpriseID: item.EnterpriseID,
ShopID: item.ShopID,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "resource_type"}, {Name: "resource_id"}, {Name: "tag_id"}}, DoNothing: true}).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制资源标签失败")
}
}
return nil
}