收口七月卡状态回调与系列授权兼容契约
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:
@@ -23,6 +23,29 @@ type SeriesRequest struct {
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
|
||||
type DeviceCardsSeriesRequest struct {
|
||||
DeviceID uint
|
||||
Request SeriesRequest
|
||||
}
|
||||
|
||||
// DeviceControlSeriesRequest 描述设备控制成功后需要在后台解析的差异化观测资源。
|
||||
type DeviceControlSeriesRequest struct {
|
||||
DeviceID uint
|
||||
TargetICCID string
|
||||
SourceCardID uint
|
||||
TargetCardID uint
|
||||
BoundCardIDs []uint
|
||||
IncludeTargetTraffic bool
|
||||
Request SeriesRequest
|
||||
}
|
||||
|
||||
// RealnameCapabilitySeriesRequest 描述需要在后台判断运营商实名能力的观测请求。
|
||||
type RealnameCapabilitySeriesRequest struct {
|
||||
CarrierID uint
|
||||
Request SeriesRequest
|
||||
}
|
||||
|
||||
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
|
||||
type SeriesTaskPayload struct {
|
||||
SeriesID string `json:"series_id"`
|
||||
@@ -46,7 +69,10 @@ type RunResult struct {
|
||||
|
||||
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
|
||||
type SeriesCoordinator interface {
|
||||
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, merged bool, err error)
|
||||
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, originalRequest SeriesRequest, merged bool, err error)
|
||||
IsScheduled(ctx context.Context, seriesID string) (bool, error)
|
||||
ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error)
|
||||
ReleaseSchedule(ctx context.Context, seriesID string, attempt int)
|
||||
IsCompleted(ctx context.Context, seriesID string) (bool, error)
|
||||
ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error)
|
||||
AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error)
|
||||
@@ -60,6 +86,14 @@ type SeriesScheduler interface {
|
||||
Enqueue(ctx context.Context, payload SeriesTaskPayload) error
|
||||
}
|
||||
|
||||
// BestEffortSeriesDispatcher 为读取入口提供不返回业务错误的轻量触发端口。
|
||||
type BestEffortSeriesDispatcher interface {
|
||||
Dispatch(ctx context.Context, request SeriesRequest)
|
||||
DispatchDeviceCards(ctx context.Context, request DeviceCardsSeriesRequest)
|
||||
DispatchDeviceControl(ctx context.Context, request DeviceControlSeriesRequest)
|
||||
DispatchRealnameWithCapability(ctx context.Context, request RealnameCapabilitySeriesRequest)
|
||||
}
|
||||
|
||||
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
|
||||
type SeriesAttemptLogger interface {
|
||||
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
|
||||
@@ -88,27 +122,58 @@ func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler,
|
||||
|
||||
// Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。
|
||||
func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) {
|
||||
return s.trigger(ctx, request, uuid.NewString(), false)
|
||||
}
|
||||
|
||||
// TriggerEvent 使用稳定业务事件键触发序列,至少一次重投不会再次创建任务。
|
||||
func (s *SeriesTrigger) TriggerEvent(ctx context.Context, eventKey string, request SeriesRequest) (string, bool, error) {
|
||||
seriesID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(eventKey)).String()
|
||||
return s.trigger(ctx, request, seriesID, true)
|
||||
}
|
||||
|
||||
func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, candidateSeriesID string, stable bool) (string, bool, error) {
|
||||
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
|
||||
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
|
||||
}
|
||||
request = normalizeSeriesTrace(request)
|
||||
if err := validateSeriesRequest(request); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if stable {
|
||||
scheduled, err := s.coordinator.IsScheduled(ctx, candidateSeriesID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if scheduled {
|
||||
if logErr := s.logger.RecordMerged(ctx, request, candidateSeriesID); logErr != nil {
|
||||
return candidateSeriesID, true, logErr
|
||||
}
|
||||
return candidateSeriesID, true, nil
|
||||
}
|
||||
}
|
||||
candidateBaseTime := s.now().UTC()
|
||||
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime)
|
||||
seriesID, baseTime, originalRequest, merged, err := s.coordinator.Reserve(ctx, request, candidateSeriesID, candidateBaseTime)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
|
||||
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
|
||||
claimed, claimErr := s.coordinator.ClaimSchedule(ctx, seriesID, attempt)
|
||||
if claimErr != nil {
|
||||
return seriesID, merged, claimErr
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
|
||||
payload := SeriesTaskPayload{
|
||||
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
|
||||
Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID,
|
||||
SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source,
|
||||
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
|
||||
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
|
||||
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
|
||||
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
|
||||
}
|
||||
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
|
||||
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
|
||||
return seriesID, false, err
|
||||
}
|
||||
}
|
||||
@@ -148,25 +213,25 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
|
||||
|
||||
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
|
||||
}
|
||||
if completed {
|
||||
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
|
||||
}
|
||||
met, err := s.runner.ExpectationMet(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
|
||||
}
|
||||
if met {
|
||||
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
|
||||
}
|
||||
provider, err := s.runner.Provider(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
|
||||
}
|
||||
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err)
|
||||
}
|
||||
if !acquired {
|
||||
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
|
||||
@@ -194,6 +259,13 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SeriesAttemptService) recordPreGatewayFailure(ctx context.Context, payload SeriesTaskPayload, reason string, original error) error {
|
||||
if logErr := s.logger.Record(ctx, payload, constants.IntegrationResultFailed, reason); logErr != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, original, reason+",且 Integration Log 写入失败")
|
||||
}
|
||||
return original
|
||||
}
|
||||
|
||||
func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error {
|
||||
baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt))
|
||||
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
|
||||
@@ -217,7 +289,8 @@ func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resou
|
||||
|
||||
func validateSeriesRequest(request SeriesRequest) error {
|
||||
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
|
||||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) {
|
||||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) || !validObservationSource(request.Source) ||
|
||||
strings.TrimSpace(request.RequestID) == "" || strings.TrimSpace(request.CorrelationID) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整")
|
||||
}
|
||||
return nil
|
||||
@@ -227,7 +300,36 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
|
||||
if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount {
|
||||
return errors.New(errors.CodeInvalidParam, "卡观测序列任务载荷无效")
|
||||
}
|
||||
return validateSeriesRequest(SeriesRequest{Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID, SyncType: payload.SyncType})
|
||||
return validateSeriesRequest(SeriesRequest{
|
||||
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
|
||||
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest {
|
||||
request.RequestID = strings.TrimSpace(request.RequestID)
|
||||
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
|
||||
if request.RequestID == "" && request.CorrelationID == "" {
|
||||
traceID := uuid.NewString()
|
||||
request.RequestID = traceID
|
||||
request.CorrelationID = traceID
|
||||
} else if request.RequestID == "" {
|
||||
request.RequestID = request.CorrelationID
|
||||
} else if request.CorrelationID == "" {
|
||||
request.CorrelationID = request.RequestID
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func validObservationSource(source string) bool {
|
||||
switch source {
|
||||
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
|
||||
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
|
||||
constants.CardObservationSourceBusinessEvent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validSyncType(syncType string) bool {
|
||||
|
||||
Reference in New Issue
Block a user