182 lines
7.1 KiB
Go
182 lines
7.1 KiB
Go
// 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
|
|
}
|