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>
290 lines
12 KiB
Go
290 lines
12 KiB
Go
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
|
||
}
|