2026-07-15 09:43:33 +08:00

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
}