Files
junhong_cmp_fiber/internal/service/package/activation_service.go
break aab56a6998
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
新增 000228 成对迁移 tb_polling_priority_item:卡、任务类型、状态、触发类型、来源订单/套餐使用记录、
触发次数与来源集合、尝试次数、失败原因、人工原因与操作者、店铺快照与各时间列;以活动项部分唯一索引
uq_polling_priority_item_active(仅 deleted_at IS NULL AND status IN ('pending','processing') 占键位)
表达「同卡同任务类型至多一条活动项」,另有状态/时间索引与全列注释;down 守卫在存在活动项或未终态行时
拒绝回滚并给出中文原因。

新增优先轮询请求可靠事件 polling.priority.requested(载荷版本 v1、事件键前缀 prio:)与消费者:只在原
业务事务内追加、幂等键稳定;消费者按卡 × 纳入任务类型(realname/carddata/card_status/package)逐条
建项并在提交后下发执行提示,重复投递只合并触发次数、来源集合与最近触发时间,不新建行也不重复调用。
触发点为四类自动场景 purchase_activated / renewal_activated(按同载体更早套餐使用记录判定)/
queue_activated / addon_activated 与「无有效套餐」no_valid_package(仅在普通套餐轮询来源且存在待生效
套餐使用记录时追加;事件通道显式拒绝 manual_trigger);入队对象恒为卡,绑定设备资产在触发事务内冻结
在用卡快照逐卡建项,不使用设备当前卡槽口径。

轮询共享基类新增认领接缝:四个 Handler(realname/carddata/card_status/package)在并发信号量之后、调用
上游之前探测活动项——待执行条件认领、执行中且 90 秒租约未到期则跳过并延后、无活动项时行为与既有完全
等价;超租约允许相邻执行接管,尝试次数只在真正发起执行后累加,未达上限(3)回到活动态按既有间隔重排,
达上限或业务校验类失败进入失败终态并保留可安全展示原因;执行前校验卡自身与绑定设备的轮询开关。未引入
通用卡级锁与 Redis 活动标记,分片队列的出队、入队与移除路径未改动。

提示通道按任务类型独立键(polling:priority:{taskType}),与既有手动触发队列分离;调度器在同一周期内先
排空优先提示、再排空手动触发队列,提示排空不受分片背压跳过影响;未新建调度设施或异步任务类型。

新增人工优先入队与只读查询三条路由 POST /api/admin/polling-priority-items、
GET /api/admin/polling-priority-items、GET /api/admin/polling-priority-items/:id:人工入队复用既有轮询
权限判定(抽取为同包共享函数),原因必填,不受每日 500 次上限与 24 小时去重约束,重复抑制由活动项合并
承担;读侧按店铺快照下推数据范围,越权与不存在不可区分,不提供优先级分级、有效期或人工重触发入口。
新增 7 个审计动作(enqueue/claim/fail/retry/complete/dequeue/manual_denied)与资源
polling_priority_item,并按(操作者类型,来源)注册,人工侧与 Worker 侧均通过来源校验。

同步 OpenAPI 文档装配三处与路由注册;归档 Change 至
openspec/changes/archive/2026-09-17-add-priority-polling-queue/ 并同步主 Spec(新增
priority-polling-queue、polling-operations 追加单次执行互斥 Requirement 与三条路由索引)与上下文健康
证据(requirement-evidence 150 行、入口矩阵 http 403 / async 56)。

本机验证:junhong_cmp_test 与隔离 Redis DB 15,未连生产、未启动 Worker/API、未调用运营商上游;迁移
up/down/up 与 down 守卫实测(含 dirty=true 记账口径与 force 恢复),A–F 批 94 PASS、接缝 63 PASS、
提示通道 12 PASS、清理零残留 20 PASS。成功路径 Complete、真并发互斥、尝试上限第 3 次判定、HTTP 层权限
矩阵、通道阈值持锁复机边界与三类生效触发点生产集成留待测试部署验证(见
docs/verification/add-priority-polling-queue-verification.md 第 4 节)。自动化测试按项目决策为 N/A,
未新增 *_test.go。
2026-09-17 14:29:56 +08:00

