Compare commits
10 Commits
ebc3e78ba9
...
ad095d73f3
| Author | SHA1 | Date | |
|---|---|---|---|
| ad095d73f3 | |||
| 8b5cd50576 | |||
| 8227ab2f20 | |||
| 6457ffa6f9 | |||
| 8d69858413 | |||
| 49e8ebed60 | |||
| bb5a5ec6ef | |||
| cf602ab5be | |||
| d7ade821f4 | |||
| 7f42d198eb |
@@ -101,10 +101,12 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deps.Logger,
|
||||
)
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活自动复机
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, stores.IotCard, stores.DeviceSimBinding, deps.GatewayClient, deps.Logger)
|
||||
usageService.SetStopResumeCallback(stopResumeService)
|
||||
activationService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
resetService.SetResumeCallback(stopResumeService)
|
||||
|
||||
return &queue.WorkerServices{
|
||||
CommissionCalculation: commissionCalculationService,
|
||||
|
||||
@@ -12,7 +12,6 @@ type Carrier struct {
|
||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
||||
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
|
||||
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)" json:"billing_day"`
|
||||
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
|
||||
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
|
||||
DataResetDay int `gorm:"column:data_reset_day;type:int;not null;default:1;comment:上游流量重置日(1-28) 运营商每月清零网关计数器的日期" json:"data_reset_day"`
|
||||
|
||||
@@ -59,7 +59,7 @@ func NewPackageActivationHandler(
|
||||
func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error {
|
||||
startTime := time.Now()
|
||||
|
||||
// 任务 19.2: 查询已过期的主套餐(status=1 AND expires_at <= NOW)
|
||||
// 任务 19.2: 查询已过期的主套餐(status IN (1,2) AND expires_at <= NOW)
|
||||
expiredPackages, err := h.findExpiredMainPackages(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("查询过期主套餐失败", zap.Error(err))
|
||||
@@ -97,12 +97,11 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
|
||||
var packages []*model.PackageUsage
|
||||
now := time.Now()
|
||||
|
||||
// 查询 status=1 (生效中) AND expires_at <= NOW AND master_usage_id IS NULL (主套餐)
|
||||
err := h.db.WithContext(ctx).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Where("expires_at <= ?", now).
|
||||
Where("master_usage_id IS NULL"). // 主套餐没有 master_usage_id
|
||||
Limit(1000). // 每次最多处理 1000 个,避免长事务
|
||||
Where("master_usage_id IS NULL").
|
||||
Limit(1000).
|
||||
Find(&packages).Error
|
||||
|
||||
return packages, err
|
||||
|
||||
@@ -105,23 +105,40 @@ func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResumeCardIfStopped 任务 24.4: 购买套餐后自动复机
|
||||
// 当购买新套餐且卡之前因流量耗尽停机时,自动复机
|
||||
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
// 查询卡信息
|
||||
// ResumeCardIfStopped 套餐激活或流量重置后自动复机入口
|
||||
// 支持 iot_card 和 device 两种载体类型:
|
||||
// - iot_card:对单张卡执行实名检查 → 停机原因检查 → 分布式锁 → Gateway → 更新 DB
|
||||
// - device:查询设备下所有已绑定卡,逐一执行单卡复机逻辑
|
||||
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error {
|
||||
switch carrierType {
|
||||
case "iot_card":
|
||||
return s.resumeSingleCard(ctx, carrierID)
|
||||
case "device":
|
||||
return s.resumeDeviceCards(ctx, carrierID)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// resumeSingleCard 对单张卡执行复机逻辑
|
||||
// 依次检查:已开机则跳过 → 未实名则跳过(WARN)→ 非流量耗尽停机则跳过 → 加锁 → 调 Gateway → 更新 DB
|
||||
func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 幂等性检查:如果已经是开机状态,跳过
|
||||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
s.logger.Debug("卡已处于开机状态,跳过",
|
||||
zap.Uint("card_id", cardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 只有因流量耗尽停机的卡才自动复机
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
s.logger.Warn("未实名卡跳过自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
return nil
|
||||
}
|
||||
|
||||
if card.StopReason != constants.StopReasonTrafficExhausted {
|
||||
s.logger.Debug("卡非流量耗尽停机,不自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
@@ -129,7 +146,21 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 24.5: 调用运营商复机接口(带重试机制)
|
||||
// 分布式锁防止同一张卡并发触发多次 Gateway 调用,TTL 30s
|
||||
lockKey := constants.RedisCardResumeLockKey(cardID)
|
||||
locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
|
||||
if lockErr != nil {
|
||||
s.logger.Warn("获取复机分布式锁失败,跳过本次复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(lockErr))
|
||||
return nil
|
||||
}
|
||||
if !locked {
|
||||
s.logger.Debug("复机操作进行中,跳过", zap.Uint("card_id", cardID))
|
||||
return nil
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
s.logger.Error("调用运营商复机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
@@ -138,23 +169,50 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "", // 清空停机原因
|
||||
"stop_reason": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
// Gateway 成功但 DB 更新失败:记录 ERROR 供人工排查,不阻断流程
|
||||
s.logger.Error("复机 Gateway 成功但 DB 更新失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("卡购买套餐后已自动复机",
|
||||
s.logger.Info("卡已自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeDeviceCards 对设备绑定的所有已绑定卡逐一执行复机逻辑
|
||||
// 部分卡实名、部分未实名时:已实名卡复机,未实名卡跳过,互不影响
|
||||
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询设备绑定卡失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, binding := range bindings {
|
||||
if resumeErr := s.resumeSingleCard(ctx, binding.IotCardID); resumeErr != nil {
|
||||
s.logger.Warn("设备绑定卡复机失败,继续处理其他卡",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Uint("card_id", binding.IotCardID),
|
||||
zap.Error(resumeErr))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAvailablePackage 检查是否有可用套餐
|
||||
func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint) (bool, error) {
|
||||
var count int64
|
||||
|
||||
@@ -51,6 +51,7 @@ type Service struct {
|
||||
wechatPayment wechat.PaymentServiceInterface
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
resumeCallback packagepkg.ResumeCallback
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -95,6 +96,11 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// SetResumeCallback 设置复机回调,支付成功激活套餐后触发自动复机
|
||||
func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) {
|
||||
s.resumeCallback = callback
|
||||
}
|
||||
|
||||
// CreateLegacy 创建订单(已废弃)
|
||||
// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。
|
||||
func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
@@ -1621,10 +1627,53 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
return err
|
||||
}
|
||||
|
||||
s.tryResumeAfterPayment(ctx, order)
|
||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryResumeAfterPayment 支付成功后检查是否应触发自动复机
|
||||
// 仅当本次支付直接激活了主套餐(status=1,非排队)时才调用复机,避免虚流量已超但套餐排队时错误复机
|
||||
func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order) {
|
||||
if s.resumeCallback == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var resumeCarrierType string
|
||||
var resumeCarrierID uint
|
||||
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
resumeCarrierType = "iot_card"
|
||||
resumeCarrierID = *order.IotCardID
|
||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
resumeCarrierType = "device"
|
||||
resumeCarrierID = *order.DeviceID
|
||||
}
|
||||
|
||||
if resumeCarrierID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var activatedCount int64
|
||||
s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("order_id = ? AND status = ? AND master_usage_id IS NULL", order.ID, constants.PackageUsageStatusActive).
|
||||
Count(&activatedCount)
|
||||
|
||||
if activatedCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func(ct string, cid uint) {
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||||
s.logger.Error("支付后自动复机失败",
|
||||
zap.String("carrier_type", ct),
|
||||
zap.Uint("carrier_id", cid),
|
||||
zap.Uint("order_id", order.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(resumeCarrierType, resumeCarrierID)
|
||||
}
|
||||
|
||||
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
var items []*model.OrderItem
|
||||
if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
|
||||
|
||||
@@ -13,11 +13,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResumeCallback 任务 24.7: 复机回调接口
|
||||
// 用于在套餐激活后触发自动复机
|
||||
// ResumeCallback 复机回调接口
|
||||
// 用于在套餐激活或流量重置后触发自动复机
|
||||
type ResumeCallback interface {
|
||||
// ResumeCardIfStopped 购买套餐后自动复机
|
||||
ResumeCardIfStopped(ctx context.Context, cardID uint) error
|
||||
ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error
|
||||
}
|
||||
|
||||
type ActivationService struct {
|
||||
@@ -149,16 +148,15 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
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),
|
||||
if s.resumeCallback != nil {
|
||||
go func(ct string, cid uint) {
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||||
s.logger.Error("实名激活后自动复机失败",
|
||||
zap.String("carrier_type", ct),
|
||||
zap.Uint("carrier_id", cid),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierID)
|
||||
}(carrierType, carrierID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,16 +308,15 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
|
||||
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 {
|
||||
if s.resumeCallback != nil {
|
||||
go func(ct string, cid uint) {
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||||
s.logger.Error("排队激活后自动复机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("carrier_type", ct),
|
||||
zap.Uint("carrier_id", cid),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierID)
|
||||
}(carrierType, carrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -18,6 +18,7 @@ type ResetService struct {
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
resumeCallback ResumeCallback
|
||||
}
|
||||
|
||||
func NewResetService(
|
||||
@@ -34,6 +35,11 @@ func NewResetService(
|
||||
}
|
||||
}
|
||||
|
||||
// SetResumeCallback 设置复机回调,流量重置后对满足条件的卡触发自动复机
|
||||
func (s *ResetService) SetResumeCallback(callback ResumeCallback) {
|
||||
s.resumeCallback = callback
|
||||
}
|
||||
|
||||
// ResetDailyUsage 任务 11.2-11.3: 重置日流量
|
||||
func (s *ResetService) ResetDailyUsage(ctx context.Context) error {
|
||||
return s.resetDailyUsageWithDB(ctx, s.db)
|
||||
@@ -43,8 +49,8 @@ func (s *ResetService) ResetDailyUsage(ctx context.Context) error {
|
||||
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 resetPackages []*model.PackageUsage
|
||||
err := 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).
|
||||
@@ -60,21 +66,18 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
|
||||
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, // 重置后恢复为生效中
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
@@ -87,8 +90,16 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
resetPackages = packages
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.triggerResumeForPackages(ctx, resetPackages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetMonthlyUsage 任务 11.4-11.5: 重置月流量
|
||||
@@ -100,8 +111,8 @@ func (s *ResetService) ResetMonthlyUsage(ctx context.Context) error {
|
||||
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 resetPackages []*model.PackageUsage
|
||||
err := 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).
|
||||
@@ -117,9 +128,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按套餐分组处理(根据套餐周期类型计算下次重置时间)
|
||||
for _, usage := range packages {
|
||||
// 查询套餐信息,获取 calendar_type
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||||
s.logger.Error("查询套餐信息失败",
|
||||
@@ -129,12 +138,9 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算下次重置时间(基于套餐周期类型)
|
||||
// 自然月套餐:每月1号重置
|
||||
// 按天套餐:每30天重置
|
||||
activatedAt := usage.ActivatedAt
|
||||
if activatedAt.IsZero() {
|
||||
activatedAt = now // 兜底处理
|
||||
activatedAt = now
|
||||
}
|
||||
nextResetAt := CalculateNextResetTime(constants.PackageDataResetMonthly, pkg.CalendarType, now, activatedAt)
|
||||
if nextResetAt == nil {
|
||||
@@ -143,12 +149,11 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新套餐
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": *nextResetAt,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||
@@ -161,8 +166,16 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
zap.Time("next_reset_at", *nextResetAt))
|
||||
}
|
||||
|
||||
resetPackages = packages
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.triggerResumeForPackages(ctx, resetPackages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetYearlyUsage 任务 11.6-11.7: 重置年流量
|
||||
@@ -174,8 +187,8 @@ func (s *ResetService) ResetYearlyUsage(ctx context.Context) error {
|
||||
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 resetPackages []*model.PackageUsage
|
||||
err := 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).
|
||||
@@ -191,21 +204,18 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
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, // 重置后恢复为生效中
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
@@ -218,6 +228,46 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
resetPackages = packages
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.triggerResumeForPackages(ctx, resetPackages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerResumeForPackages 对重置后的套餐,遍历其载体并触发自动复机
|
||||
// 流量重置后,因流量耗尽而停机的卡满足复机条件,此处异步触发避免阻塞重置流程
|
||||
func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []*model.PackageUsage) {
|
||||
if s.resumeCallback == nil || len(packages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
var carrierType string
|
||||
var carrierID uint
|
||||
|
||||
if pkg.DeviceID > 0 {
|
||||
carrierType = "device"
|
||||
carrierID = pkg.DeviceID
|
||||
} else if pkg.IotCardID > 0 {
|
||||
carrierType = "iot_card"
|
||||
carrierID = pkg.IotCardID
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(ct string, cid uint) {
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
|
||||
s.logger.Warn("流量重置后自动复机失败",
|
||||
zap.String("carrier_type", ct),
|
||||
zap.Uint("carrier_id", cid),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierType, carrierID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ func (h *PollingHandler) stopCardByUsageExhausted(ctx context.Context, card *mod
|
||||
updates := map[string]any{
|
||||
"network_status": 0, // 停机
|
||||
"stopped_at": now,
|
||||
"stop_reason": "套餐流量耗尽自动停机",
|
||||
"stop_reason": constants.StopReasonTrafficExhausted,
|
||||
"updated_at": now,
|
||||
}
|
||||
if err := h.db.Model(&model.IotCard{}).
|
||||
@@ -686,12 +686,13 @@ func (h *PollingHandler) stopCards(ctx context.Context, cards []*model.IotCard,
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.String("reason", "套餐超额"))
|
||||
|
||||
// 更新数据库:卡的网络状态
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(map[string]any{
|
||||
"network_status": 0, // 停机
|
||||
"network_status": 0,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonTrafficExhausted,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
h.logger.Error("更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(err))
|
||||
@@ -1018,7 +1019,7 @@ func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *a
|
||||
h.db.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": time.Now(),
|
||||
"stop_reason": "保护期一致性检查自动停机",
|
||||
"stop_reason": constants.StopReasonProtectPeriod,
|
||||
})
|
||||
h.updateCardCache(ctx, card.ID, map[string]any{"network_status": constants.NetworkStatusOffline})
|
||||
return nil
|
||||
|
||||
3
migrations/000100_remove_carrier_billing_day.down.sql
Normal file
3
migrations/000100_remove_carrier_billing_day.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 回滚:恢复 billing_day 字段
|
||||
ALTER TABLE tb_carrier ADD COLUMN IF NOT EXISTS billing_day INT DEFAULT 1;
|
||||
COMMENT ON COLUMN tb_carrier.billing_day IS '运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)';
|
||||
4
migrations/000100_remove_carrier_billing_day.up.sql
Normal file
4
migrations/000100_remove_carrier_billing_day.up.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 删除运营商计费日字段
|
||||
-- billing_day 从未被业务代码引用,实际使用的是 data_reset_day(网关重置日)
|
||||
-- 两个字段功能重叠且 billing_day 从未被实际使用,故删除以清理表结构
|
||||
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS billing_day;
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-31
|
||||
@@ -0,0 +1,81 @@
|
||||
## Context
|
||||
|
||||
现有停复机系统存在多条互相依赖的断链:
|
||||
|
||||
1. **stop_reason 不一致**:`stopCards`(虚流量超额停机路径)写入 DB 时缺少 `stop_reason` 和 `stopped_at`,导致 `ResumeCardIfStopped` 的 `stop_reason == "traffic_exhausted"` 判断永远不通过
|
||||
2. **复机回调缺失**:购买套餐(`order/service.go`)和流量重置(`reset_service.go`)后均无复机触发,两者都没有注入 `StopResumeService` 的依赖
|
||||
3. **Depleted 套餐到期盲区**:`findExpiredMainPackages` 只查 `status=1`,已耗尽(`status=2`)套餐到期后无人处理,预购的下一个套餐永远卡在 Pending
|
||||
4. **device 类型无覆盖**:`ResumeCardIfStopped` 及其两处调用点均有 `carrierType == "iot_card"` 硬判断,设备类型完全跳过
|
||||
5. **未实名卡无防护**:系统自动复机不检查 `real_name_status`,未实名卡可能被复机
|
||||
6. **并发不安全**:流量重置和排队激活可能同时触发同一张卡的 `gateway.StartCard`
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复所有已识别的停复机断链,使停机卡在满足条件时可靠地自动复机
|
||||
- 统一 `stop_reason` 写入,所有系统自动停机路径都使用常量
|
||||
- 设备类型与单卡类型的停复机行为对等
|
||||
- 未实名卡不被自动复机
|
||||
- 防止并发 Gateway 调用
|
||||
|
||||
**Non-Goals:**
|
||||
- 企业设备的 `SuspendCard/ResumeCard` Gateway 补全(独立 Issue)
|
||||
- Gateway + DB 双阶段补偿机制(后期迭代)
|
||||
- 对人工复机接口增加实名校验(业务上不需要)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:扩展 ResumeCardIfStopped 签名为 (ctx, carrierType, carrierID)
|
||||
|
||||
**备选方案**:新增独立的 `ResumeDeviceIfStopped` 函数
|
||||
**选择原因**:复机的核心逻辑(检查 stop_reason、检查实名状态、加锁、调 Gateway)对两种类型是共用的,只有"查哪张卡"不同。将两者合并到一个函数,只在内部分支处理差异,避免维护两套并行逻辑。调用点签名统一,未来新增类型只改函数内部。
|
||||
**代价**:所有现有调用点需要更新传参(当前只有 2 处,成本可控)。
|
||||
|
||||
### 决策 2:SetResumeCallback 模式注入 StopResumeService
|
||||
|
||||
**备选方案**:构造函数直接注入
|
||||
**选择原因**:项目中 `UsageService` 和 `ActivationService` 已采用此模式,保持一致性。`SetResumeCallback` 使依赖为可选(nil 检查),不强制要求所有场景都提供复机逻辑,例如纯退款场景的 `ActivationService` 实例可以不注入。
|
||||
|
||||
### 决策 3:findExpiredMainPackages 改为 status IN (1,2)
|
||||
|
||||
**备选方案 B**:新增独立扫描任务处理 Depleted 套餐
|
||||
**备选方案 C**:套餐变 Depleted 时立即激活下一个
|
||||
**选择原因**:
|
||||
- 方案 B 引入重复调度逻辑,两个任务并发时需要额外幂等保护
|
||||
- 方案 C 改变了状态机触发时机,`expires_at` 的语义是"到期才交接",提前交接会导致套餐重叠或加油包提前失效
|
||||
- 方案 A 最小改动,语义与现有 `Active→Expired` 路径完全一致,`processExpiredPackage` 已包含加油包级联失效和下一套餐激活的完整逻辑
|
||||
|
||||
**加油包处理**:当 Depleted 主套餐到达 `expires_at` 时,加油包一并级联失效(现有 `invalidateAddons` 逻辑),符合"加油包跟随主套餐生命周期"的业务规则。
|
||||
|
||||
### 决策 4:实名检查在 ResumeCardIfStopped 内部统一执行
|
||||
|
||||
**备选方案**:各调用点分别检查
|
||||
**选择原因**:一处检查所有自动复机路径都受保护,不会因新增调用点而遗漏。静默跳过(warn 日志但不返回 error),不影响调用方的正常流程。
|
||||
|
||||
### 决策 5:Redis 分布式锁保护 ResumeCardIfStopped
|
||||
|
||||
Key 格式:`card:resume:lock:{cardID}`,TTL 30s。
|
||||
**背景**:流量重置和排队激活有可能在同一时间窗口内对同一张卡触发复机。重复 `gateway.StartCard` 后果可控(运营商幂等),但增加无效 Gateway 调用和日志噪音。Redis 锁以最低成本解决此问题。
|
||||
加锁失败(锁被占用)时直接返回,不排队等待——说明正在复机,本次复机请求可丢弃。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| `ResumeCardIfStopped` 签名变更导致编译错误 | 直接用 `lsp_diagnostics` 找所有调用点,修改完后编译验证 |
|
||||
| `findExpiredMainPackages` 扩展后 Depleted 套餐被重复处理 | `processExpiredPackage` 内部先更新状态为 `Expired`,幂等性由 `expires_at <= now AND status IN (1,2)` 条件查询保证,相同套餐不会被重复捞起 |
|
||||
| `order/service.go` 复机调用在事务外执行,事务失败但复机已触发 | 复机调用在事务 **commit 之后**(`HandlePaymentCallback` 的 `if err != nil { return }` 之后),事务失败不会执行复机;事务成功但复机 Gateway 失败时用户购买了套餐但卡仍停机,属于可接受的降级(有日志可人工介入) |
|
||||
| device 类型遍历绑定卡时部分卡实名部分未实名 | 内部实名检查在每张卡维度独立执行,已实名卡复机,未实名卡跳过,互不影响 |
|
||||
| Redis 锁超时(30s)内卡无法再次被复机触发 | 30s 是宽裕值,正常复机操作 < 5s;锁超时后自动释放,不会永久阻塞 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**无需数据库迁移**。本次所有变更均为代码逻辑修复。
|
||||
|
||||
**部署顺序**:直接滚动升级。修复不改变数据库 schema,旧版停机的卡 `stop_reason` 为空或中文字符串,在 `ResumeCardIfStopped` 中会命中"非 traffic_exhausted 不复机"的逻辑,行为与现有一致(不会错误复机)。新版停机的卡会写入正确的 `stop_reason`,才能走自动复机链路。
|
||||
|
||||
**回滚**:直接回滚代码版本,无状态变化需要回滚。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。所有关键决策已在盘问环节与产品确认。
|
||||
@@ -0,0 +1,44 @@
|
||||
## Why
|
||||
|
||||
停复机与套餐生命周期引擎存在多处系统性缺陷:停机时 `stop_reason` 字段写入不一致导致自动复机链路断裂;已耗尽(Depleted)套餐在到期时无法触发下一个排队套餐激活;流量重置后停机卡不自动复机;设备类型载体完全缺失复机逻辑;`ResumeCardIfStopped` 未检查实名状态。这些问题直接影响用户续费后无法正常上网的核心业务场景。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **停机原因标准化**:`stopCards`(套餐超额停机)补写 `stop_reason=traffic_exhausted` + `stopped_at`,与其他停机路径对齐
|
||||
- **购买套餐后自动复机**:`order/service.go` 支付回调激活套餐后,调用复机回调
|
||||
- **Depleted 套餐到期处理**:`findExpiredMainPackages` 扩展为同时查 `status IN (1,2)`,使已耗尽套餐在到期日触发下一个 Pending 套餐激活与复机
|
||||
- **流量重置后自动复机**:`reset_service` 注入复机回调,重置后对满足条件的卡自动复机
|
||||
- **设备类型复机覆盖**:`ResumeCardIfStopped` 扩展签名支持 `carrierType`,设备类型时遍历绑定的已实名卡逐一复机
|
||||
- **未实名不自动复机**:`ResumeCardIfStopped` 内部统一检查 `real_name_status`,未实名卡静默跳过
|
||||
- **并发保护**:`ResumeCardIfStopped` 加 Redis 分布式锁,防止同一张卡并发触发多次 `gateway.StartCard`
|
||||
- **告警日志**:Gateway 成功但 DB 更新失败时记录 ERROR 日志便于人工排查
|
||||
- **常量化**:`HandleProtectConsistencyCheck` 停机原因提取为 `constants.StopReasonProtectPeriod` 常量
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `auto-resume-on-purchase`:购买套餐(支付回调激活)后触发停机卡自动复机的能力
|
||||
- `auto-resume-on-reset`:流量周期重置(日/月/年)后触发停机卡自动复机的能力
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `auto-stop-resume`:扩展复机前置条件(未实名不复机)、添加设备类型支持、添加并发保护
|
||||
- `package-queue-activation`:扩展过期套餐检测逻辑,将 Depleted 套餐纳入到期检测范围
|
||||
|
||||
## Impact
|
||||
|
||||
**修改文件:**
|
||||
- `internal/task/polling_handler.go`:`stopCards` 补写 `stop_reason` + `stopped_at`;`HandleProtectConsistencyCheck` 使用 `StopReasonProtectPeriod` 常量
|
||||
- `internal/service/package/activation_service.go`:更新 `ResumeCallback` 接口签名(增加 `carrierType` 参数);两处调用点更新传参
|
||||
- `internal/service/iot_card/stop_resume_service.go`:`ResumeCardIfStopped` 扩展支持 `carrierType`;内部加未实名检查;加 Redis 分布式锁;设备类型时遍历复机绑定卡
|
||||
- `internal/service/order/service.go`:注入 `ResumeCallback`;`HandlePaymentCallback` 事务后调用复机
|
||||
- `internal/service/package/reset_service.go`:注入 `ResumeCallback`;三个重置方法结束后异步调用复机
|
||||
- `internal/polling/package_activation_handler.go`:`findExpiredMainPackages` 查询改为 `status IN (1,2)`
|
||||
- `pkg/constants/iot.go`:新增 `StopReasonProtectPeriod` 常量
|
||||
- `pkg/constants/redis.go`:新增 `RedisCardResumeLockKey(cardID)` Key 生成函数
|
||||
- `internal/bootstrap/worker_services.go`:为 `orderService`、`resetService` 注入 `stopResumeService`
|
||||
|
||||
**不在本次范围:**
|
||||
- `enterprise_device/service.go`:SuspendCard/ResumeCard 缺失 Gateway 调用(单独立 Issue 跟踪)
|
||||
- Gateway + DB 双阶段补偿机制(后期迭代)
|
||||
@@ -0,0 +1,36 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 购买套餐支付成功后自动复机
|
||||
|
||||
系统 SHALL 在支付回调成功激活套餐(`PackageUsage.status` 变为 1)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: C 端购买套餐支付成功后停机卡自动复机
|
||||
- **GIVEN** 卡 C1 因流量耗尽停机(`network_status=0`,`stop_reason=traffic_exhausted`),已完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** C 端用户为 C1 购买新套餐并完成微信/支付宝支付,`HandlePaymentCallback` 事务提交成功
|
||||
- **THEN** 系统异步调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 系统调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 后台购买套餐(钱包支付)后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 因流量耗尽停机,已实名
|
||||
- **WHEN** 后台管理员通过钱包支付为 C2 购买套餐,`HandlePaymentCallback` 事务提交成功,套餐状态为 `status=1`
|
||||
- **THEN** 系统调用复机逻辑,C2 自动复机
|
||||
|
||||
#### Scenario: 购买排队套餐(新套餐为 Pending 状态)时不触发复机
|
||||
- **GIVEN** 卡 C3 已有生效中套餐,用户购买第二个套餐(新套餐创建为 `status=0` Pending)
|
||||
- **WHEN** 支付成功,`HandlePaymentCallback` 完成
|
||||
- **THEN** 系统 SHALL NOT 触发复机调用(仅在套餐变为 `status=1` 时才复机)
|
||||
|
||||
#### Scenario: 未实名卡购买套餐不触发复机
|
||||
- **GIVEN** 卡 C4 停机(`stop_reason=traffic_exhausted`),但 `real_name_status=0`(未实名)
|
||||
- **WHEN** 为 C4 购买新套餐且套餐激活为 `status=1`
|
||||
- **THEN** `ResumeCardIfStopped` 内部检测到未实名,静默跳过,C4 保持停机状态
|
||||
|
||||
#### Scenario: 停机原因非流量耗尽时不触发复机
|
||||
- **GIVEN** 卡 C5 因手动停机(`stop_reason=manual`)
|
||||
- **WHEN** 为 C5 购买新套餐并激活
|
||||
- **THEN** `ResumeCardIfStopped` 检测到 `stop_reason` 不为 `traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 设备类型载体购买套餐后自动复机
|
||||
- **GIVEN** 设备 D1 绑定了 3 张卡,其中 2 张已实名且因流量耗尽停机,1 张未实名
|
||||
- **WHEN** 为 D1 购买设备级套餐并激活
|
||||
- **THEN** 已实名的 2 张卡自动复机,未实名的 1 张卡保持停机状态
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量周期重置后自动复机
|
||||
|
||||
系统 SHALL 在成功重置套餐流量(`data_usage_mb` 归零,`status` 恢复为 `Active`)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: 3 个月套餐第 2 个月耗尽停机,第 3 个月重置后自动复机
|
||||
- **GIVEN** 卡 C1 有一个 3 个月套餐(`data_reset_cycle=monthly`),第 2 个月流量耗尽(`status=2 Depleted`),卡已停机(`network_status=0`,`stop_reason=traffic_exhausted`),已实名
|
||||
- **WHEN** 月度重置任务运行,套餐 `data_usage_mb` 归零,`status` 恢复为 `1 Active`
|
||||
- **THEN** `ResetService` 调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 卡自动复机(`network_status=1`)
|
||||
|
||||
#### Scenario: 日流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 有日流量套餐(`data_reset_cycle=daily`),当天流量耗尽停机,已实名
|
||||
- **WHEN** 次日零点日流量重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 年流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C3 有年流量套餐(`data_reset_cycle=yearly`),年内流量耗尽停机,已实名
|
||||
- **WHEN** 年度重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 重置后卡已处于开机状态不重复复机
|
||||
- **GIVEN** 卡 C4 套餐被重置,但 `network_status=1`(已开机,可能已被人工复机)
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到卡已开机,幂等跳过,不调用 Gateway
|
||||
|
||||
#### Scenario: 重置后未实名卡不复机
|
||||
- **GIVEN** 卡 C5 套餐被重置,`real_name_status=0`(未实名),停机中
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到未实名,静默跳过,卡保持停机
|
||||
|
||||
#### Scenario: 批量重置中部分套餐的卡已停机
|
||||
- **GIVEN** 月度重置扫描到 100 个套餐,其中 10 个对应的卡处于停机状态且已实名
|
||||
- **WHEN** 月度重置完成
|
||||
- **THEN** 10 张停机卡异步触发复机,其余 90 张正常跳过
|
||||
- **AND** 单个复机失败不影响其他卡的处理
|
||||
@@ -0,0 +1,56 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 流量耗尽自动复机
|
||||
|
||||
系统 SHALL 在套餐激活或流量重置后,对满足以下全部条件的卡自动调用运营商接口复机:
|
||||
1. `stop_reason = "traffic_exhausted"`(仅限流量耗尽停机,手动停机不自动复机)
|
||||
2. `real_name_status = 1`(已完成实名认证)
|
||||
3. `network_status = 0`(当前处于停机状态)
|
||||
|
||||
系统 SHALL 在执行复机前获取 Redis 分布式锁(Key:`card:resume:lock:{cardID}`,TTL 30s),防止并发重复调用 Gateway。
|
||||
|
||||
#### Scenario: 所有条件满足时自动复机
|
||||
- **GIVEN** 卡 C1 满足:`stop_reason=traffic_exhausted`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "iot_card", cardID)` 被调用
|
||||
- **THEN** 系统获取分布式锁,调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`,释放锁
|
||||
|
||||
#### Scenario: 未实名卡跳过自动复机
|
||||
- **GIVEN** 卡 C2 `stop_reason=traffic_exhausted`,`real_name_status=0`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统记录 WARN 日志,静默跳过,不调用 Gateway,函数返回 nil
|
||||
|
||||
#### Scenario: 手动停机的卡不被自动复机
|
||||
- **GIVEN** 卡 C3 `stop_reason=manual`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统检测到 `stop_reason != traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 并发调用时只有一次复机执行
|
||||
- **GIVEN** 卡 C4 满足复机条件,复机操作正在执行(分布式锁被持有)
|
||||
- **WHEN** 第二个 `ResumeCardIfStopped` 调用同时到达
|
||||
- **THEN** 第二个调用获锁失败,直接返回,不调用 Gateway
|
||||
|
||||
#### Scenario: 设备类型载体复机遍历所有绑定卡
|
||||
- **GIVEN** 设备 D1 绑定 3 张卡(C1 已实名停机、C2 已实名停机、C3 未实名停机)
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "device", deviceID)` 被调用
|
||||
- **THEN** C1 和 C2 自动复机,C3 因未实名跳过
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐超额停机必须写入 stop_reason 和 stopped_at
|
||||
|
||||
系统 SHALL 在套餐虚流量超额触发停机时(`stopCards` 函数),写入 `stop_reason=traffic_exhausted` 和 `stopped_at` 到 IoT 卡记录。
|
||||
|
||||
#### Scenario: 虚流量超额停机完整写入 DB
|
||||
- **GIVEN** 卡 C1 当月流量超过套餐 `virtual_data_mb` 上限,`network_status=1`
|
||||
- **WHEN** `HandlePackageCheck` 的 `stopCards` 执行
|
||||
- **THEN** Gateway 调用成功后,DB 更新包含:`network_status=0`,`stop_reason=traffic_exhausted`,`stopped_at=now()`,`updated_at=now()`
|
||||
|
||||
### Requirement: 保护期停机使用常量 stop_reason
|
||||
|
||||
系统 SHALL 在保护期一致性检查停机时,使用 `constants.StopReasonProtectPeriod` 常量(值:`protect_period`)记录 `stop_reason`。
|
||||
|
||||
#### Scenario: 保护期停机写入正确原因
|
||||
- **GIVEN** 设备处于停机保护期,绑定的卡 C1 `network_status=1`(开机状态不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 触发停机
|
||||
- **THEN** DB 中 `stop_reason=protect_period`(使用常量,非中文硬编码)
|
||||
- **AND** `ResumeCardIfStopped` 调用时因 `stop_reason != traffic_exhausted` 跳过,不会错误触发自动复机
|
||||
@@ -0,0 +1,46 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 同时只能有一个生效中的主套餐
|
||||
|
||||
系统 SHALL 确保载体(设备/卡)同一时刻只能有一个 `package_type=formal` 且 `status=1` 的套餐。
|
||||
|
||||
**到期检测扩展**:过期检测 SHALL 同时覆盖 `status=1`(生效中)和 `status=2`(已耗尽)的主套餐。当 `expires_at <= now` 时,无论套餐当前是生效中还是已耗尽,均视为到期,触发下一个排队套餐激活。
|
||||
|
||||
**数据一致性保证**:
|
||||
- 购买时检查:查询 `WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL`
|
||||
- 并发控制:使用数据库事务避免并发插入多个生效中主套餐
|
||||
- 激活时二次检查:激活前再次查询是否有生效中主套餐,避免并发激活
|
||||
|
||||
#### Scenario: 首次购买主套餐立即生效
|
||||
- **GIVEN** 载体无任何主套餐记录
|
||||
- **WHEN** 用户购买主套餐并支付成功
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`priority=1`,`activated_at=支付完成时间`,`expires_at=根据 calendar_type 计算`,`master_usage_id=NULL`
|
||||
|
||||
#### Scenario: 购买第二个主套餐自动排队
|
||||
- **GIVEN** 载体已有 1 个生效中的主套餐(`priority=1`,`status=1`)
|
||||
- **WHEN** 用户购买第 2 个主套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=0`,`priority=2`,无 `activated_at` 和 `expires_at`
|
||||
|
||||
#### Scenario: 生效中主套餐到期触发下一个激活
|
||||
- **GIVEN** 卡 C1 有生效中主套餐(`status=1`,`expires_at=过去时间`)和一个排队套餐(`status=0`)
|
||||
- **WHEN** 过期检测任务扫描到该套餐
|
||||
- **THEN** 生效中套餐更新为 `status=3 Expired`,加油包级联失效,下一个排队套餐激活为 `status=1`
|
||||
- **AND** 如卡因流量耗尽停机(`stop_reason=traffic_exhausted`)且已实名,触发自动复机
|
||||
|
||||
#### Scenario: 已耗尽(Depleted)主套餐到期后触发下一个激活
|
||||
- **GIVEN** 卡 C2 有已耗尽主套餐(`status=2 Depleted`,`expires_at=过去时间`)和一个预购排队套餐(`status=0`)
|
||||
- **WHEN** 过期检测任务扫描(查询条件:`status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`)
|
||||
- **THEN** 耗尽套餐更新为 `status=3 Expired`,加油包级联失效,预购排队套餐激活为 `status=1`
|
||||
- **AND** 如卡因流量耗尽停机且已实名,触发自动复机
|
||||
|
||||
#### Scenario: 已耗尽套餐到期时加油包级联失效
|
||||
- **GIVEN** 卡 C3 有耗尽主套餐(`status=2`,`expires_at=过去时间`),以及绑定到该主套餐的 1 个加油包(`status=1`,尚有剩余流量)
|
||||
- **WHEN** 过期检测任务处理该主套餐
|
||||
- **THEN** 主套餐更新为 `status=3`,加油包更新为 `status=4 Invalidated`(即使尚有剩余流量)
|
||||
- **AND** 下一个排队主套餐激活
|
||||
|
||||
#### Scenario: 已耗尽套餐到期且无下一个排队套餐
|
||||
- **GIVEN** 卡 C4 有耗尽主套餐(`status=2`,`expires_at=过去时间`),无排队套餐
|
||||
- **WHEN** 过期检测任务处理该主套餐
|
||||
- **THEN** 耗尽套餐更新为 `status=3`,加油包级联失效,无新套餐激活
|
||||
- **AND** 卡的业务状态更新为 `IotCardStatusSuspended=4`(已停用)
|
||||
@@ -0,0 +1,80 @@
|
||||
# Tasks: fix-stop-resume-lifecycle-engine
|
||||
|
||||
## 阶段 1:基础层修复(常量 + 停机写入)
|
||||
|
||||
> 优先级最高,其他所有阶段的复机逻辑都依赖 stop_reason 写入正确。
|
||||
|
||||
- [x] **1.1** `pkg/constants/iot.go`:新增 `StopReasonProtectPeriod = "protect_period"` 常量
|
||||
- [x] **1.2** `pkg/constants/redis.go`:新增 `RedisCardResumeLockKey(cardID uint) string` 函数,Key 格式 `card:resume:lock:{cardID}`
|
||||
- [x] **1.3** `internal/task/polling_handler.go`:`stopCards` 函数 DB 更新中补充 `"stop_reason": constants.StopReasonTrafficExhausted` 和 `"stopped_at": now`
|
||||
- [x] **1.4** `internal/task/polling_handler.go`:`HandleProtectConsistencyCheck` 中的 `"stop_reason": "保护期一致性检查自动停机"` 替换为 `constants.StopReasonProtectPeriod`
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:ResumeCardIfStopped 核心重构
|
||||
|
||||
> 扩展签名 + 加未实名检查 + 加并发锁 + 加 device 支持。其他阶段依赖此函数签名。
|
||||
|
||||
- [x] **2.1** `internal/service/iot_card/stop_resume_service.go`:`ResumeCallback` 接口方法签名改为 `ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error`
|
||||
- [x] **2.2** `internal/service/iot_card/stop_resume_service.go`:`ResumeCardIfStopped` 实现:
|
||||
- 参数改为 `(ctx, carrierType, carrierID)`
|
||||
- `carrierType == "iot_card"` 时:读取单卡信息 → 检查实名 → 检查 stop_reason → 加 Redis 锁 → 调 Gateway → 更新 DB
|
||||
- `carrierType == "device"` 时:查询设备绑定的所有已绑定卡(`bind_status=bound`) → 逐一执行 iot_card 复机逻辑(含实名检查)
|
||||
- Gateway 成功但 DB 失败时记录 ERROR 日志(含卡 ID、ICCID),不返回 error 阻断流程
|
||||
- [x] **2.3** `internal/service/package/activation_service.go`:更新 `ResumeCallback` 接口定义(与 2.1 一致)
|
||||
- [x] **2.4** `internal/service/package/activation_service.go`:两处 `resumeCallback.ResumeCardIfStopped` 调用点更新传参(传入 `carrierType`、`carrierID`)
|
||||
- [x] **2.5** 编译检查:`lsp_diagnostics` 确认无编译错误
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3:购买套餐后复机(order service)
|
||||
|
||||
- [x] **3.1** `internal/service/order/service.go`:`Service` 结构体增加 `resumeCallback ResumeCallback` 字段(复用 `activation_service.go` 中定义的接口)
|
||||
- [x] **3.2** `internal/service/order/service.go`:新增 `SetResumeCallback(callback ResumeCallback)` 方法
|
||||
- [x] **3.3** `internal/service/order/service.go`:`HandlePaymentCallback` 在事务成功后(`if err != nil { return err }` 之后),根据订单的 `carrierType` 和 `carrierID` 调用 `s.resumeCallback.ResumeCardIfStopped`(检查 nil,异步 goroutine 调用)
|
||||
- 仅当 `activateMainPackage` 创建了 `status=1` 的套餐时才触发(即 `!hasActiveMain` 分支)
|
||||
- 传递载体信息:从 order 中读取 `IotCardID` 或 `DeviceID`
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4:Depleted 套餐到期处理
|
||||
|
||||
- [x] **4.1** `internal/polling/package_activation_handler.go`:`findExpiredMainPackages` 查询条件改为 `WHERE status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`
|
||||
- [x] **4.2** 验证:`processExpiredPackage` 的状态更新 `tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired)` 对 `status=2` 的套餐同样生效(GORM 无状态前置条件,直接更新,逻辑正确无需修改)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 5:流量重置后复机(reset service)
|
||||
|
||||
- [x] **5.1** `internal/service/package/reset_service.go`:`ResetService` 结构体增加 `resumeCallback ResumeCallback` 字段
|
||||
- [x] **5.2** `internal/service/package/reset_service.go`:新增 `SetResumeCallback(callback ResumeCallback)` 方法
|
||||
- [x] **5.3** `internal/service/package/reset_service.go`:`resetDailyUsageWithDB`、`resetMonthlyUsageWithDB`、`resetYearlyUsageWithDB` 三个方法在批量重置完成后,遍历被重置的套餐列表,对每个套餐的 `IotCardID` 异步调用 `resumeCallback.ResumeCardIfStopped(ctx, "iot_card", cardID)`(device 类型套餐传 `"device"` 和 `DeviceID`,nil 检查)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 6:Bootstrap 注入
|
||||
|
||||
- [x] **6.1** `internal/bootstrap/worker_services.go`:`stopResumeService` 创建后,调用:
|
||||
- `orderService.SetResumeCallback(stopResumeService)` (注意:`orderService` 已在 bootstrap 中创建)
|
||||
- `resetService.SetResumeCallback(stopResumeService)`
|
||||
- `usageService.SetStopResumeCallback(stopResumeService)`(已有,确认保留)
|
||||
- `activationService.SetResumeCallback(stopResumeService)`(已有,确认保留)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 7:知识库更新
|
||||
|
||||
- [x] **7.1** 更新知识库 `卡管业务整理/11-停机与复机流程.md`:在"这条链路的问题"表中标注已修复项;补充"自动停复机触发点"说明
|
||||
- [x] **7.2** 更新知识库 `卡管业务整理/08-套餐使用生命周期.md`:补充"Depleted 套餐到期处理"流程节点
|
||||
- [x] **7.3** 更新知识库 `修正业务/修正业务总览.md`:将本次修复的 Bug 从待修复移到已修复,新增企业设备停复机的独立追踪条目
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [x] **V1** 编译通过:`go build ./...` 无错误
|
||||
- [ ] **V2** DB 查询验证:停机卡买套餐后 `network_status=1`,`stop_reason` 清空
|
||||
- [ ] **V3** DB 查询验证:流量耗尽的 3 个月套餐在模拟重置后复机
|
||||
- [ ] **V4** DB 查询验证:Depleted 主套餐到期后,下一个 Pending 套餐激活为 status=1
|
||||
- [ ] **V5** 日志验证:未实名卡触发复机时出现 WARN 日志而非 ERROR
|
||||
- [ ] **V6** 日志验证:`stopCards` 停机后 `stop_reason=traffic_exhausted` 写入 DB
|
||||
36
openspec/specs/auto-resume-on-purchase/spec.md
Normal file
36
openspec/specs/auto-resume-on-purchase/spec.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 购买套餐支付成功后自动复机
|
||||
|
||||
系统 SHALL 在支付回调成功激活套餐(`PackageUsage.status` 变为 1)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: C 端购买套餐支付成功后停机卡自动复机
|
||||
- **GIVEN** 卡 C1 因流量耗尽停机(`network_status=0`,`stop_reason=traffic_exhausted`),已完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** C 端用户为 C1 购买新套餐并完成微信/支付宝支付,`HandlePaymentCallback` 事务提交成功
|
||||
- **THEN** 系统异步调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 系统调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 后台购买套餐(钱包支付)后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 因流量耗尽停机,已实名
|
||||
- **WHEN** 后台管理员通过钱包支付为 C2 购买套餐,`HandlePaymentCallback` 事务提交成功,套餐状态为 `status=1`
|
||||
- **THEN** 系统调用复机逻辑,C2 自动复机
|
||||
|
||||
#### Scenario: 购买排队套餐(新套餐为 Pending 状态)时不触发复机
|
||||
- **GIVEN** 卡 C3 已有生效中套餐,用户购买第二个套餐(新套餐创建为 `status=0` Pending)
|
||||
- **WHEN** 支付成功,`HandlePaymentCallback` 完成
|
||||
- **THEN** 系统 SHALL NOT 触发复机调用(仅在套餐变为 `status=1` 时才复机)
|
||||
|
||||
#### Scenario: 未实名卡购买套餐不触发复机
|
||||
- **GIVEN** 卡 C4 停机(`stop_reason=traffic_exhausted`),但 `real_name_status=0`(未实名)
|
||||
- **WHEN** 为 C4 购买新套餐且套餐激活为 `status=1`
|
||||
- **THEN** `ResumeCardIfStopped` 内部检测到未实名,静默跳过,C4 保持停机状态
|
||||
|
||||
#### Scenario: 停机原因非流量耗尽时不触发复机
|
||||
- **GIVEN** 卡 C5 因手动停机(`stop_reason=manual`)
|
||||
- **WHEN** 为 C5 购买新套餐并激活
|
||||
- **THEN** `ResumeCardIfStopped` 检测到 `stop_reason` 不为 `traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 设备类型载体购买套餐后自动复机
|
||||
- **GIVEN** 设备 D1 绑定了 3 张卡,其中 2 张已实名且因流量耗尽停机,1 张未实名
|
||||
- **WHEN** 为 D1 购买设备级套餐并激活
|
||||
- **THEN** 已实名的 2 张卡自动复机,未实名的 1 张卡保持停机状态
|
||||
37
openspec/specs/auto-resume-on-reset/spec.md
Normal file
37
openspec/specs/auto-resume-on-reset/spec.md
Normal file
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量周期重置后自动复机
|
||||
|
||||
系统 SHALL 在成功重置套餐流量(`data_usage_mb` 归零,`status` 恢复为 `Active`)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: 3 个月套餐第 2 个月耗尽停机,第 3 个月重置后自动复机
|
||||
- **GIVEN** 卡 C1 有一个 3 个月套餐(`data_reset_cycle=monthly`),第 2 个月流量耗尽(`status=2 Depleted`),卡已停机(`network_status=0`,`stop_reason=traffic_exhausted`),已实名
|
||||
- **WHEN** 月度重置任务运行,套餐 `data_usage_mb` 归零,`status` 恢复为 `1 Active`
|
||||
- **THEN** `ResetService` 调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 卡自动复机(`network_status=1`)
|
||||
|
||||
#### Scenario: 日流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 有日流量套餐(`data_reset_cycle=daily`),当天流量耗尽停机,已实名
|
||||
- **WHEN** 次日零点日流量重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 年流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C3 有年流量套餐(`data_reset_cycle=yearly`),年内流量耗尽停机,已实名
|
||||
- **WHEN** 年度重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 重置后卡已处于开机状态不重复复机
|
||||
- **GIVEN** 卡 C4 套餐被重置,但 `network_status=1`(已开机,可能已被人工复机)
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到卡已开机,幂等跳过,不调用 Gateway
|
||||
|
||||
#### Scenario: 重置后未实名卡不复机
|
||||
- **GIVEN** 卡 C5 套餐被重置,`real_name_status=0`(未实名),停机中
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到未实名,静默跳过,卡保持停机
|
||||
|
||||
#### Scenario: 批量重置中部分套餐的卡已停机
|
||||
- **GIVEN** 月度重置扫描到 100 个套餐,其中 10 个对应的卡处于停机状态且已实名
|
||||
- **WHEN** 月度重置完成
|
||||
- **THEN** 10 张停机卡异步触发复机,其余 90 张正常跳过
|
||||
- **AND** 单个复机失败不影响其他卡的处理
|
||||
@@ -389,3 +389,60 @@ func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
- ✅ 并发场景和异常处理
|
||||
- ✅ 性能指标和错误码定义
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-stop-resume-lifecycle-engine)
|
||||
|
||||
### MODIFIED Requirement: 流量耗尽自动复机
|
||||
|
||||
系统 SHALL 在套餐激活或流量重置后,对满足以下全部条件的卡自动调用运营商接口复机:
|
||||
1. `stop_reason = "traffic_exhausted"`(仅限流量耗尽停机,手动停机不自动复机)
|
||||
2. `real_name_status = 1`(已完成实名认证)
|
||||
3. `network_status = 0`(当前处于停机状态)
|
||||
|
||||
系统 SHALL 在执行复机前获取 Redis 分布式锁(Key:`card:resume:lock:{cardID}`,TTL 30s),防止并发重复调用 Gateway。
|
||||
|
||||
#### Scenario: 所有条件满足时自动复机
|
||||
- **GIVEN** 卡 C1 满足:`stop_reason=traffic_exhausted`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "iot_card", cardID)` 被调用
|
||||
- **THEN** 系统获取分布式锁,调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`,释放锁
|
||||
|
||||
#### Scenario: 未实名卡跳过自动复机
|
||||
- **GIVEN** 卡 C2 `stop_reason=traffic_exhausted`,`real_name_status=0`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统记录 WARN 日志,静默跳过,不调用 Gateway,函数返回 nil
|
||||
|
||||
#### Scenario: 手动停机的卡不被自动复机
|
||||
- **GIVEN** 卡 C3 `stop_reason=manual`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统检测到 `stop_reason != traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 并发调用时只有一次复机执行
|
||||
- **GIVEN** 卡 C4 满足复机条件,复机操作正在执行(分布式锁被持有)
|
||||
- **WHEN** 第二个 `ResumeCardIfStopped` 调用同时到达
|
||||
- **THEN** 第二个调用获锁失败,直接返回,不调用 Gateway
|
||||
|
||||
#### Scenario: 设备类型载体复机遍历所有绑定卡
|
||||
- **GIVEN** 设备 D1 绑定 3 张卡(C1 已实名停机、C2 已实名停机、C3 未实名停机)
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "device", deviceID)` 被调用
|
||||
- **THEN** C1 和 C2 自动复机,C3 因未实名跳过
|
||||
|
||||
### ADDED Requirement: 套餐超额停机必须写入 stop_reason 和 stopped_at
|
||||
|
||||
系统 SHALL 在套餐虚流量超额触发停机时(`stopCards` 函数),写入 `stop_reason=traffic_exhausted` 和 `stopped_at` 到 IoT 卡记录。
|
||||
|
||||
#### Scenario: 虚流量超额停机完整写入 DB
|
||||
- **GIVEN** 卡 C1 当月流量超过套餐 `virtual_data_mb` 上限,`network_status=1`
|
||||
- **WHEN** `HandlePackageCheck` 的 `stopCards` 执行
|
||||
- **THEN** Gateway 调用成功后,DB 更新包含:`network_status=0`,`stop_reason=traffic_exhausted`,`stopped_at=now()`,`updated_at=now()`
|
||||
|
||||
### ADDED Requirement: 保护期停机使用常量 stop_reason
|
||||
|
||||
系统 SHALL 在保护期一致性检查停机时,使用 `constants.StopReasonProtectPeriod` 常量(值:`protect_period`)记录 `stop_reason`。
|
||||
|
||||
#### Scenario: 保护期停机写入正确原因
|
||||
- **GIVEN** 设备处于停机保护期,绑定的卡 C1 `network_status=1`(开机状态不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 触发停机
|
||||
- **THEN** DB 中 `stop_reason=protect_period`(使用常量,非中文硬编码)
|
||||
- **AND** `ResumeCardIfStopped` 调用时因 `stop_reason != traffic_exhausted` 跳过,不会错误触发自动复机
|
||||
|
||||
@@ -381,3 +381,33 @@ CREATE INDEX idx_package_usage_priority ON tb_package_usage(iot_card_id, status,
|
||||
| | 轮询系统宕机 | 恢复后自动激活过期套餐 |
|
||||
| **性能场景** | 单个套餐激活延迟 | < 20 秒 |
|
||||
| | 10000 个套餐同时过期 | P99 < 5 分钟 |
|
||||
| **Depleted 场景** | 已耗尽套餐到期 + 有排队套餐 | 耗尽套餐→Expired,激活下一个 |
|
||||
| | 已耗尽套餐到期 + 无排队套餐 | 耗尽套餐→Expired,载体进入无套餐状态 |
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-stop-resume-lifecycle-engine)
|
||||
|
||||
### MODIFIED Requirement: 同时只能有一个生效中的主套餐
|
||||
|
||||
**到期检测扩展**:过期检测 SHALL 同时覆盖 `status=1`(生效中)和 `status=2`(已耗尽)的主套餐。当 `expires_at <= now` 时,无论套餐当前是生效中还是已耗尽,均视为到期,触发下一个排队套餐激活。
|
||||
|
||||
查询条件:`WHERE status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`
|
||||
|
||||
#### Scenario: 已耗尽(Depleted)主套餐到期后触发下一个激活
|
||||
- **GIVEN** 卡 C2 有已耗尽主套餐(`status=2 Depleted`,`expires_at=过去时间`)和一个预购排队套餐(`status=0`)
|
||||
- **WHEN** 过期检测任务扫描(查询条件:`status IN (1,2) AND expires_at <= now AND master_usage_id IS NULL`)
|
||||
- **THEN** 耗尽套餐更新为 `status=3 Expired`,加油包级联失效,预购排队套餐激活为 `status=1`
|
||||
- **AND** 如卡因流量耗尽停机且已实名,触发自动复机
|
||||
|
||||
#### Scenario: 已耗尽套餐到期时加油包级联失效
|
||||
- **GIVEN** 卡 C3 有耗尽主套餐(`status=2`,`expires_at=过去时间`),以及绑定到该主套餐的 1 个加油包(`status=1`,尚有剩余流量)
|
||||
- **WHEN** 过期检测任务处理该主套餐
|
||||
- **THEN** 主套餐更新为 `status=3`,加油包更新为 `status=4 Invalidated`(即使尚有剩余流量)
|
||||
- **AND** 下一个排队主套餐激活
|
||||
|
||||
#### Scenario: 已耗尽套餐到期且无下一个排队套餐
|
||||
- **GIVEN** 卡 C4 有耗尽主套餐(`status=2`,`expires_at=过去时间`),无排队套餐
|
||||
- **WHEN** 过期检测任务处理该主套餐
|
||||
- **THEN** 耗尽套餐更新为 `status=3`,加油包级联失效,无新套餐激活
|
||||
- **AND** 卡的业务状态更新为 `IotCardStatusSuspended=4`(已停用)
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
StopReasonTrafficExhausted = "traffic_exhausted" // 流量耗尽
|
||||
StopReasonManual = "manual" // 手动停机
|
||||
StopReasonArrears = "arrears" // 欠费
|
||||
StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机
|
||||
)
|
||||
|
||||
// 套餐流量类型
|
||||
|
||||
@@ -289,6 +289,17 @@ func RedisPollingInitProgressKey() string {
|
||||
return "polling:init:progress"
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 复机并发保护相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisCardResumeLockKey 生成卡复机操作分布式锁的 Redis 键
|
||||
// 用途:防止同一张卡并发触发多次 gateway.StartCard
|
||||
// 过期时间:30 秒
|
||||
func RedisCardResumeLockKey(cardID uint) string {
|
||||
return fmt.Sprintf("card:resume:lock:%d", cardID)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 套餐激活锁相关键
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user