package contract import ( "time" "unicode/utf8" ) // Cursor is an opaque repository-issued continuation token. Clients must not // parse, increment, or reuse it for another run/filter. Empty starts a scan; // an empty nextCursor ends it. An empty page with a cursor is not end-of-scan. // Repositories validate token contents and query binding with ErrInvalidArgument. type Cursor string const ( DefaultPageLimit = 100 MaxPageLimit = 500 ) type PageRequest struct { Cursor Cursor `json:"cursor,omitempty"` Limit int `json:"limit,omitempty"` } // Normalized must be called at both HTTP and repository boundaries. Limits // outside the bounds are rejected, not silently truncated. func (p PageRequest) Normalized() (PageRequest, error) { if p.Limit == 0 { p.Limit = DefaultPageLimit } if p.Limit < 1 || p.Limit > MaxPageLimit { return PageRequest{}, invalid("limit must be between 1 and %d", MaxPageLimit) } return p, nil } // Failure is the bounded public projection, not the replay payload. Legacy // free-form failureType/message fields remain available in FailureRecord. type Failure struct { TaskID string `json:"taskId"` CardID int64 `json:"cardId"` ErrorCategory ErrorCategory `json:"errorCategory"` ErrorMessage string `json:"errorMessage"` FailedAt time.Time `json:"failedAt"` Round int `json:"round"` } func (f Failure) Validate() error { if f.TaskID == "" || f.Round < 0 || len(f.ErrorMessage) > MaxErrorMessageBytes || !utf8.ValidString(f.ErrorMessage) { return invalid("invalid failure summary") } return nil } type FailurePage struct { Items []Failure `json:"items"` NextCursor Cursor `json:"nextCursor"` } // FailureRecord preserves the existing persisted payload without changing // review semantics or discarding fields needed to reconstruct replay tasks. type FailureRecord struct { Version int `json:"version"` TaskID string `json:"taskId"` OriginalRunID string `json:"originalRunId"` LatestRunID string `json:"latestRunId"` QueryMonth string `json:"queryMonth"` Operation string `json:"operation"` CardID int64 `json:"cardId"` ICCID string `json:"iccid"` MSISDN string `json:"msisdn"` IMSI string `json:"imsi"` ChanKey string `json:"chanKey"` TempKey string `json:"tempKey"` // PackageCode is additive; older failure records decode it as empty. PackageCode string `json:"packageCode,omitempty"` Carrier string `json:"carrier"` FailureType string `json:"failureType"` Round int `json:"round"` Attempts int `json:"attempts"` Code int `json:"code"` ErrCode string `json:"errCode"` Message string `json:"message"` ReviewStatus string `json:"reviewStatus"` ReviewRemark string `json:"reviewRemark"` ReviewedBy string `json:"reviewedBy"` ReviewedAt string `json:"reviewedAt"` RetryCycle int `json:"retryCycle"` ReplayRunID string `json:"replayRunId"` FirstFailedAt time.Time `json:"firstFailedAt"` LastFailedAt time.Time `json:"lastFailedAt"` } func (f FailureRecord) Summary() Failure { return Failure{ TaskID: f.TaskID, CardID: f.CardID, ErrorCategory: NormalizeErrorCategory(f.FailureType), ErrorMessage: BoundedErrorMessage(f.Message), FailedAt: f.LastFailedAt, Round: f.Round, } } // FailureEntry carries an opaque version for conditional source cleanup. It // must identify the precise record read, not just its reusable task ID. type FailureEntry struct { Record FailureRecord `json:"record"` Version string `json:"-"` } type FailureRecordPage struct { Items []FailureEntry `json:"items"` NextCursor Cursor `json:"nextCursor"` } // Task keeps the existing queue member serialization. The scheduling score // must never be part of the JSON member or producer retries lose idempotency. type Task struct { TaskID string `json:"taskId"` RunID string `json:"runId"` Operation string `json:"operation"` QueryMonth string `json:"queryMonth"` ChanKey string `json:"chanKey"` TempKey string `json:"tempKey"` Operator int `json:"operator,omitempty"` CardID int64 `json:"cardId"` ICCID string `json:"iccid"` MSISDN string `json:"msisdn"` IMSI string `json:"imsi"` // PackageCode is additive; older members decode it as empty. PackageCode string `json:"packageCode,omitempty"` NextExecuteAt int64 `json:"-"` }