Files
junhong_cmp_fiber/internal/service/exchange/package_migration.go
邹进 2ead341f2f fix: 换货迁移按套餐是否已开始与新资产占位决定主套餐状态
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活
- 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量
- 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐
- 换货审计记录套餐状态、优先级、用量的前后值

OpenSpec: fix-exchange-migration-package-state

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

290 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}