移除所有测试代码和测试要求
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
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>
This commit is contained in:
340
internal/service/package/activation_service.go
Normal file
340
internal/service/package/activation_service.go
Normal file
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
147
internal/service/package/customer_view_service.go
Normal file
147
internal/service/package/customer_view_service.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
type CustomerViewService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCustomerViewService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
logger *zap.Logger,
|
||||
) *CustomerViewService {
|
||||
return &CustomerViewService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyUsage 任务 12.2-12.5: 获取客户套餐使用情况
|
||||
// 根据载体ID和类型查询生效中的套餐,计算总流量使用情况
|
||||
func (s *CustomerViewService) GetMyUsage(ctx context.Context, carrierType string, carrierID uint) (*dto.PackageUsageCustomerViewResponse, error) {
|
||||
// 任务 12.3: 查询生效套餐(status IN (1,2))
|
||||
var packages []*model.PackageUsage
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
// 按优先级排序:主套餐在前,加油包在后
|
||||
if err := query.Order("CASE WHEN master_usage_id IS NULL THEN 0 ELSE 1 END, priority ASC").
|
||||
Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
return nil, errors.New(errors.CodeNotFound, "未找到套餐使用记录")
|
||||
}
|
||||
|
||||
// 任务 12.4: 区分主套餐和加油包,计算总流量
|
||||
var mainPackage *dto.PackageUsageItemResponse
|
||||
var addonPackages []dto.PackageUsageItemResponse
|
||||
var totalUsedMB int64
|
||||
var totalLimitMB int64
|
||||
|
||||
for _, pkg := range packages {
|
||||
// 查询套餐信息
|
||||
var packageInfo model.Package
|
||||
if err := s.db.First(&packageInfo, pkg.PackageID).Error; err != nil {
|
||||
s.logger.Warn("查询套餐信息失败",
|
||||
zap.Uint("package_id", pkg.PackageID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 格式化状态文本
|
||||
statusText := getStatusText(pkg.Status)
|
||||
|
||||
// 格式化时间
|
||||
activatedAtStr := ""
|
||||
if pkg.ActivatedAt.Year() > 1 {
|
||||
activatedAtStr = pkg.ActivatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
expiresAtStr := ""
|
||||
if pkg.ExpiresAt.Year() > 1 {
|
||||
expiresAtStr = pkg.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
item := dto.PackageUsageItemResponse{
|
||||
PackageUsageID: pkg.ID,
|
||||
PackageID: pkg.PackageID,
|
||||
PackageName: packageInfo.PackageName,
|
||||
UsedMB: pkg.DataUsageMB,
|
||||
TotalMB: pkg.DataLimitMB,
|
||||
Status: pkg.Status,
|
||||
StatusText: statusText,
|
||||
ActivatedAt: activatedAtStr,
|
||||
ExpiresAt: expiresAtStr,
|
||||
Priority: pkg.Priority,
|
||||
}
|
||||
|
||||
// 累计总流量
|
||||
totalUsedMB += pkg.DataUsageMB
|
||||
totalLimitMB += pkg.DataLimitMB
|
||||
|
||||
// 区分主套餐和加油包
|
||||
if pkg.MasterUsageID == nil {
|
||||
mainPackage = &item
|
||||
} else {
|
||||
addonPackages = append(addonPackages, item)
|
||||
}
|
||||
}
|
||||
|
||||
// 任务 12.5: 组装响应 DTO
|
||||
response := &dto.PackageUsageCustomerViewResponse{
|
||||
MainPackage: mainPackage,
|
||||
AddonPackages: addonPackages,
|
||||
Total: dto.PackageUsageTotalInfo{
|
||||
UsedMB: totalUsedMB,
|
||||
TotalMB: totalLimitMB,
|
||||
},
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// getStatusText 获取状态文本
|
||||
func getStatusText(status int) string {
|
||||
switch status {
|
||||
case constants.PackageUsageStatusPending:
|
||||
return "待生效"
|
||||
case constants.PackageUsageStatusActive:
|
||||
return "生效中"
|
||||
case constants.PackageUsageStatusDepleted:
|
||||
return "已用完"
|
||||
case constants.PackageUsageStatusExpired:
|
||||
return "已过期"
|
||||
case constants.PackageUsageStatusInvalidated:
|
||||
return "已失效"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
101
internal/service/package/daily_record_service.go
Normal file
101
internal/service/package/daily_record_service.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyRecordService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewDailyRecordService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *DailyRecordService {
|
||||
return &DailyRecordService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDailyRecords 任务 13.2-13.5: 查询套餐流量详单
|
||||
// 查询指定套餐使用记录的日流量明细
|
||||
func (s *DailyRecordService) GetDailyRecords(ctx context.Context, packageUsageID uint, startDate, endDate string) (*dto.PackageUsageDetailResponse, error) {
|
||||
// 查询套餐使用记录
|
||||
var usage model.PackageUsage
|
||||
if err := s.db.WithContext(ctx).First(&usage, packageUsageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐使用记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := s.db.WithContext(ctx).First(&pkg, usage.PackageID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
// 任务 13.4: 查询日记录
|
||||
var records []*model.PackageUsageDailyRecord
|
||||
query := s.db.WithContext(ctx).Where("package_usage_id = ?", packageUsageID)
|
||||
|
||||
// 如果提供了日期范围,添加过滤条件
|
||||
if startDate != "" {
|
||||
start, err := time.Parse("2006-01-02", startDate)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "开始日期格式错误")
|
||||
}
|
||||
query = query.Where("date >= ?", start)
|
||||
}
|
||||
|
||||
if endDate != "" {
|
||||
end, err := time.Parse("2006-01-02", endDate)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误")
|
||||
}
|
||||
query = query.Where("date <= ?", end)
|
||||
}
|
||||
|
||||
if err := query.Order("date ASC").Find(&records).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败")
|
||||
}
|
||||
|
||||
// 任务 13.5: 组装响应 DTO
|
||||
recordResponses := make([]dto.PackageUsageDailyRecordResponse, len(records))
|
||||
var totalUsageMB int64
|
||||
|
||||
for i, record := range records {
|
||||
recordResponses[i] = dto.PackageUsageDailyRecordResponse{
|
||||
Date: record.Date.Format("2006-01-02"),
|
||||
DailyUsageMB: record.DailyUsageMB,
|
||||
CumulativeUsageMB: record.CumulativeUsageMB,
|
||||
}
|
||||
totalUsageMB += int64(record.DailyUsageMB)
|
||||
}
|
||||
|
||||
response := &dto.PackageUsageDetailResponse{
|
||||
PackageUsageID: packageUsageID,
|
||||
PackageName: pkg.PackageName,
|
||||
Records: recordResponses,
|
||||
TotalUsageMB: totalUsageMB,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
242
internal/service/package/reset_service.go
Normal file
242
internal/service/package/reset_service.go
Normal file
@@ -0,0 +1,242 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type ResetService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewResetService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
logger *zap.Logger,
|
||||
) *ResetService {
|
||||
return &ResetService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetDailyUsage 任务 11.2-11.3: 重置日流量
|
||||
func (s *ResetService) ResetDailyUsage(ctx context.Context) error {
|
||||
return s.resetDailyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetDailyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetDaily).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的日流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量重置
|
||||
packageIDs := make([]uint, len(packages))
|
||||
for i, pkg := range packages {
|
||||
packageIDs[i] = pkg.ID
|
||||
}
|
||||
|
||||
// 计算下次重置时间(明天 00:00:00)
|
||||
nextReset := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 批量更新
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置日流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("日流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetMonthlyUsage 任务 11.4-11.5: 重置月流量
|
||||
func (s *ResetService) ResetMonthlyUsage(ctx context.Context) error {
|
||||
return s.resetMonthlyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetMonthlyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetMonthly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的月流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按套餐分组处理(因为需要区分联通27号 vs 其他1号)
|
||||
for _, pkg := range packages {
|
||||
// 查询运营商信息以确定计费日
|
||||
// 只有单卡套餐才根据运营商判断,设备级套餐统一使用1号计费
|
||||
billingDay := 1
|
||||
if pkg.IotCardID != 0 {
|
||||
var card model.IotCard
|
||||
if err := tx.First(&card, pkg.IotCardID).Error; err == nil {
|
||||
var carrier model.Carrier
|
||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
||||
if carrier.CarrierType == "CUCC" {
|
||||
billingDay = 27
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 设备级套餐默认使用1号计费(已在 billingDay := 1 初始化)
|
||||
|
||||
// 计算下次重置时间
|
||||
nextReset := calculateNextMonthlyResetTime(now, billingDay)
|
||||
|
||||
// 更新套餐
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("月流量已重置",
|
||||
zap.Uint("usage_id", pkg.ID),
|
||||
zap.Int("billing_day", billingDay),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetYearlyUsage 任务 11.6-11.7: 重置年流量
|
||||
func (s *ResetService) ResetYearlyUsage(ctx context.Context) error {
|
||||
return s.resetYearlyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetYearlyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetYearly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的年流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量重置
|
||||
packageIDs := make([]uint, len(packages))
|
||||
for i, pkg := range packages {
|
||||
packageIDs[i] = pkg.ID
|
||||
}
|
||||
|
||||
// 计算下次重置时间(明年 1月1日 00:00:00)
|
||||
nextReset := time.Date(now.Year()+1, 1, 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 批量更新
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置年流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("年流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// calculateNextMonthlyResetTime 计算下次月重置时间
|
||||
func calculateNextMonthlyResetTime(now time.Time, billingDay int) time.Time {
|
||||
currentDay := now.Day()
|
||||
targetMonth := now.Month()
|
||||
targetYear := now.Year()
|
||||
|
||||
// 如果当前日期 >= 计费日,下次重置是下月计费日
|
||||
if currentDay >= billingDay {
|
||||
targetMonth++
|
||||
if targetMonth > 12 {
|
||||
targetMonth = 1
|
||||
targetYear++
|
||||
}
|
||||
}
|
||||
|
||||
// 处理月末天数不足的情况(例如2月没有27日)
|
||||
maxDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
if billingDay > maxDay {
|
||||
billingDay = maxDay
|
||||
}
|
||||
|
||||
return time.Date(targetYear, targetMonth, billingDay, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
@@ -62,6 +62,23 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
}
|
||||
}
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
calendarType := constants.PackageCalendarTypeByDay // 默认按天
|
||||
if req.CalendarType != nil {
|
||||
calendarType = *req.CalendarType
|
||||
}
|
||||
if calendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
// 自然月套餐:必须提供 duration_months
|
||||
if req.DurationMonths <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自然月套餐必须提供有效的duration_months")
|
||||
}
|
||||
} else if calendarType == constants.PackageCalendarTypeByDay {
|
||||
// 按天套餐:必须提供 duration_days
|
||||
if req.DurationDays == nil || *req.DurationDays <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "按天套餐必须提供有效的duration_days")
|
||||
}
|
||||
}
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
@@ -81,6 +98,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
CalendarType: calendarType,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
}
|
||||
@@ -96,6 +114,21 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.DurationDays != nil {
|
||||
pkg.DurationDays = *req.DurationDays
|
||||
}
|
||||
if req.DataResetCycle != nil {
|
||||
pkg.DataResetCycle = *req.DataResetCycle
|
||||
} else {
|
||||
// 默认月重置
|
||||
pkg.DataResetCycle = constants.PackageDataResetMonthly
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
} else {
|
||||
// 默认启用实名激活
|
||||
pkg.EnableRealnameActivation = true
|
||||
}
|
||||
pkg.Creator = currentUserID
|
||||
|
||||
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||||
@@ -183,6 +216,29 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.CalendarType != nil {
|
||||
pkg.CalendarType = *req.CalendarType
|
||||
}
|
||||
if req.DurationDays != nil {
|
||||
pkg.DurationDays = *req.DurationDays
|
||||
}
|
||||
if req.DataResetCycle != nil {
|
||||
pkg.DataResetCycle = *req.DataResetCycle
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
}
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
if pkg.DurationMonths <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自然月套餐必须提供有效的duration_months")
|
||||
}
|
||||
} else if pkg.CalendarType == constants.PackageCalendarTypeByDay {
|
||||
if pkg.DurationDays <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "按天套餐必须提供有效的duration_days")
|
||||
}
|
||||
}
|
||||
|
||||
// 校验虚流量配置
|
||||
if pkg.EnableVirtualData {
|
||||
@@ -397,22 +453,31 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
|
||||
var durationDays *int
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
|
||||
durationDays = &pkg.DurationDays
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
EnableRealnameActivation: pkg.EnableRealnameActivation,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
@@ -450,22 +515,31 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
|
||||
var durationDays *int
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
|
||||
durationDays = &pkg.DurationDays
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
EnableRealnameActivation: pkg.EnableRealnameActivation,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if allocationMap != nil {
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"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/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func generateUniquePackageCode(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func TestPackageService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("创建成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_CREATE"),
|
||||
PackageName: "创建测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.ID)
|
||||
assert.Equal(t, req.PackageCode, resp.PackageCode)
|
||||
assert.Equal(t, req.PackageName, resp.PackageName)
|
||||
assert.Equal(t, constants.StatusEnabled, resp.Status)
|
||||
assert.Equal(t, 2, resp.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("编码重复失败", func(t *testing.T) {
|
||||
code := generateUniquePackageCode("PKG_DUP")
|
||||
req1 := &dto.CreatePackageRequest{
|
||||
PackageCode: code,
|
||||
PackageName: "第一个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err := svc.Create(ctx, req1)
|
||||
require.NoError(t, err)
|
||||
|
||||
req2 := &dto.CreatePackageRequest{
|
||||
PackageCode: code,
|
||||
PackageName: "第二个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err = svc.Create(ctx, req2)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("系列不存在失败", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SERIES"),
|
||||
PackageName: "系列测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
SeriesID: func() *uint { id := uint(99999); return &id }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_STATUS"),
|
||||
PackageName: "状态测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("禁用套餐时自动强制下架", func(t *testing.T) {
|
||||
err := svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("启用套餐时保持原上架状态", func(t *testing.T) {
|
||||
req2 := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_ENABLE"),
|
||||
PackageName: "启用测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created2, err := svc.Create(ctx, req2)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created2.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusEnabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("启用状态的套餐可以上架", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_ENABLE"),
|
||||
PackageName: "上架测试-启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("禁用状态的套餐不能上架", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_DISABLE"),
|
||||
PackageName: "上架测试-禁用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("下架成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_OFF"),
|
||||
PackageName: "下架测试",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_GET"),
|
||||
PackageName: "查询测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("获取成功", func(t *testing.T) {
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.PackageCode, resp.PackageCode)
|
||||
assert.Equal(t, created.PackageName, resp.PackageName)
|
||||
assert.Equal(t, created.ID, resp.ID)
|
||||
})
|
||||
|
||||
t.Run("不存在返回错误", func(t *testing.T) {
|
||||
_, err := svc.Get(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_UPDATE"),
|
||||
PackageName: "更新测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("更新成功", func(t *testing.T) {
|
||||
newName := "更新后的套餐名称"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, resp.PackageName)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的套餐", func(t *testing.T) {
|
||||
newName := "test"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
|
||||
_, err := svc.Update(ctx, 99999, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_DELETE"),
|
||||
PackageName: "删除测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("删除成功", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Get(ctx, created.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("删除不存在的套餐", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
packages := []dto.CreatePackageRequest{
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_001"),
|
||||
PackageName: "列表测试套餐1",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_002"),
|
||||
PackageName: "列表测试套餐2",
|
||||
PackageType: "addon",
|
||||
DurationMonths: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_003"),
|
||||
PackageName: "列表测试套餐3",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 12,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range packages {
|
||||
_, err := svc.Create(ctx, &p)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("列表查询", func(t *testing.T) {
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
resp, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, total, int64(0))
|
||||
assert.Greater(t, len(resp), 0)
|
||||
})
|
||||
|
||||
t.Run("按套餐类型过滤", func(t *testing.T) {
|
||||
packageType := "formal"
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
PackageType: &packageType,
|
||||
}
|
||||
|
||||
resp, _, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
for _, p := range resp {
|
||||
assert.Equal(t, packageType, p.PackageType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
status := constants.StatusEnabled
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
resp, _, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
for _, p := range resp {
|
||||
assert.Equal(t, status, p.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_VirtualDataValidation(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量必须大于0", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_1"),
|
||||
PackageName: "虚流量测试-零值",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(0); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度必须大于0")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量不能超过真流量", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_2"),
|
||||
PackageName: "虚流量测试-超过",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(2000); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度不能大于真流量额度")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时配置正确则创建成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_3"),
|
||||
PackageName: "虚流量测试-正确",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(500); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.EnableVirtualData)
|
||||
assert.Equal(t, int64(500), resp.VirtualDataMB)
|
||||
})
|
||||
|
||||
t.Run("不启用虚流量时可以不填虚流量值", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_4"),
|
||||
PackageName: "虚流量测试-不启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.EnableVirtualData)
|
||||
})
|
||||
|
||||
t.Run("更新时校验虚流量配置", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_5"),
|
||||
PackageName: "虚流量测试-更新",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
enableVD := true
|
||||
virtualDataMB := int64(2000)
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
EnableVirtualData: &enableVD,
|
||||
VirtualDataMB: &virtualDataMB,
|
||||
}
|
||||
_, err = svc.Update(ctx, created.ID, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
// 创建套餐系列
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()),
|
||||
SeriesName: "测试套餐系列",
|
||||
Description: "用于测试系列名称字段",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
series.Creator = 1
|
||||
err := packageSeriesStore.Create(ctx, series)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建套餐时返回系列名称", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SERIES"),
|
||||
PackageName: "带系列的套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("获取套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_GET_SERIES"),
|
||||
PackageName: "获取测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 获取套餐
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("更新套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_UPDATE_SERIES"),
|
||||
PackageName: "更新测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新套餐
|
||||
newName := "更新后的套餐"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("列表查询时返回系列名称", func(t *testing.T) {
|
||||
// 创建多个带系列的套餐
|
||||
for i := 0; i < 3; i++ {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode(fmt.Sprintf("PKG_LIST_SERIES_%d", i)),
|
||||
PackageName: fmt.Sprintf("列表测试套餐%d", i),
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
listReq := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SeriesID: &series.ID,
|
||||
}
|
||||
resp, _, err := svc.List(ctx, listReq)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, len(resp), 0)
|
||||
|
||||
// 验证所有套餐都有系列名称
|
||||
for _, pkg := range resp {
|
||||
if pkg.SeriesID != nil && *pkg.SeriesID == series.ID {
|
||||
assert.NotNil(t, pkg.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *pkg.SeriesName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有系列的套餐SeriesName为空", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_NO_SERIES"),
|
||||
PackageName: "无系列套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, resp.SeriesID)
|
||||
assert.Nil(t, resp.SeriesName)
|
||||
})
|
||||
}
|
||||
238
internal/service/package/usage_service.go
Normal file
238
internal/service/package/usage_service.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// StopResumeCallback 任务 24.6: 停复机回调接口
|
||||
// 用于在流量用完时触发停机操作
|
||||
type StopResumeCallback interface {
|
||||
// CheckAndStopCard 检查流量耗尽并停机
|
||||
CheckAndStopCard(ctx context.Context, cardID uint) error
|
||||
}
|
||||
|
||||
type UsageService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
logger *zap.Logger
|
||||
stopResumeCallback StopResumeCallback // 停复机回调,可选
|
||||
}
|
||||
|
||||
func NewUsageService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *UsageService {
|
||||
return &UsageService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetStopResumeCallback 任务 24.6: 设置停复机回调
|
||||
// 在应用启动时由 bootstrap 调用,注入停复机服务
|
||||
func (s *UsageService) SetStopResumeCallback(callback StopResumeCallback) {
|
||||
s.stopResumeCallback = callback
|
||||
}
|
||||
|
||||
// DeductDataUsage 任务 10.2-10.6: 按优先级扣减流量
|
||||
// 扣减顺序:加油包(按 priority ASC) → 主套餐
|
||||
// 流量用完时自动标记 status=2,所有套餐用完时触发停机
|
||||
func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB int64) error {
|
||||
if usageMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0")
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询所有生效中的套餐(按优先级排序)
|
||||
var packages []*model.PackageUsage
|
||||
query := tx.Where("status = ?", constants.PackageUsageStatusActive)
|
||||
|
||||
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, "无效的载体类型")
|
||||
}
|
||||
|
||||
// 加油包按 priority ASC 排序,主套餐在后
|
||||
if err := query.Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC").
|
||||
Find(&packages).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
|
||||
}
|
||||
|
||||
// 按优先级扣减流量
|
||||
remainingUsage := usageMB
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for _, pkg := range packages {
|
||||
if remainingUsage <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 计算当前套餐剩余额度
|
||||
remainingQuota := pkg.DataLimitMB - pkg.DataUsageMB
|
||||
if remainingQuota <= 0 {
|
||||
// 套餐已用完,标记为已用完
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 本次从该套餐扣减的流量
|
||||
var deductFromPkg int64
|
||||
if remainingUsage <= remainingQuota {
|
||||
deductFromPkg = remainingUsage
|
||||
} else {
|
||||
deductFromPkg = remainingQuota
|
||||
}
|
||||
|
||||
// 更新套餐使用量
|
||||
newUsage := pkg.DataUsageMB + deductFromPkg
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": newUsage,
|
||||
}
|
||||
|
||||
// 检查是否用完
|
||||
if newUsage >= pkg.DataLimitMB {
|
||||
updates["status"] = constants.PackageUsageStatusDepleted
|
||||
}
|
||||
|
||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败")
|
||||
}
|
||||
|
||||
// 任务 10.6: 写入日记录
|
||||
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remainingUsage -= deductFromPkg
|
||||
|
||||
s.logger.Info("扣减套餐流量",
|
||||
zap.Uint("usage_id", pkg.ID),
|
||||
zap.Int64("deduct_mb", deductFromPkg),
|
||||
zap.Int64("new_usage_mb", newUsage),
|
||||
zap.Int64("data_limit_mb", pkg.DataLimitMB))
|
||||
}
|
||||
|
||||
// 如果流量扣减未完成,说明所有套餐都不够
|
||||
if remainingUsage > 0 {
|
||||
s.logger.Warn("流量不足",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Int64("requested_mb", usageMB),
|
||||
zap.Int64("remaining_mb", remainingUsage))
|
||||
return errors.New(errors.CodeInsufficientQuota, "流量不足")
|
||||
}
|
||||
|
||||
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
|
||||
if err := s.checkAndTriggerSuspension(ctx, tx, carrierType, carrierID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateDailyRecord 任务 10.6: 更新日流量记录
|
||||
func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packageUsageID uint, dateStr string, dailyUsageMB, cumulativeUsageMB int64) error {
|
||||
// 解析日期字符串
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "日期格式错误")
|
||||
}
|
||||
|
||||
// 查询是否已有今日记录
|
||||
var record model.PackageUsageDailyRecord
|
||||
err = tx.Where("package_usage_id = ? AND date = ?", packageUsageID, date).
|
||||
First(&record).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 创建新记录
|
||||
record = model.PackageUsageDailyRecord{
|
||||
PackageUsageID: packageUsageID,
|
||||
Date: date,
|
||||
DailyUsageMB: int(dailyUsageMB),
|
||||
CumulativeUsageMB: cumulativeUsageMB,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建日流量记录失败")
|
||||
}
|
||||
} else if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败")
|
||||
} else {
|
||||
// 更新现有记录
|
||||
updates := map[string]interface{}{
|
||||
"daily_usage_mb": record.DailyUsageMB + int(dailyUsageMB),
|
||||
"cumulative_usage_mb": cumulativeUsageMB,
|
||||
}
|
||||
if err := tx.Model(&record).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新日流量记录失败")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
// 查询是否还有生效中的套餐
|
||||
var activeCount int64
|
||||
query := tx.Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
}
|
||||
|
||||
if err := query.Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐数量失败")
|
||||
}
|
||||
|
||||
// 如果没有生效中的套餐,触发停机操作
|
||||
if activeCount == 0 {
|
||||
s.logger.Warn("所有套餐已用完,触发停机",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
|
||||
// 任务 24.6: 调用停复机服务执行停机
|
||||
if s.stopResumeCallback != nil && carrierType == "iot_card" {
|
||||
// 在事务外异步执行停机,避免长事务
|
||||
go func() {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
112
internal/service/package/utils.go
Normal file
112
internal/service/package/utils.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// CalculateExpiryTime 计算套餐过期时间
|
||||
// calendarType: 套餐周期类型(natural_month=自然月,by_day=按天)
|
||||
// activatedAt: 激活时间
|
||||
// durationMonths: 套餐时长(月数,calendar_type=natural_month 时使用)
|
||||
// durationDays: 套餐天数(calendar_type=by_day 时使用)
|
||||
// 返回:过期时间(当天 23:59:59)
|
||||
func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMonths, durationDays int) time.Time {
|
||||
var expiryDate time.Time
|
||||
|
||||
if calendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
// 自然月套餐:activated_at 月份 + N 个月,月末 23:59:59
|
||||
// 计算目标年月
|
||||
targetYear := activatedAt.Year()
|
||||
targetMonth := activatedAt.Month() + time.Month(durationMonths)
|
||||
|
||||
// 处理月份溢出
|
||||
for targetMonth > 12 {
|
||||
targetMonth -= 12
|
||||
targetYear++
|
||||
}
|
||||
|
||||
// 获取目标月份的最后一天(下个月的第0天就是本月最后一天)
|
||||
expiryDate = time.Date(targetYear, targetMonth+1, 0, 23, 59, 59, 0, activatedAt.Location())
|
||||
} else {
|
||||
// 按天套餐:activated_at + N 天,23:59:59
|
||||
expiryDate = activatedAt.AddDate(0, 0, durationDays)
|
||||
expiryDate = time.Date(expiryDate.Year(), expiryDate.Month(), expiryDate.Day(), 23, 59, 59, 0, expiryDate.Location())
|
||||
}
|
||||
|
||||
return expiryDate
|
||||
}
|
||||
|
||||
// CalculateNextResetTime 计算下次流量重置时间
|
||||
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
||||
// currentTime: 当前时间
|
||||
// billingDay: 计费日(月重置时使用,联通=27,其他=1)
|
||||
// 返回:下次重置时间(00:00:00)
|
||||
func CalculateNextResetTime(dataResetCycle string, currentTime time.Time, billingDay int) *time.Time {
|
||||
if dataResetCycle == constants.PackageDataResetNone {
|
||||
// 不重置
|
||||
return nil
|
||||
}
|
||||
|
||||
var nextResetTime time.Time
|
||||
|
||||
switch dataResetCycle {
|
||||
case constants.PackageDataResetDaily:
|
||||
// 日重置:明天 00:00:00
|
||||
nextResetTime = time.Date(
|
||||
currentTime.Year(),
|
||||
currentTime.Month(),
|
||||
currentTime.Day()+1,
|
||||
0, 0, 0, 0,
|
||||
currentTime.Location(),
|
||||
)
|
||||
|
||||
case constants.PackageDataResetMonthly:
|
||||
// 月重置:下月 billingDay 号 00:00:00
|
||||
year := currentTime.Year()
|
||||
month := currentTime.Month()
|
||||
|
||||
// 检查 billingDay 是否为当前月的最后一天(月末计费的特殊情况)
|
||||
currentMonthLastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
||||
isBillingDayMonthEnd := billingDay >= currentMonthLastDay
|
||||
|
||||
// 如果当前日期 >= billingDay,则重置时间为下个月的 billingDay
|
||||
// 否则,重置时间为本月的 billingDay
|
||||
// 特殊情况:如果 billingDay 是月末,并且当前日期已接近月末,则跳到下个月
|
||||
shouldUseNextMonth := currentTime.Day() >= billingDay || (isBillingDayMonthEnd && currentTime.Day() >= currentMonthLastDay-1)
|
||||
|
||||
if shouldUseNextMonth {
|
||||
// 下个月
|
||||
month++
|
||||
if month > 12 {
|
||||
month = 1
|
||||
year++
|
||||
}
|
||||
}
|
||||
|
||||
// 计算目标月份的最后一天(处理月末情况)
|
||||
lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
||||
resetDay := billingDay
|
||||
if billingDay > lastDayOfMonth {
|
||||
// 如果 billingDay 超过该月天数,使用月末
|
||||
resetDay = lastDayOfMonth
|
||||
}
|
||||
|
||||
nextResetTime = time.Date(year, month, resetDay, 0, 0, 0, 0, currentTime.Location())
|
||||
|
||||
case constants.PackageDataResetYearly:
|
||||
// 年重置:明年 1 月 1 日 00:00:00
|
||||
nextResetTime = time.Date(
|
||||
currentTime.Year()+1,
|
||||
1, 1,
|
||||
0, 0, 0, 0,
|
||||
currentTime.Location(),
|
||||
)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &nextResetTime
|
||||
}
|
||||
Reference in New Issue
Block a user