Files
junhong_cmp_fiber/internal/service/package/activation_service.go
huang 353621d923
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
移除所有测试代码和测试要求
**变更说明**:
- 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试)
- 删除整个 tests/ 目录
- 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求
- 删除测试生成 Skill (openspec-generate-acceptance-tests)
- 删除测试生成命令 (opsx:gen-tests)
- 更新 tasks.md:删除所有测试相关任务

**新规范**:
-  禁止编写任何形式的自动化测试
-  禁止创建 *_test.go 文件
-  禁止在任务中包含测试相关工作
-  仅当用户明确要求时才编写测试

**原因**:
业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 17:13:42 +08:00

341 lines
11 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)
// 计算下次重置时间
billingDay := 1 // 默认1号计费
if carrierType == "iot_card" {
var card model.IotCard
if err := tx.First(&card, carrierID).Error; err == nil {
var carrier model.Carrier
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
if carrier.CarrierType == "CUCC" {
billingDay = 27 // 联通27号计费
}
}
}
}
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
// 更新套餐使用记录
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)
// 计算下次重置时间
billingDay := 1
if carrierType == "iot_card" {
var card model.IotCard
if err := tx.First(&card, carrierID).Error; err == nil {
var carrier model.Carrier
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
if carrier.CarrierType == "CUCC" {
billingDay = 27
}
}
}
}
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
// 更新套餐使用记录
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
}