Files
junhong_cmp_fiber/internal/application/cardobservation/series.go
break 5c4d17e9fc
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 联调延期
2026-07-24 19:59:24 +08:00

344 lines
14 KiB
Go

package cardobservation
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesRequest 描述一次业务成功边界产生的观测序列请求。
type SeriesRequest struct {
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
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"`
Attempt int `json:"attempt"`
ScheduledAt time.Time `json:"scheduled_at"`
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
type RunResult struct {
StateChanged bool
RateLimited bool
}
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
type SeriesCoordinator interface {
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)
FinishAttempt(ctx context.Context, payload SeriesTaskPayload) error
CompleteSeries(ctx context.Context, payload SeriesTaskPayload) error
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
}
// SeriesScheduler 提交固定的零重试观测任务。
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
RecordMerged(ctx context.Context, request SeriesRequest, seriesID string) error
}
// SeriesRunner 查询本地快照并执行一次 Gateway 公共观测。
type SeriesRunner interface {
Provider(ctx context.Context, payload SeriesTaskPayload) (string, error)
ExpectationMet(ctx context.Context, payload SeriesTaskPayload) (bool, error)
Run(ctx context.Context, payload SeriesTaskPayload) (RunResult, error)
}
// SeriesTrigger 创建或合并固定的立即、3 分钟、5 分钟任务序列。
type SeriesTrigger struct {
coordinator SeriesCoordinator
scheduler SeriesScheduler
logger SeriesAttemptLogger
now func() time.Time
}
// NewSeriesTrigger 创建观测序列触发器。
func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler, logger SeriesAttemptLogger) *SeriesTrigger {
return &SeriesTrigger{coordinator: coordinator, scheduler: scheduler, logger: logger, now: time.Now}
}
// 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, 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: 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
}
}
if merged {
if err := s.logger.RecordMerged(ctx, request, seriesID); err != nil {
return "", true, err
}
}
return seriesID, merged, nil
}
// SeriesAttemptService 执行单次事件观测,不改变后续阶梯任务。
type SeriesAttemptService struct {
coordinator SeriesCoordinator
runner SeriesRunner
logger SeriesAttemptLogger
}
// NewSeriesAttemptService 创建序列尝试服务。
func NewSeriesAttemptService(coordinator SeriesCoordinator, runner SeriesRunner, logger SeriesAttemptLogger) *SeriesAttemptService {
return &SeriesAttemptService{coordinator: coordinator, runner: runner, logger: logger}
}
// Execute 执行一次幂等尝试;失败只结束当前任务。
func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPayload) error {
if s == nil || s.coordinator == nil || s.runner == nil || s.logger == nil {
return errors.New(errors.CodeInternalError, "卡观测序列执行能力未完整配置")
}
if err := validateSeriesPayload(payload); err != nil {
return err
}
claimed, err := s.coordinator.ClaimAttempt(ctx, payload.SeriesID, payload.Attempt)
if err != nil || !claimed {
return err
}
defer func() { _ = s.coordinator.FinishAttempt(context.Background(), payload) }()
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err)
}
if !acquired {
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
}
defer release()
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return s.logger.Record(ctx, payload, constants.IntegrationResultRateLimited, "最小请求间隔等待被取消")
case <-timer.C:
}
}
result, err := s.runner.Run(ctx, payload)
if result.RateLimited {
return nil
}
if err != nil {
return err
}
if payload.Attempt == constants.CardObservationSeriesAttemptCount {
return s.coordinator.CompleteSeries(ctx, payload)
}
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++ {
remaining := payload
remaining.Attempt = attempt
remaining.ScheduledAt = baseTime.Add(constants.CardObservationAttemptDelay(attempt))
if err := s.logger.Record(ctx, remaining, constants.IntegrationResultCompleted, reason); err != nil {
return err
}
}
return s.coordinator.CompleteSeries(ctx, payload)
}
// CompleteResourceSeries 供可信回调在公共观测成功后提前完成同资源序列。
func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
if s == nil || s.coordinator == nil {
return errors.New(errors.CodeInternalError, "卡观测序列协调器未配置")
}
return s.coordinator.CompleteResourceSeries(ctx, resourceType, resourceID, syncType)
}
func validateSeriesRequest(request SeriesRequest) error {
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
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
}
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, 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 {
switch syncType {
case constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork, constants.CardObservationSyncTypeDeviceInfo:
return true
default:
return false
}
}