diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index d0d14a7..95e29f6 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -253,7 +253,8 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context) } // processExpiredPackage 处理单个过期套餐 -// 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后投递下一套餐并触发停机检查 +// 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后投递下一套餐; +// 仅明确没有后续套餐时才触发停机检查,避免与异步激活任务竞态。 func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error { carrierType, carrierID := h.getCarrierInfo(pkg) @@ -311,9 +312,15 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk // 事务提交后再投递,确保消费者只能读取到旧套餐已经过期的状态。 if carrierType != "" && carrierID > 0 { - activationErr := h.activateNextPackage(ctx, h.db.WithContext(ctx), carrierType, carrierID) + activationEnqueued, err := h.activateNextPackage(ctx, h.db.WithContext(ctx), carrierType, carrierID) + if err != nil { + // 激活结果未知时不能将其当作无套餐;孤儿扫描和套餐轮询会继续兜底。 + return err + } + if activationEnqueued { + return nil + } h.triggerStopAfterExpiry(ctx, carrierType, carrierID) - return activationErr } return nil @@ -368,7 +375,8 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri } // activateNextPackage 提交下一个待生效主套餐的异步激活任务。 -func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error { +// 返回 true 表示已找到并成功提交后续套餐;false 表示不存在后续套餐。 +func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { var nextPkg model.PackageUsage query := tx.Where("status = ?", constants.PackageUsageStatusPending). Where("master_usage_id IS NULL"). @@ -383,16 +391,19 @@ func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx * if err := query.First(&nextPkg).Error; err != nil { if err == gorm.ErrRecordNotFound { - return nil + return false, nil } - return err + return false, err } - return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue") + if err := h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue"); err != nil { + return false, err + } + return true, nil } -// triggerStopAfterExpiry 套餐过期后异步触发停机检查 -// 仅在确认无后续生效套餐时有效;CheckAndStopCard 内部有幂等保护,重复调用安全 +// triggerStopAfterExpiry 在明确无后续套餐时异步触发停机检查。 +// 后续套餐存在时,停机重评估由激活任务在完成后顺序执行。 func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) { if h.stopResumeCallback == nil { return @@ -431,6 +442,42 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c } } +// reconcileCarrierAfterActivation 在套餐激活任务完成后按最新事实重评估停复机。 +// 它必须在激活调用返回后执行,不能与激活任务并发读取过期权益快照。 +func (h *PackageActivationHandler) reconcileCarrierAfterActivation(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint) error { + if h.stopResumeCallback == nil { + h.logger.Warn("套餐激活后停复机回调未注入,跳过重评估", + zap.Uint("package_usage_id", packageUsageID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) + return nil + } + if carrierID == 0 { + return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少有效载体") + } + + if carrierType == constants.AssetTypeIotCard { + if err := h.stopResumeCallback.CheckAndStopCard(ctx, carrierID); err != nil { + return err + } + return nil + } + if carrierType != constants.AssetTypeDevice { + return errors.New(errors.CodeInvalidParam, "套餐使用记录载体类型无效") + } + + bindings, err := h.deviceSimBinding.ListByDeviceID(ctx, carrierID) + if err != nil { + return err + } + for _, binding := range bindings { + if err := h.stopResumeCallback.CheckAndStopCard(ctx, binding.IotCardID); err != nil { + return err + } + } + return nil +} + // enqueueActivationTask 提交套餐激活任务到 Asynq func (h *PackageActivationHandler) enqueueActivationTask(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint, activationType string) error { linkage := auditcontext.From(ctx) @@ -499,14 +546,7 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont return err } - // 幂等性检查:如果已经是生效状态,跳过 - if pkg.Status == constants.PackageUsageStatusActive { - h.logger.Info("套餐已激活,跳过", - zap.Uint("package_usage_id", payload.PackageUsageID)) - return nil - } - - // 调用 ActivationService 执行激活 + // 调用 ActivationService 执行激活。即使套餐已由其他任务激活,仍须重新评估停复机。 if h.activationService != nil { if err := h.activationService.ActivateSpecificPackage(ctx, payload.PackageUsageID); err != nil { h.logger.Error("套餐激活失败", @@ -521,8 +561,20 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont return errors.New(errors.CodeInternalError, "激活服务未注入,无法执行套餐激活") } - h.logger.Info("套餐激活成功", - zap.Uint("package_usage_id", payload.PackageUsageID)) + carrierType, carrierID := h.getCarrierInfo(&pkg) + if err := h.reconcileCarrierAfterActivation(ctx, payload.PackageUsageID, carrierType, carrierID); err != nil { + h.logger.Error("套餐激活后停复机重评估失败", + zap.Uint("package_usage_id", payload.PackageUsageID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID), + zap.Error(err)) + return err + } + + h.logger.Info("套餐激活及停复机重评估完成", + zap.Uint("package_usage_id", payload.PackageUsageID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) return nil } diff --git a/internal/polling/queue_manager.go b/internal/polling/queue_manager.go index a999b6e..9729c53 100644 --- a/internal/polling/queue_manager.go +++ b/internal/polling/queue_manager.go @@ -98,6 +98,20 @@ func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType }).Err() } +// EnsureQueued 仅在任务当前不在分片队列中时补入任务,不覆盖已有任务的执行时间。 +func (m *PollingQueueManager) EnsureQueued(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) (bool, error) { + shardID := int(cardID) % m.shardCount + key := constants.RedisPollingShardQueueKey(shardID, taskType) + added, err := m.redis.ZAddArgs(ctx, key, redis.ZAddArgs{ + NX: true, + Members: []redis.Z{{ + Score: float64(nextCheckAt.Unix()), + Member: fmt.Sprintf("%d", cardID), + }}, + }).Result() + return added > 0, err +} + // RemoveFromAllQueues 从所有分片的所有5个队列(realname/carddata/package/protect/card_status)移除指定卡 // 修复 Bug3:旧实现漏掉 protect 队列 func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error { diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index fd7617d..f409300 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -334,10 +334,10 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } if !locked { - s.logger.Warn("套餐激活正在进行中,跳过", + s.logger.Warn("套餐激活正在进行中,等待任务重试", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) - return nil + return errors.New(errors.CodePackageActivationConflict) } defer s.redis.Del(ctx, lockKey) diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index 213be76..0d8d4b0 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -132,6 +132,29 @@ func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint, } } +// ensureMissingTask 根据数据库最新卡状态,仅在对应分片队列缺失时补入任务。 +func (b *PollingBase) ensureMissingTask(ctx context.Context, cardID uint, taskType string) error { + card, err := b.iotCardStore.GetByID(ctx, cardID) + if err != nil { + return err + } + + info, ok := b.configMgr.MergedTaskIntervals(card)[taskType] + if !ok || info.Interval <= 0 { + return nil + } + + added, err := b.queueMgr.EnsureQueued(ctx, cardID, taskType, time.Now()) + if err != nil { + return err + } + if added { + b.logger.Info("卡状态轮询补齐缺失套餐任务", + zap.Uint("card_id", cardID), zap.String("task_type", taskType)) + } + return nil +} + // requeueCardAt 使用独立短超时上下文执行真正的 ZADD 重入队。 func (b *PollingBase) requeueCardAt(cardID uint, taskType string, nextCheckAt time.Time) error { ctx, cancel := pollingFallbackContext() diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 37b6c40..33764f6 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -106,6 +106,12 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend)) return nil } + + // 卡状态任务仍正常但套餐任务丢失时,仅补入缺失项;不改写已有套餐任务的执行时间。 + h.base.invalidateCardCache(ctx, cardID) + if err := h.base.ensureMissingTask(ctx, cardID, constants.TaskTypePollingPackage); err != nil { + h.base.logger.Warn("卡状态轮询补齐套餐任务失败", zap.Uint("card_id", cardID), zap.Error(err)) + } return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } diff --git a/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/.openspec.yaml b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/.openspec.yaml new file mode 100644 index 0000000..50adc91 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-29 diff --git a/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/design.md b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/design.md new file mode 100644 index 0000000..ccd88e3 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/design.md @@ -0,0 +1,66 @@ +## Context + +见 proposal.md。当前到期处理在事务提交后投递异步套餐激活任务,同时以 goroutine 发起停机检查;两条路径没有共同顺序或共享状态结论。生产中已观察到新套餐激活后 1.9 至 15.7 秒仍写入 `no_package` 停机。 + +现有 `ActivationService` 已负责待生效主套餐激活及激活后的复机回调;`StopResumeService` 已集中停复机条件。保留这两个职责边界,不增加新的状态表或外部依赖。 + +## Goals / Non-Goals + +**Goals:** +- 同一载体到期接续期间不以过时权益快照停机。 +- 没有后续可生效套餐时保留现有停机语义。 +- 激活投递或执行暂时失败时宁可交由既有恢复/轮询兜底,不误判为无套餐。 +- 给维护者提供基于当前事实的只读候选筛选与受控补偿流程。 + +**Non-Goals:** +- 不新增自动批量复机、管理接口或数据库迁移。 +- 不把 Redis/Asynq 运行排障改造成业务状态机。 +- 不改变手动停机、运营商风险停机或实名策略语义。 + +## Decisions + +### 1. 将停机决策后移到套餐激活任务的最终重新评估 + +到期处理找到后续待生效主套餐时,只投递激活任务,不并发启动停机检查。激活任务完成后以该载体的最新套餐状态调用现有停机评估:成功激活时不触发无套餐停机;队首套餐因实名等业务条件不能激活时,才按当前事实评估停机。 + +这样复用现有激活锁、套餐条件和停复机服务,不让两个异步流程各自读取一次状态。 + +备选方案:在到期处理内同步完成套餐激活。未采用,因为到期扫描单轮最多处理大量套餐,同步串行激活会放大调度 Worker 的数据库和 Redis 锁持有时间。 + +### 2. 激活投递失败不是无套餐结论 + +仅在明确不存在后续主套餐时,允许到期处理立即发起停机评估。存在后续套餐但投递失败时记录错误并保留当前网络状态,由既有孤儿套餐恢复扫描和套餐轮询重试;不得回退到并发停机。 + +备选方案:投递失败立即停机。未采用,因为它重新引入“接续结果未知被当作无套餐”的错误路径。 + +### 3. 存量恢复与代码修复分离 + +代码发布后,维护者用只读查询筛选“有效主套餐、未耗尽、实名满足、`no_package` 停机”的候选卡;每张或受控小批次先查询运营商实时状态,再调用既有复机能力。补偿不直接更新数据库,也不自动对全部候选执行。 + +备选方案:发布时自动批量复机全部候选。未采用,因为本地状态不足以代替运营商风险、销户和人工业务判断。 + +### 4. 轮询只作兜底并单独核验运行覆盖 + +停机卡配置的 `polling:package` 仍是后备恢复机制,不能作为避免竞态的主路径。维护者分别核验调度 Worker 心跳、Redis 分片队列深度、Asynq 队列积压与消费者吞吐;这些运行指标只用于定位未补偿原因。 + +### 5. 卡状态轮询仅补齐缺失的套餐任务 + +生产日志确认存在卡状态和流量任务仍持续执行、但同一卡的 `polling:package` 队列项缺失的情形。卡状态轮询成功且未命中风险停机后,按数据库最新卡状态重新匹配轮询配置;若该卡应参与套餐检查,则以 Redis `ZADD NX` 仅在对应分片 Sorted Set 不存在该卡时立即补入套餐任务。已存在任务不得覆盖其 score,也不新增重复任务。 + +该做法不将卡状态轮询变为停复机决策路径:它只修复缺失的套餐调度项,最终仍由既有 `PollingPackageHandler → EvaluateAndAct` 判断权益、实名、停机原因和运营商结果。 + +## Risks / Trade-offs + +- [激活任务投递失败时卡暂不因套餐到期自动停机] → 保留孤儿恢复扫描和套餐轮询;记录失败并在运行排障中核验任务恢复。 +- [激活任务内增加最终停机评估] → 保持幂等条件和现有 Redis 锁;同一载体状态变化必须重读数据库。 +- [存量候选包含运营商侧不可复机卡] → 补偿前逐卡或受控小批查询运营商状态,拒绝风险停机、销户或其他非本地套餐原因。 +- [轮询吞吐不足延迟兜底] → 发布前后分别记录队列深度、消费速率和最近检查时间分布,不以单次数据库字段判断 Worker 健康。 +- [卡状态轮询补齐套餐任务] → 仅使用 `ZADD NX` 补入缺失成员,不改写已有任务的执行时间;最终复机仍受既有权益、实名、停机原因和运营商校验约束。 + +## Migration Plan + +1. 在隔离环境验证“旧套餐到期 + 后续套餐可激活”不会产生停机调用,及“无后续/不可激活”仍进入停机评估。 +2. 按生产运行说明发布 Worker;不需要数据库迁移。 +3. 维护者观察一轮套餐到期处理、激活任务与轮询队列,确认没有新的“有效套餐 + `no_package` 停机”记录。 +4. 使用只读筛选得到存量候选,按受控批次核验运营商状态后调用既有复机;失败项保留失败原因并人工处理。 +5. 若发布后出现意外停机语义,回滚 Worker 二进制;存量补偿不得直接写库。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/proposal.md b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/proposal.md new file mode 100644 index 0000000..423af46 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/proposal.md @@ -0,0 +1,28 @@ +## Why + +生产诊断发现套餐接续期间会并发投递下一套餐激活任务和无套餐停机检查。停机检查可在新套餐生效后才完成并回写停机,造成卡已具备有效套餐、实名和未耗尽流量却仍以 `no_package` 停机;当前已检出 117 张此类卡,且 13 张在新套餐生效后 120 秒内被停机。 + +## What Changes + +- 将主套餐到期后的“接续下一套餐”和“无有效套餐停机”收敛为同一条按载体串行的决策流程,禁止待生效套餐接续期间以旧快照发起停机。 +- 只有在确认不存在可生效的后续主套餐,或后续套餐经业务校验确定不能生效后,才执行无套餐停机检查。 +- 套餐接续完成后重新依据当前卡与套餐事实判断停复机,确保有效套餐卡不遗留 `no_package` 停机状态。 +- 为既有异常卡提供只读筛选条件和受控人工补偿步骤;补偿前必须重新核验套餐、流量、实名和运营商实时状态,不引入自动批量复机。 +- 排查并记录轮询调度、入队与消费覆盖情况;当卡状态轮询仍正常但 `polling:package` 队列项缺失时,仅补入缺失的套餐任务,确保套餐轮询仍可作为即时接续失败后的兜底,不将 Redis/Asynq 运行状态误判为业务终态。 + +## Capabilities + +### New Capabilities + +- 无。 + +### Modified Capabilities + +- `package-lifecycle`: 主套餐到期、后续套餐接续及无套餐停机必须基于同一载体的最新权益事实,避免接续成功后错误停机。 + +## Impact + +- 受影响代码:`internal/polling/package_activation_handler.go`、`internal/polling/queue_manager.go`、`internal/service/package/activation_service.go`、`internal/service/iot_card/stop_resume_service.go` 及相关轮询任务装配。 +- 受影响运行单元:调度 Worker、Asynq 套餐激活任务、套餐轮询任务。 +- 不新增 HTTP API、数据库 Schema 或第三方依赖。 +- 生产补偿仅由维护者执行受控外部复机;Agent 仅提供只读诊断与候选清单。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/specs/package-lifecycle/spec.md b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/specs/package-lifecycle/spec.md new file mode 100644 index 0000000..5568b3e --- /dev/null +++ b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/specs/package-lifecycle/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: 套餐状态流转 + +系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。 + +#### Scenario: 套餐状态流转 + +- **GIVEN** 套餐或使用记录处于允许的前置状态 +- **WHEN** 执行状态操作 +- **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误 + +#### Scenario: 到期主套餐接续后续套餐 + +- **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐 +- **WHEN** 系统处理该主套餐到期 +- **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机 + +#### Scenario: 到期主套餐无后续可生效套餐 + +- **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续主套餐不满足激活条件 +- **WHEN** 系统完成该套餐到期处理 +- **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估 + +#### Scenario: 后续套餐激活结果未知 + +- **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知 +- **WHEN** 系统处理该套餐到期 +- **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底 + +#### Scenario: 卡状态轮询发现缺失的套餐任务 + +- **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失 +- **WHEN** 卡状态轮询成功完成且未命中风险停机 +- **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡 diff --git a/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/tasks.md b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/tasks.md new file mode 100644 index 0000000..9456d4e --- /dev/null +++ b/openspec/changes/archive/2026-08-29-fix-package-expiry-stop-race/tasks.md @@ -0,0 +1,21 @@ +## 1. 到期接续顺序 + +- [x] 1.1 调整主套餐到期处理:明确区分“无后续主套餐”“已提交后续激活”和“后续激活投递失败”,仅无后续主套餐可立即进入停机评估。 +- [x] 1.2 调整套餐激活任务:在后续套餐激活完成或确认暂不能激活后,重读载体当前权益并调用既有停机评估;移除与激活任务并发的停机 goroutine。 +- [x] 1.3 保持激活后的既有复机回调、Redis 锁、审计与 Integration Log 语义,不新增状态表、接口或自动批量复机。 + +## 2. 运行兜底与可观测性 + +- [x] 2.1 核对停机卡匹配的 `polling:package` 配置及生命周期回调入队路径,修正本次改动涉及的入队或重排遗漏,确保其仅作为接续异常后的兜底。 +- [x] 2.2 为激活任务的最终停机评估保留可按载体、套餐使用记录和结果关联的中文结构化日志;不得记录敏感外部载荷。 + +## 3. 验证 + +- [ ] 3.1 在隔离环境人工演练“旧套餐到期且后续套餐可激活”“无后续套餐”“后续套餐暂不能激活”“激活投递失败”四种路径,核对停复机调用与套餐最终状态。 +- [x] 3.2 执行 `gofmt -w `、`go build ./cmd/api ./cmd/worker`、`openspec validate fix-package-expiry-stop-race --strict`。 + +## 4. 生产受控恢复 + +- [x] 4.1 维护者通过只读查询重新筛选“有效主套餐、流量未耗尽、实名满足且 `no_package` 停机”的候选卡,并记录筛选时点与数量。 +- [ ] 4.2 维护者按受控小批次查询运营商实时状态,仅对可复机卡调用既有复机能力;不得直接更新卡网络状态或停机原因。 +- [ ] 4.3 维护者核验调度 Worker 心跳、Redis 分片队列、Asynq 队列和消费者吞吐,并在发布后观察是否仍产生“有效套餐 + `no_package` 停机”记录。 diff --git a/openspec/specs/package-lifecycle/spec.md b/openspec/specs/package-lifecycle/spec.md index db05e73..0ca912a 100644 --- a/openspec/specs/package-lifecycle/spec.md +++ b/openspec/specs/package-lifecycle/spec.md @@ -8,7 +8,7 @@ ### Requirement: 套餐状态流转 -系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。 +系统 SHALL 按当前套餐和套餐使用状态控制上架、订购、激活、失效与到期处理。主套餐到期时,系统 MUST 先确定同一载体是否存在待生效的后续主套餐:存在时,后续套餐激活与停复机重新评估 MUST 由同一条顺序流程完成;系统 MUST NOT 依据后续套餐激活前的无套餐快照发起停机。后续套餐成功生效后,系统 MUST 依据最新套餐、流量和实名事实重新判断卡网络状态,且不得遗留 `no_package` 停机。不存在后续套餐或后续套餐经业务校验不能生效时,系统 SHALL 按现有停机规则评估卡状态。后续套餐激活结果未知或任务投递失败不得被当作无后续套餐处理并据此停机,系统 SHALL 保留既有激活恢复与轮询兜底路径。 #### Scenario: 套餐状态流转 @@ -16,6 +16,30 @@ - **WHEN** 执行状态操作 - **THEN** 仅发生一次允许的状态变化;不满足前置状态时返回业务错误 +#### Scenario: 到期主套餐接续后续套餐 + +- **GIVEN** 某载体的当前主套餐到期,且存在满足激活条件的待生效后续主套餐 +- **WHEN** 系统处理该主套餐到期 +- **THEN** 系统先完成后续套餐激活并按最新权益事实重新评估停复机,且不得因到期前的无套餐快照对该载体发起 `no_package` 停机 + +#### Scenario: 到期主套餐无后续可生效套餐 + +- **GIVEN** 某载体的当前主套餐到期,且不存在后续主套餐或队首后续主套餐不满足激活条件 +- **WHEN** 系统完成该套餐到期处理 +- **THEN** 系统按当前套餐、流量和实名事实执行既有停机评估 + +#### Scenario: 后续套餐激活结果未知 + +- **GIVEN** 某载体的当前主套餐到期,存在待生效后续主套餐,但激活任务投递或执行结果暂时未知 +- **WHEN** 系统处理该套餐到期 +- **THEN** 系统不得将该未知结果视为不存在后续套餐而依据旧快照发起停机,并保留既有激活恢复与套餐轮询兜底 + +#### Scenario: 卡状态轮询发现缺失的套餐任务 + +- **GIVEN** 启用轮询的卡匹配套餐检查配置,且其 `polling:package` 分片队列项因异常缺失 +- **WHEN** 卡状态轮询成功完成且未命中风险停机 +- **THEN** 系统基于最新卡状态仅补入缺失的套餐任务,不改写已存在套餐任务的执行时间;后续套餐任务仍按既有停复机条件评估该卡 + ### Requirement: 批量操作可追踪 系统 SHALL 为同步批量分配和调价直接返回处理结果;对异步批量订购返回任务标识并提供状态查询。异步批量订购完成后,系统 MUST 持久化每个输入行的成功或失败结果及与其一致的总数、成功数和失败数;部分资产因余额不足、资产校验或重复输入失败不得阻止任务进入完成终态。