939 lines
36 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"
"strconv"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
"gorm.io/gorm/clause"
)
// 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 // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
}
// SetLifecycleAudit 注入套餐权益生命周期统一审计 Writer。
func (s *ActivationService) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetPriorityEventWriter 注入套餐生效后的优先轮询请求 Outbox Writer。
func (s *ActivationService) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) {
s.priorityEvents = writer
}
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()
activated := false
var failedUsage *model.PackageUsage
// 在事务中激活套餐
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages {
failedUsage = usage
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
// 检查是否是主套餐
if usage.MasterUsageID == nil {
// 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐
var activeMain model.PackageUsage
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&activeMain).Error
if err == nil {
// 已有生效中或已用完(占位)的主套餐,保持排队状态
s.logger.Warn("已有占位主套餐,跳过激活",
zap.Uint("usage_id", usage.ID),
zap.Uint("active_main_id", activeMain.ID),
zap.Int("active_main_status", activeMain.Status))
continue
}
if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败")
}
}
terms, termsErr := ResolveUsageTerms(usage, &pkg, s.logger)
if termsErr != nil {
return termsErr
}
// REALNAME-04: 按购买快照选择激活计时基准
// from_purchase购买时起算用 PackageUsage 创建时间;否则用实名触发当前时刻
var activatedAt time.Time
if terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
activatedAt = usage.CreatedAt
} else {
activatedAt = now
}
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
// 计算下次重置时间
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.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
}
beforeData := packageUsageStateData(usage)
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活套餐失败")
}
if result.RowsAffected == 0 {
continue
}
activatedUsage := *usage
activatedUsage.Status = constants.PackageUsageStatusActive
activatedUsage.PendingRealnameActivation = false
activatedUsage.ActivatedAt = &activatedAt
activatedUsage.ExpiresAt = &expiresAt
activatedUsage.NextResetAt = nextResetAt
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益", []packageUsageAuditChange{{
Usage: &activatedUsage, BeforeData: beforeData, AfterData: packageUsageStateData(&activatedUsage),
}}, nil, map[string]any{"activation_source": "realname"}); err != nil {
return 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))
activated = true
}
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益失败", failedUsage, err)
return err
}
if activated {
s.resumeAfterActivation(ctx, 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)
activated := false
var failedUsage *model.PackageUsage
err = 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 {
failedUsage = expiredMain
// 更新主套餐状态为已过期
mainBeforeData := packageUsageStateData(expiredMain)
result := tx.Model(expiredMain).
Where("status = ?", constants.PackageUsageStatusActive).
Update("status", constants.PackageUsageStatusExpired)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新过期主套餐状态失败")
}
if result.RowsAffected == 0 {
continue
}
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: 加油包级联失效
addons, err := s.invalidateAddons(ctx, tx, expiredMain.ID)
if err != nil {
return err
}
expiredUsage := *expiredMain
expiredUsage.Status = constants.PackageUsageStatusExpired
if err := s.appendExpirationAudit(ctx, tx, &expiredUsage, mainBeforeData, addons); err != nil {
return err
}
// 任务 9.6: 激活下一个待生效主套餐
currentActivated, err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now)
if err != nil {
return err
}
activated = activated || currentActivated
}
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", failedUsage, err)
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败")
}
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 errors.New(errors.CodePackageActivationConflict)
}
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, "查询套餐信息失败")
}
// 事务内激活
activated := false
err = 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
}
if currentUsage.MasterUsageID == nil {
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
if err != nil {
return err
}
if hasActive {
s.logger.Info("已有生效主套餐,跳过指定套餐激活",
zap.Uint("usage_id", currentUsage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
canActivate, err := s.canActivatePendingUsage(ctx, tx, &currentUsage, carrierType, carrierID)
if err != nil {
return err
}
if !canActivate {
s.logger.Info("指定套餐暂不满足激活条件",
zap.Uint("usage_id", currentUsage.ID),
zap.Bool("pending_realname_activation", currentUsage.PendingRealnameActivation))
return nil
}
}
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "specific", "指定套餐已激活"); err != nil {
return err
}
activated = true
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活指定套餐权益失败", &usage, err)
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败")
}
return nil
}
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
// 返回 true 表示本次成功激活了一个主套餐;队首套餐未满足实名条件时返回 false。
func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
if carrierType != constants.AssetTypeIotCard && carrierType != constants.AssetTypeDevice {
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
if carrierID == 0 {
return false, errors.New(errors.CodeInvalidParam, "无效的载体ID")
}
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
lockValue := time.Now().String()
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
if err != nil {
return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
}
if !locked {
s.logger.Warn("套餐激活正在进行中,跳过",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return false, errors.New(errors.CodePackageActivationConflict)
}
defer s.redis.Del(ctx, lockKey)
activated := false
var failedUsage *model.PackageUsage
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID)
if err != nil {
return err
}
if hasActive {
s.logger.Info("载体已有占位主套餐,本轮不接续",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound {
s.logger.Info("载体没有待生效主套餐,本轮不接续",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
if err != nil {
return err
}
failedUsage = nextMain
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
return err
}
if !canActivate {
s.logger.Info("队首待生效套餐暂不满足激活条件",
zap.Uint("usage_id", nextMain.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return nil
}
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "queue", "队首待生效套餐已激活"); err != nil {
return err
}
activated = true
return nil
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活排队套餐权益失败", failedUsage, err)
return false, err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败")
}
return activated, nil
}
// HasActiveMainPackage 查询载体当前是否存在生效中的主套餐。
func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
return s.hasActiveMainPackage(ctx, s.db.WithContext(ctx), carrierType, carrierID)
}
// invalidateAddons 任务 9.7: 加油包级联失效
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]packageUsageAuditChange, 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 nil, errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
}
if len(addons) == 0 {
return nil, 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 nil, errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
}
s.logger.Info("加油包已级联失效",
zap.Uint("master_usage_id", masterUsageID),
zap.Int("addon_count", len(addons)))
changes := make([]packageUsageAuditChange, 0, len(addons))
for _, addon := range addons {
beforeData := packageUsageStateData(addon)
addon.Status = constants.PackageUsageStatusInvalidated
changes = append(changes, packageUsageAuditChange{Usage: addon, BeforeData: beforeData, AfterData: packageUsageStateData(addon)})
}
return changes, nil
}
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
// 查询下一个待生效主套餐
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound {
s.logger.Info("没有待生效的主套餐",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return false, nil
}
if err != nil {
return false, err
}
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
return false, err
}
if !canActivate {
s.logger.Info("下一个待生效主套餐暂不满足激活条件",
zap.Uint("usage_id", nextMain.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return false, nil
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "queue", "排队主套餐已激活"); err != nil {
return false, err
}
return true, nil
}
func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) {
var nextMain model.PackageUsage
err := tx.WithContext(ctx).
Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC, created_at ASC").
First(&nextMain).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, err
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败")
}
return &nextMain, nil
}
func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
var count int64
// 生效中1和已用完2都属于"占位"状态:已用完的套餐仍在有效期内,不允许提前激活下一个主套餐
if err := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败")
}
return count > 0, nil
}
func (s *ActivationService) canActivatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) (bool, error) {
if usage == nil {
return false, nil
}
if !usage.PendingRealnameActivation {
return true, nil
}
return s.isCarrierRealnamed(ctx, tx, carrierType, carrierID)
}
func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
switch carrierType {
case constants.AssetTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).
Select("real_name_status").
First(&card, carrierID).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名状态失败")
}
return card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.AssetTypeDevice:
var count int64
subQuery := tx.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound)
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名状态失败")
}
return count > 0, nil
default:
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
}
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, activationSource, logMessage string) error {
terms, err := ResolveUsageTerms(usage, pkg, s.logger)
if err != nil {
return err
}
// ExpiryBase=from_purchase 只用于"等待实名激活"场景REALNAME-04套餐已购买但资产未实名
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
// 这种情况下 usage.PendingRealnameActivation 为 false不应该套用购买时间否则排队等待的天数会
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
var activatedAt time.Time
if usage.PendingRealnameActivation && terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
activatedAt = usage.CreatedAt
} else {
activatedAt = now
}
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
// 计算下次重置时间
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.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
}
beforeData := packageUsageStateData(usage)
result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活排队主套餐失败")
}
if result.RowsAffected == 0 {
return nil
}
usage.Status = constants.PackageUsageStatusActive
usage.PendingRealnameActivation = false
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
// 排队主套餐顺延生效:仅 queue 来源追加优先轮询请求specific 与 realname 来源不属于触发场景。
if activationSource == "queue" {
if err := AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID,
constants.PollingPriorityTriggerQueueActivated, now); err != nil {
return err
}
}
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{
Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage),
}}, nil, map[string]any{"activation_source": activationSource}); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
s.logger.Info(logMessage,
zap.Uint("usage_id", usage.ID),
zap.Uint("package_id", usage.PackageID),
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
return nil
}
// resumeAfterActivation 在激活事务提交后异步尝试复机,避免外部副作用先于业务事实提交。
func (s *ActivationService) resumeAfterActivation(ctx context.Context, carrierType string, carrierID uint, errorMessage string) {
if s.resumeCallback == nil {
return
}
resumeCtx := context.WithoutCancel(ctx)
go func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, carrierType, carrierID); err != nil {
s.logger.Error(errorMessage,
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}()
}
func (s *ActivationService) appendActivationObservation(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint, occurredAt time.Time) error {
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "套餐激活观测 Outbox Writer 未配置")
}
if usage == nil || usage.ID == 0 || carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐激活观测事件缺少载体")
}
eventID := "card-observation:package-usage:" + strconv.FormatUint(uint64(usage.ID), 10) + ":activated"
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: occurredAt,
RequestID: eventID, CorrelationID: eventID,
})
}
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
if usage == nil {
return
}
if usage.UsageType == constants.PackageUsageTypeDevice || usage.DeviceID > 0 || carrierType == constants.AssetTypeDevice {
// 设备激活状态由“生效中主套餐 + 任意绑定卡已实名”动态计算,
// 不再写入设备归属状态字段。
return
}
cardID := usage.IotCardID
if cardID == 0 {
cardID = carrierID
}
if cardID == 0 {
return
}
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND status < ?", cardID, constants.IotCardStatusActivated).
Update("status", constants.IotCardStatusActivated).Error; err != nil {
s.logger.Warn("套餐激活后更新卡状态失败",
zap.Uint("iot_card_id", cardID),
zap.Uint("usage_id", usage.ID),
zap.Error(err))
}
}
// InvalidatePackagesForRefund 退款精准失效套餐并写入退款快照
// 规则:
// 1. 优先按 packageUsageID 精准失效单条套餐(且必须属于当前退款订单)
// 2. 未传 packageUsageID 时,按 orderID 失效该订单生成的套餐
// 3. 若命中主套餐,级联失效其加油包,避免残留挂靠关系
func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, assetType string, assetID uint, orderID uint, refundID uint, refundNo string, packageUsageID *uint) error {
if orderID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
validStatuses := []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}
var failedUsage *model.PackageUsage
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 换货后权益仍保留原订单关系,退款必须按订单定位并使用权益当前资产快照。
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
var targets []model.PackageUsage
if packageUsageID != nil && *packageUsageID > 0 {
var usage model.PackageUsage
if err := query.Where("id = ? AND order_id = ?", *packageUsageID, orderID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败")
}
targets = append(targets, usage)
} else if err := query.Where("order_id = ?", orderID).Find(&targets).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败")
}
if len(targets) == 0 {
return nil
}
mainUsageIDs := make([]uint, 0, len(targets))
for i := range targets {
if targets[i].MasterUsageID == nil {
mainUsageIDs = append(mainUsageIDs, targets[i].ID)
}
}
if len(mainUsageIDs) > 0 {
var addons []model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("master_usage_id IN ? AND status IN ?", mainUsageIDs, validStatuses).Find(&addons).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败")
}
targets = append(targets, addons...)
}
failedUsage = &targets[0]
targetIDs := make([]uint, 0, len(targets))
changes := make([]packageUsageAuditChange, 0, len(targets))
for i := range targets {
targetIDs = append(targetIDs, targets[i].ID)
beforeData := packageUsageStateData(&targets[i])
targets[i].Status = constants.PackageUsageStatusInvalidated
if refundID > 0 {
targets[i].RefundID = &refundID
}
targets[i].RefundNo = refundNo
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
}
updates := map[string]any{"status": constants.PackageUsageStatusInvalidated}
if refundID > 0 {
updates["refund_id"] = refundID
}
if refundNo != "" {
updates["refund_no"] = refundNo
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", targetIDs, validStatuses).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败")
}
if result.RowsAffected != int64(len(targetIDs)) {
return errors.New(errors.CodeConflict, "退款套餐权益状态已变化")
}
var refund *model.RefundRequest
if refundID > 0 {
refund = &model.RefundRequest{}
if err := tx.WithContext(ctx).Where("id = ?", refundID).First(refund).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联退款单审计快照失败")
}
}
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益", changes, refund, map[string]any{
"asset_type": assetType, "asset_id": assetID, "order_id": orderID, "refund_id": refundID, "refund_no": refundNo,
})
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益失败", failedUsage, err)
return err
}
return nil
}
// InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐
// 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4)
func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error {
validStatuses := []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}
if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
var failedUsage *model.PackageUsage
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses)
query = query.Where(assetType+"_id = ?", assetID)
var targets []model.PackageUsage
if err := query.Find(&targets).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联套餐权益失败")
}
if len(targets) == 0 {
return nil
}
failedUsage = &targets[0]
ids := make([]uint, 0, len(targets))
changes := make([]packageUsageAuditChange, 0, len(targets))
for i := range targets {
ids = append(ids, targets[i].ID)
beforeData := packageUsageStateData(&targets[i])
targets[i].Status = constants.PackageUsageStatusInvalidated
changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])})
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", ids, validStatuses).
Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败")
}
if result.RowsAffected != int64(len(ids)) {
return errors.New(errors.CodeConflict, "资产套餐权益状态已变化")
}
return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益", changes, nil, map[string]any{
"asset_type": assetType, "asset_id": assetID,
})
})
if err != nil {
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益失败", failedUsage, err)
return err
}
s.logger.Info("批量失效套餐完成",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
)
return nil
}