收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
@@ -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(¤tUsage, packageUsageID).Error; err != nil {
|
||||
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
|
||||
if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &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
|
||||
|
||||
Reference in New Issue
Block a user