收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -2,8 +2,10 @@ package packagepkg
import (
"context"
"strconv"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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"
@@ -27,6 +29,12 @@ type ActivationService struct {
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
func NewActivationService(
@@ -81,8 +89,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
now := time.Now()
activated := false
// 在事务中激活套餐
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages {
// 查询套餐信息
var pkg model.Package
@@ -144,6 +153,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -153,20 +165,18 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
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))
}
}(carrierType, carrierID)
}
activated = true
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "实名激活后自动复机失败")
}
return nil
}
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
@@ -187,7 +197,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
defer s.redis.Del(ctx, lockKey)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 9.5: 检测并标记过期的主套餐
now := time.Now()
var expiredMainUsages []*model.PackageUsage
@@ -221,13 +232,22 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
}
// 任务 9.6: 激活下一个待生效主套餐
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil {
currentActivated, err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now)
if err != nil {
return err
}
activated = activated || currentActivated
}
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败")
}
return nil
}
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
@@ -285,7 +305,8 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
}
// 事务内激活
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
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 {
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
return err
}
// 异步复机
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))
}
}(carrierType, carrierID)
}
activated = true
return nil
})
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败")
}
return nil
}
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
@@ -409,6 +425,9 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
if err != nil {
return false, err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败")
}
return activated, nil
}
@@ -451,22 +470,22 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
}
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error {
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 nil
return false, nil
}
if err != nil {
return err
return false, err
}
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil {
return err
return false, err
}
if !canActivate {
s.logger.Info("下一个待生效主套餐暂不满足激活条件",
@@ -474,16 +493,19 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return nil
return false, nil
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活")
if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); 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) {
@@ -589,6 +611,9 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
}
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -598,20 +623,45 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt))
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))
}
}(carrierType, carrierID)
}
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