Files
junhong_cmp_fiber/internal/service/package/activation_service.go
huang 8d2e8faa84
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m43s
修复退款bug
2026-04-23 11:30:46 +08:00

639 lines
21 KiB
Go
Raw 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 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 = ?", constants.PackageUsageStatusActive).
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))
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(&currentUsage, packageUsageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重新查询套餐使用记录失败")
}
if currentUsage.Status != constants.PackageUsageStatusPending {
return nil
}
// 按 ExpiryBase 选择激活时间基准
now := time.Now()
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,
"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.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
// 异步复机
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
})
}
// 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 {
// 查询下一个待生效主套餐
var nextMain model.PackageUsage
err := tx.Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&nextMain).Error
if err == gorm.ErrRecordNotFound {
s.logger.Info("没有待生效的主套餐",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败")
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
// 激活套餐
// 按 ExpiryBase 选择激活计时基准from_purchase-购买时起算用 CreatedAt否则用当前时刻
var activatedAt time.Time
if pkg.ExpiryBase == "from_purchase" {
activatedAt = nextMain.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,
"activated_at": activatedAt,
"expires_at": expiresAt,
}
if nextResetAt != nil {
updates["next_reset_at"] = *nextResetAt
}
if err := tx.Model(&nextMain).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
}
s.syncCarrierStatusActivated(ctx, tx, &nextMain, carrierType, carrierID)
s.logger.Info("排队主套餐已激活",
zap.Uint("usage_id", nextMain.ID),
zap.Uint("package_id", nextMain.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 {
deviceID := usage.DeviceID
if deviceID == 0 {
deviceID = carrierID
}
if deviceID == 0 {
return
}
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ? AND status < ?", deviceID, constants.DeviceStatusActivated).
Update("status", constants.DeviceStatusActivated).Error; err != nil {
s.logger.Warn("套餐激活后更新设备状态失败",
zap.Uint("device_id", deviceID),
zap.Uint("usage_id", usage.ID),
zap.Error(err))
}
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, 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
}
result := s.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("id IN ?", targetIDs).
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.Uint("order_id", orderID),
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
}