// Package activitymq defines the RocketMQ fact contract owned by activity-service. package activitymq import ( "encoding/json" "errors" "fmt" "sort" "strings" "time" ) const ( MessageTypeActivityTemplateFact = "activity_template_fact" TagActivityTemplateFact = MessageTypeActivityTemplateFact EventTypeGiftScored = "GiftScored" EventTypeTaskProgressed = "TaskProgressed" EventTypeTaskClaimed = "TaskClaimed" EventTypeActivityVisited = "ActivityVisited" ) // ActivityTemplateFactMessage is a self-contained immutable activity fact. Every // dimension needed by statistics is carried in the message so consumers never // need to read activity-service owner tables. type ActivityTemplateFactMessage struct { MessageType string `json:"message_type"` AppCode string `json:"app_code"` EventID string `json:"event_id"` EventType string `json:"event_type"` TemplateID string `json:"template_id"` TemplateCode string `json:"template_code"` PublishedVersion int64 `json:"published_version"` StatDay string `json:"stat_day"` RegionID int64 `json:"region_id"` UserID int64 `json:"user_id"` GiftID string `json:"gift_id"` TaskKey string `json:"task_key"` TaskType string `json:"task_type"` GiftCountDelta int64 `json:"gift_count_delta"` CoinAmountDelta int64 `json:"coin_amount_delta"` ProgressDelta int64 `json:"progress_delta"` CompletedDelta int64 `json:"completed_delta"` ClaimedDelta int64 `json:"claimed_delta"` VisitCountDelta int64 `json:"visit_count_delta"` OccurredAtMS int64 `json:"occurred_at_ms"` AttributionAtMS int64 `json:"attribution_at_ms"` } // EventTypeTag accepts only committed owner facts. Keeping the allow-list here // prevents a malformed database value from becoming a broker routing expression. func EventTypeTag(eventType string) (string, error) { switch tag := strings.TrimSpace(eventType); tag { case EventTypeGiftScored, EventTypeTaskProgressed, EventTypeTaskClaimed, EventTypeActivityVisited: return tag, nil case "": return "", errors.New("activity event_type tag is required") default: return "", fmt.Errorf("unsupported activity event_type tag %q", eventType) } } // LegacyCompatibleTagExpression keeps messages published before typed tags were // enabled consumable while making all new deliveries broker-filtered and explicit. func LegacyCompatibleTagExpression(eventTypes ...string) (string, error) { tags := make([]string, 0, len(eventTypes)) seen := make(map[string]struct{}, len(eventTypes)) for _, eventType := range eventTypes { tag, err := EventTypeTag(eventType) if err != nil { return "", err } if _, exists := seen[tag]; exists { continue } seen[tag] = struct{}{} tags = append(tags, tag) } if len(tags) == 0 { return "", errors.New("at least one typed activity event tag is required") } sort.Strings(tags) return TagActivityTemplateFact + " || " + strings.Join(tags, " || "), nil } // EncodeActivityTemplateFactMessage normalizes the wrapper discriminator and // rejects poison facts before they can leave the transactional outbox relay. func EncodeActivityTemplateFactMessage(message ActivityTemplateFactMessage) ([]byte, error) { message.MessageType = MessageTypeActivityTemplateFact message = normalizeActivityTemplateFact(message) if err := validateActivityTemplateFact(message); err != nil { return nil, err } return json.Marshal(message) } // DecodeActivityTemplateFactMessage applies the same validation at every ingest // boundary, including replay tooling that may bypass the original producer. func DecodeActivityTemplateFactMessage(body []byte) (ActivityTemplateFactMessage, error) { var message ActivityTemplateFactMessage if err := json.Unmarshal(body, &message); err != nil { return ActivityTemplateFactMessage{}, err } if message.MessageType != MessageTypeActivityTemplateFact { return ActivityTemplateFactMessage{}, errors.New("unexpected activity template fact message_type") } message = normalizeActivityTemplateFact(message) if err := validateActivityTemplateFact(message); err != nil { return ActivityTemplateFactMessage{}, err } return message, nil } func normalizeActivityTemplateFact(message ActivityTemplateFactMessage) ActivityTemplateFactMessage { message.AppCode = strings.TrimSpace(message.AppCode) message.EventID = strings.TrimSpace(message.EventID) message.EventType = strings.TrimSpace(message.EventType) message.TemplateID = strings.TrimSpace(message.TemplateID) message.TemplateCode = strings.TrimSpace(message.TemplateCode) message.StatDay = strings.TrimSpace(message.StatDay) message.GiftID = strings.TrimSpace(message.GiftID) message.TaskKey = strings.TrimSpace(message.TaskKey) message.TaskType = strings.TrimSpace(message.TaskType) if message.AttributionAtMS <= 0 { message.AttributionAtMS = message.OccurredAtMS } return message } func validateActivityTemplateFact(message ActivityTemplateFactMessage) error { if message.AppCode == "" || message.EventID == "" || message.TemplateID == "" || message.TemplateCode == "" || message.PublishedVersion <= 0 || message.RegionID < 0 || message.UserID <= 0 || message.OccurredAtMS <= 0 { return errors.New("activity template fact is incomplete") } if _, err := EventTypeTag(message.EventType); err != nil { return err } if message.GiftCountDelta < 0 || message.CoinAmountDelta < 0 || message.ProgressDelta < 0 || message.CompletedDelta < 0 || message.ClaimedDelta < 0 || message.VisitCountDelta < 0 { return errors.New("activity template fact deltas cannot be negative") } parsedDay, err := time.Parse("2006-01-02", message.StatDay) if err != nil || parsedDay.Format("2006-01-02") != message.StatDay { return errors.New("activity template fact stat_day must be a UTC YYYY-MM-DD date") } // Task claims can be executed days after completion. stat_day remains the task earning day while // occurred_at_ms is the truthful wallet-grant time; attribution_at_ms proves the owner-selected bucket. daySourceMS := message.OccurredAtMS if message.EventType == EventTypeTaskClaimed { daySourceMS = message.AttributionAtMS } if time.UnixMilli(daySourceMS).UTC().Format("2006-01-02") != message.StatDay { return errors.New("activity template fact stat_day does not match attribution time") } switch message.EventType { case EventTypeGiftScored: if message.GiftID == "" || message.GiftCountDelta <= 0 { return errors.New("GiftScored requires gift_id and positive gift_count_delta") } case EventTypeTaskProgressed: if message.TaskKey == "" || message.TaskType == "" || (message.ProgressDelta == 0 && message.CompletedDelta == 0) { return errors.New("TaskProgressed requires task identity and a progress or completed delta") } case EventTypeTaskClaimed: if message.TaskKey == "" || message.TaskType == "" || message.ClaimedDelta <= 0 { return errors.New("TaskClaimed requires task identity and positive claimed_delta") } case EventTypeActivityVisited: if message.VisitCountDelta <= 0 { return errors.New("ActivityVisited requires positive visit_count_delta") } } return nil }