All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2. 生效套餐逻辑错误的问题
790 lines
26 KiB
Go
790 lines
26 KiB
Go
package packagepkg
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/redis/go-redis/v9"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ResumeCallback 复机回调接口
|
||
// 用于在套餐激活或流量重置后触发自动复机
|
||
type ResumeCallback interface {
|
||
ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error
|
||
}
|
||
|
||
type ActivationService struct {
|
||
db *gorm.DB
|
||
redis *redis.Client
|
||
packageUsageStore *postgres.PackageUsageStore
|
||
packageStore *postgres.PackageStore
|
||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||
logger *zap.Logger
|
||
resumeCallback ResumeCallback // 复机回调,可选
|
||
}
|
||
|
||
func NewActivationService(
|
||
db *gorm.DB,
|
||
redis *redis.Client,
|
||
packageUsageStore *postgres.PackageUsageStore,
|
||
packageStore *postgres.PackageStore,
|
||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||
logger *zap.Logger,
|
||
) *ActivationService {
|
||
return &ActivationService{
|
||
db: db,
|
||
redis: redis,
|
||
packageUsageStore: packageUsageStore,
|
||
packageStore: packageStore,
|
||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// SetResumeCallback 任务 24.7: 设置复机回调
|
||
// 在应用启动时由 bootstrap 调用,注入停复机服务
|
||
func (s *ActivationService) SetResumeCallback(callback ResumeCallback) {
|
||
s.resumeCallback = callback
|
||
}
|
||
|
||
// ActivateByRealname 任务 9.2-9.3: 首次实名激活
|
||
// 当用户完成实名后,激活所有待实名激活的套餐
|
||
func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error {
|
||
// 查询待实名激活的套餐
|
||
var pendingUsages []*model.PackageUsage
|
||
query := s.db.WithContext(ctx).
|
||
Where("pending_realname_activation = ?", true).
|
||
Where("status = ?", constants.PackageUsageStatusPending)
|
||
|
||
if carrierType == "iot_card" {
|
||
query = query.Where("iot_card_id = ?", carrierID)
|
||
} else if carrierType == "device" {
|
||
query = query.Where("device_id = ?", carrierID)
|
||
} else {
|
||
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||
}
|
||
|
||
if err := query.Order("priority ASC").Find(&pendingUsages).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待实名激活套餐失败")
|
||
}
|
||
|
||
if len(pendingUsages) == 0 {
|
||
s.logger.Info("没有待实名激活的套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID))
|
||
return nil
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// 在事务中激活套餐
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
for _, usage := range pendingUsages {
|
||
// 查询套餐信息
|
||
var pkg model.Package
|
||
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||
}
|
||
|
||
// 检查是否是主套餐
|
||
if usage.MasterUsageID == nil {
|
||
// 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐
|
||
var activeMain model.PackageUsage
|
||
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Order("priority ASC").
|
||
First(&activeMain).Error
|
||
|
||
if err == nil {
|
||
// 已有生效中或已用完(占位)的主套餐,保持排队状态
|
||
s.logger.Warn("已有占位主套餐,跳过激活",
|
||
zap.Uint("usage_id", usage.ID),
|
||
zap.Uint("active_main_id", activeMain.ID),
|
||
zap.Int("active_main_status", activeMain.Status))
|
||
continue
|
||
}
|
||
if err != gorm.ErrRecordNotFound {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败")
|
||
}
|
||
}
|
||
|
||
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
|
||
// from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻
|
||
var activatedAt time.Time
|
||
if pkg.ExpiryBase == "from_purchase" {
|
||
activatedAt = usage.CreatedAt
|
||
} else {
|
||
activatedAt = now
|
||
}
|
||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||
|
||
// 计算下次重置时间
|
||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||
|
||
// 更新套餐使用记录
|
||
updates := map[string]interface{}{
|
||
"status": constants.PackageUsageStatusActive,
|
||
"pending_realname_activation": false,
|
||
"activated_at": activatedAt,
|
||
"expires_at": expiresAt,
|
||
}
|
||
if nextResetAt != nil {
|
||
updates["next_reset_at"] = *nextResetAt
|
||
}
|
||
|
||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
|
||
}
|
||
|
||
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
|
||
|
||
s.logger.Info("套餐已激活",
|
||
zap.Uint("usage_id", usage.ID),
|
||
zap.Uint("package_id", usage.PackageID),
|
||
zap.Time("activated_at", activatedAt),
|
||
zap.Time("expires_at", expiresAt))
|
||
|
||
if s.resumeCallback != nil {
|
||
go func(ct string, cid uint) {
|
||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||
s.logger.Error("实名激活后自动复机失败",
|
||
zap.String("carrier_type", ct),
|
||
zap.Uint("carrier_id", cid),
|
||
zap.Error(err))
|
||
}
|
||
}(carrierType, carrierID)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
|
||
// 当主套餐过期后,激活下一个待生效的主套餐
|
||
func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierType string, carrierID uint) error {
|
||
// 使用 Redis 分布式锁避免并发
|
||
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
|
||
lockValue := time.Now().String()
|
||
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
|
||
}
|
||
if !locked {
|
||
s.logger.Warn("套餐激活正在进行中,跳过",
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return nil
|
||
}
|
||
defer s.redis.Del(ctx, lockKey)
|
||
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 任务 9.5: 检测并标记过期的主套餐
|
||
now := time.Now()
|
||
var expiredMainUsages []*model.PackageUsage
|
||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||
Where("master_usage_id IS NULL").
|
||
Where("expires_at <= ?", now).
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Find(&expiredMainUsages).Error
|
||
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询过期主套餐失败")
|
||
}
|
||
|
||
for _, expiredMain := range expiredMainUsages {
|
||
// 更新主套餐状态为已过期
|
||
if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败")
|
||
}
|
||
|
||
expiresAt := now
|
||
if expiredMain.ExpiresAt != nil {
|
||
expiresAt = *expiredMain.ExpiresAt
|
||
}
|
||
s.logger.Info("主套餐已过期",
|
||
zap.Uint("usage_id", expiredMain.ID),
|
||
zap.Time("expires_at", expiresAt))
|
||
|
||
// 任务 9.7: 加油包级联失效
|
||
if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 任务 9.6: 激活下一个待生效主套餐
|
||
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
|
||
// 根据 PackageUsageID 精准激活目标套餐,而非重跑"查找过期包"流程
|
||
func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error {
|
||
// 加载 PackageUsage
|
||
var usage model.PackageUsage
|
||
if err := s.db.WithContext(ctx).First(&usage, packageUsageID).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "套餐使用记录不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||
}
|
||
|
||
// 幂等检查:非待生效状态直接返回
|
||
if usage.Status != constants.PackageUsageStatusPending {
|
||
s.logger.Info("套餐无需激活(非待生效状态)",
|
||
zap.Uint("usage_id", usage.ID),
|
||
zap.Int("status", usage.Status))
|
||
return nil
|
||
}
|
||
|
||
// 确定载体类型和 ID
|
||
var carrierType string
|
||
var carrierID uint
|
||
if usage.IotCardID > 0 {
|
||
carrierType = "iot_card"
|
||
carrierID = usage.IotCardID
|
||
} else if usage.DeviceID > 0 {
|
||
carrierType = "device"
|
||
carrierID = usage.DeviceID
|
||
} else {
|
||
return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少载体信息")
|
||
}
|
||
|
||
// 获取分布式锁
|
||
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
|
||
lockValue := time.Now().String()
|
||
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
|
||
}
|
||
if !locked {
|
||
s.logger.Warn("套餐激活正在进行中,跳过",
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return nil
|
||
}
|
||
defer s.redis.Del(ctx, lockKey)
|
||
|
||
// 加载关联 Package
|
||
var pkg model.Package
|
||
if err := s.db.WithContext(ctx).First(&pkg, usage.PackageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||
}
|
||
|
||
// 事务内激活
|
||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 再次幂等检查(防止锁释放期间被其他操作激活)
|
||
var currentUsage model.PackageUsage
|
||
if err := tx.First(¤tUsage, packageUsageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "重新查询套餐使用记录失败")
|
||
}
|
||
if currentUsage.Status != constants.PackageUsageStatusPending {
|
||
return nil
|
||
}
|
||
|
||
if currentUsage.MasterUsageID == nil {
|
||
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if hasActive {
|
||
s.logger.Info("已有生效主套餐,跳过指定套餐激活",
|
||
zap.Uint("usage_id", currentUsage.ID),
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return nil
|
||
}
|
||
|
||
canActivate, err := s.canActivatePendingUsage(ctx, tx, ¤tUsage, carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !canActivate {
|
||
s.logger.Info("指定套餐暂不满足激活条件",
|
||
zap.Uint("usage_id", currentUsage.ID),
|
||
zap.Bool("pending_realname_activation", currentUsage.PendingRealnameActivation))
|
||
return nil
|
||
}
|
||
}
|
||
|
||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 异步复机
|
||
if s.resumeCallback != nil {
|
||
go func(ct string, cid uint) {
|
||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||
s.logger.Error("激活后自动复机失败",
|
||
zap.String("carrier_type", ct),
|
||
zap.Uint("carrier_id", cid),
|
||
zap.Error(err))
|
||
}
|
||
}(carrierType, carrierID)
|
||
}
|
||
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
|
||
// 返回 true 表示本次成功激活了一个主套餐;队首套餐未满足实名条件时返回 false。
|
||
func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
|
||
if carrierType != constants.AssetTypeIotCard && carrierType != constants.AssetTypeDevice {
|
||
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||
}
|
||
if carrierID == 0 {
|
||
return false, errors.New(errors.CodeInvalidParam, "无效的载体ID")
|
||
}
|
||
|
||
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
|
||
lockValue := time.Now().String()
|
||
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
|
||
if err != nil {
|
||
return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
|
||
}
|
||
if !locked {
|
||
s.logger.Warn("套餐激活正在进行中,跳过",
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return false, errors.New(errors.CodePackageActivationConflict)
|
||
}
|
||
defer s.redis.Del(ctx, lockKey)
|
||
|
||
activated := false
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if hasActive {
|
||
return nil
|
||
}
|
||
|
||
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !canActivate {
|
||
s.logger.Info("队首待生效套餐暂不满足激活条件",
|
||
zap.Uint("usage_id", nextMain.ID),
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID),
|
||
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
|
||
return nil
|
||
}
|
||
|
||
var pkg model.Package
|
||
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||
}
|
||
|
||
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil {
|
||
return err
|
||
}
|
||
activated = true
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
return activated, nil
|
||
}
|
||
|
||
// HasActiveMainPackage 查询载体当前是否存在生效中的主套餐。
|
||
func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
|
||
return s.hasActiveMainPackage(ctx, s.db.WithContext(ctx), carrierType, carrierID)
|
||
}
|
||
|
||
// invalidateAddons 任务 9.7: 加油包级联失效
|
||
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||
var addons []*model.PackageUsage
|
||
if err := tx.Where("master_usage_id = ?", masterUsageID).
|
||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}).
|
||
Find(&addons).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
|
||
}
|
||
|
||
if len(addons) == 0 {
|
||
return nil
|
||
}
|
||
|
||
addonIDs := make([]uint, len(addons))
|
||
for i, addon := range addons {
|
||
addonIDs[i] = addon.ID
|
||
}
|
||
|
||
// 批量更新加油包状态为已失效
|
||
if err := tx.Model(&model.PackageUsage{}).
|
||
Where("id IN ?", addonIDs).
|
||
Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
|
||
}
|
||
|
||
s.logger.Info("加油包已级联失效",
|
||
zap.Uint("master_usage_id", masterUsageID),
|
||
zap.Int("addon_count", len(addons)))
|
||
|
||
return nil
|
||
}
|
||
|
||
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
|
||
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error {
|
||
// 查询下一个待生效主套餐
|
||
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
|
||
if err == gorm.ErrRecordNotFound {
|
||
s.logger.Info("没有待生效的主套餐",
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !canActivate {
|
||
s.logger.Info("下一个待生效主套餐暂不满足激活条件",
|
||
zap.Uint("usage_id", nextMain.ID),
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID),
|
||
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
|
||
return nil
|
||
}
|
||
|
||
// 查询套餐信息
|
||
var pkg model.Package
|
||
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||
}
|
||
|
||
return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活")
|
||
}
|
||
|
||
func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) {
|
||
var nextMain model.PackageUsage
|
||
err := tx.WithContext(ctx).
|
||
Where("status = ?", constants.PackageUsageStatusPending).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Order("priority ASC, created_at ASC").
|
||
First(&nextMain).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, err
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败")
|
||
}
|
||
return &nextMain, nil
|
||
}
|
||
|
||
func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||
var count int64
|
||
// 生效中(1)和已用完(2)都属于"占位"状态:已用完的套餐仍在有效期内,不允许提前激活下一个主套餐
|
||
if err := tx.WithContext(ctx).
|
||
Model(&model.PackageUsage{}).
|
||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Count(&count).Error; err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败")
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
func (s *ActivationService) canActivatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) (bool, error) {
|
||
if usage == nil {
|
||
return false, nil
|
||
}
|
||
if !usage.PendingRealnameActivation {
|
||
return true, nil
|
||
}
|
||
return s.isCarrierRealnamed(ctx, tx, carrierType, carrierID)
|
||
}
|
||
|
||
func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||
switch carrierType {
|
||
case constants.AssetTypeIotCard:
|
||
var card model.IotCard
|
||
if err := tx.WithContext(ctx).
|
||
Select("real_name_status").
|
||
First(&card, carrierID).Error; err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名状态失败")
|
||
}
|
||
return card.RealNameStatus == constants.RealNameStatusVerified, nil
|
||
case constants.AssetTypeDevice:
|
||
var count int64
|
||
subQuery := tx.WithContext(ctx).
|
||
Model(&model.DeviceSimBinding{}).
|
||
Select("iot_card_id").
|
||
Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound)
|
||
if err := tx.WithContext(ctx).
|
||
Model(&model.IotCard{}).
|
||
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
|
||
Count(&count).Error; err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名状态失败")
|
||
}
|
||
return count > 0, nil
|
||
default:
|
||
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||
}
|
||
}
|
||
|
||
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
|
||
var activatedAt time.Time
|
||
if pkg.ExpiryBase == "from_purchase" {
|
||
activatedAt = usage.CreatedAt
|
||
} else {
|
||
activatedAt = now
|
||
}
|
||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||
|
||
// 计算下次重置时间
|
||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||
|
||
// 更新套餐使用记录
|
||
updates := map[string]interface{}{
|
||
"status": constants.PackageUsageStatusActive,
|
||
"pending_realname_activation": false,
|
||
"activated_at": activatedAt,
|
||
"expires_at": expiresAt,
|
||
}
|
||
if nextResetAt != nil {
|
||
updates["next_reset_at"] = *nextResetAt
|
||
}
|
||
|
||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
|
||
}
|
||
|
||
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
|
||
|
||
s.logger.Info(logMessage,
|
||
zap.Uint("usage_id", usage.ID),
|
||
zap.Uint("package_id", usage.PackageID),
|
||
zap.Time("activated_at", activatedAt),
|
||
zap.Time("expires_at", expiresAt))
|
||
|
||
if s.resumeCallback != nil {
|
||
go func(ct string, cid uint) {
|
||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||
s.logger.Error("排队激活后自动复机失败",
|
||
zap.String("carrier_type", ct),
|
||
zap.Uint("carrier_id", cid),
|
||
zap.Error(err))
|
||
}
|
||
}(carrierType, carrierID)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
|
||
if usage == nil {
|
||
return
|
||
}
|
||
|
||
if usage.UsageType == constants.PackageUsageTypeDevice || usage.DeviceID > 0 || carrierType == constants.AssetTypeDevice {
|
||
// 设备激活状态由“生效中主套餐 + 任意绑定卡已实名”动态计算,
|
||
// 不再写入设备归属状态字段。
|
||
return
|
||
}
|
||
|
||
cardID := usage.IotCardID
|
||
if cardID == 0 {
|
||
cardID = carrierID
|
||
}
|
||
if cardID == 0 {
|
||
return
|
||
}
|
||
|
||
if err := tx.WithContext(ctx).
|
||
Model(&model.IotCard{}).
|
||
Where("id = ? AND status < ?", cardID, constants.IotCardStatusActivated).
|
||
Update("status", constants.IotCardStatusActivated).Error; err != nil {
|
||
s.logger.Warn("套餐激活后更新卡状态失败",
|
||
zap.Uint("iot_card_id", cardID),
|
||
zap.Uint("usage_id", usage.ID),
|
||
zap.Error(err))
|
||
}
|
||
}
|
||
|
||
// InvalidatePackagesForRefund 退款精准失效套餐并写入退款快照
|
||
// 规则:
|
||
// 1. 优先按 packageUsageID 精准失效单条套餐(且必须属于当前退款订单)
|
||
// 2. 未传 packageUsageID 时,按 orderID 失效该订单生成的套餐
|
||
// 3. 若命中主套餐,级联失效其加油包,避免残留挂靠关系
|
||
func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, assetType string, assetID uint, orderID uint, refundID uint, refundNo string, packageUsageID *uint) error {
|
||
if orderID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
||
}
|
||
|
||
validStatuses := []int{
|
||
constants.PackageUsageStatusPending,
|
||
constants.PackageUsageStatusActive,
|
||
constants.PackageUsageStatusDepleted,
|
||
}
|
||
|
||
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||
switch assetType {
|
||
case "iot_card":
|
||
baseQuery = baseQuery.Where("iot_card_id = ?", assetID)
|
||
case "device":
|
||
baseQuery = baseQuery.Where("device_id = ?", assetID)
|
||
default:
|
||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||
}
|
||
|
||
var targets []model.PackageUsage
|
||
if packageUsageID != nil && *packageUsageID > 0 {
|
||
var usage model.PackageUsage
|
||
err := baseQuery.
|
||
Where("id = ? AND order_id = ?", *packageUsageID, orderID).
|
||
Where("status IN ?", validStatuses).
|
||
First(&usage).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
s.logger.Info("退款精准失效:未命中可失效套餐",
|
||
zap.String("asset_type", assetType),
|
||
zap.Uint("asset_id", assetID),
|
||
zap.Uint("order_id", orderID),
|
||
zap.Uint("package_usage_id", *packageUsageID),
|
||
)
|
||
return nil
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
|
||
}
|
||
targets = append(targets, usage)
|
||
} else {
|
||
if err := baseQuery.
|
||
Where("order_id = ?", orderID).
|
||
Where("status IN ?", validStatuses).
|
||
Find(&targets).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败")
|
||
}
|
||
if len(targets) == 0 {
|
||
s.logger.Info("退款精准失效:订单无可失效套餐",
|
||
zap.String("asset_type", assetType),
|
||
zap.Uint("asset_id", assetID),
|
||
zap.Uint("order_id", orderID),
|
||
)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
targetIDSet := make(map[uint]struct{}, len(targets))
|
||
mainUsageIDs := make([]uint, 0, len(targets))
|
||
for _, usage := range targets {
|
||
targetIDSet[usage.ID] = struct{}{}
|
||
if usage.MasterUsageID == nil {
|
||
mainUsageIDs = append(mainUsageIDs, usage.ID)
|
||
}
|
||
}
|
||
|
||
if len(mainUsageIDs) > 0 {
|
||
var addons []model.PackageUsage
|
||
if err := baseQuery.
|
||
Where("master_usage_id IN ?", mainUsageIDs).
|
||
Where("status IN ?", validStatuses).
|
||
Find(&addons).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
|
||
}
|
||
for _, addon := range addons {
|
||
targetIDSet[addon.ID] = struct{}{}
|
||
}
|
||
}
|
||
|
||
targetIDs := make([]uint, 0, len(targetIDSet))
|
||
for id := range targetIDSet {
|
||
targetIDs = append(targetIDs, id)
|
||
}
|
||
if len(targetIDs) == 0 {
|
||
return nil
|
||
}
|
||
|
||
updates := map[string]any{
|
||
"status": constants.PackageUsageStatusInvalidated,
|
||
}
|
||
if refundID > 0 {
|
||
updates["refund_id"] = refundID
|
||
}
|
||
if refundNo != "" {
|
||
updates["refund_no"] = refundNo
|
||
}
|
||
|
||
result := s.db.WithContext(ctx).
|
||
Model(&model.PackageUsage{}).
|
||
Where("id IN ?", targetIDs).
|
||
Updates(updates)
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
|
||
}
|
||
|
||
s.logger.Info("退款精准失效套餐完成",
|
||
zap.String("asset_type", assetType),
|
||
zap.Uint("asset_id", assetID),
|
||
zap.Uint("order_id", orderID),
|
||
zap.Uint("refund_id", refundID),
|
||
zap.String("refund_no", refundNo),
|
||
zap.Int("target_count", len(targetIDs)),
|
||
zap.Int64("affected", result.RowsAffected),
|
||
)
|
||
|
||
return nil
|
||
}
|
||
|
||
// InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐
|
||
// 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4)
|
||
func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error {
|
||
query := s.db.WithContext(ctx).
|
||
Model(&model.PackageUsage{}).
|
||
Where("status IN ?", []int{
|
||
constants.PackageUsageStatusPending,
|
||
constants.PackageUsageStatusActive,
|
||
constants.PackageUsageStatusDepleted,
|
||
})
|
||
|
||
switch assetType {
|
||
case "iot_card":
|
||
query = query.Where("iot_card_id = ?", assetID)
|
||
case "device":
|
||
query = query.Where("device_id = ?", assetID)
|
||
default:
|
||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||
}
|
||
|
||
result := query.Update("status", constants.PackageUsageStatusInvalidated)
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
|
||
}
|
||
|
||
s.logger.Info("批量失效套餐完成",
|
||
zap.String("asset_type", assetType),
|
||
zap.Uint("asset_id", assetID),
|
||
zap.Int64("affected", result.RowsAffected),
|
||
)
|
||
|
||
return nil
|
||
}
|