Files
junhong_cmp_fiber/internal/service/package/activation_service.go
huang c665f32976
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入
- 实现套餐流量重置服务(日/月/年周期重置)
- 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑
- 新增订单创建幂等性防重(Redis 业务键 + 分布式锁)
- 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求
- 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南)
- 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录
- 新增 queue types 抽象和 Redis 常量定义
2026-02-12 14:24:15 +08:00

317 lines
10 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 任务 24.7: 复机回调接口
// 用于在套餐激活后触发自动复机
type ResumeCallback interface {
// ResumeCardIfStopped 购买套餐后自动复机
ResumeCardIfStopped(ctx context.Context, cardID 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, "检查生效中主套餐失败")
}
}
// 激活套餐
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.logger.Info("套餐已激活",
zap.Uint("usage_id", usage.ID),
zap.Uint("package_id", usage.PackageID),
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
// 任务 24.7: 在套餐激活后触发自动复机
if s.resumeCallback != nil && carrierType == "iot_card" {
go func(cardID uint) {
resumeCtx := context.Background()
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil {
s.logger.Error("自动复机失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}(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, "更新过期主套餐状态失败")
}
s.logger.Info("主套餐已过期",
zap.Uint("usage_id", expiredMain.ID),
zap.Time("expires_at", expiredMain.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
})
}
// 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, "查询套餐信息失败")
}
// 激活套餐
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.logger.Info("排队主套餐已激活",
zap.Uint("usage_id", nextMain.ID),
zap.Uint("package_id", nextMain.PackageID),
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
// 任务 24.7: 在套餐激活后触发自动复机
if s.resumeCallback != nil && carrierType == "iot_card" {
go func(cardID uint) {
resumeCtx := context.Background()
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil {
s.logger.Error("排队激活后自动复机失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}(carrierID)
}
return nil
}