package app import ( "encoding/json" "strconv" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/usermq" "hyapp/pkg/walletmq" taskdomain "hyapp/services/activity-service/internal/domain/task" ) const ( taskWalletRechargeRecorded = "WalletRechargeRecorded" taskCPRelationshipCreated = "UserCPRelationshipCreated" taskMicDailyStatsUpdated = "UserMicDailyStatsUpdated" taskMoodPublished = "UserMoodPublished" ) // taskEventFromWalletMessage 只把钱包 outbox 中的充值入账事实映射为任务事件。 // 钱包流水可能同时承载扣款、冻结、后台调账等事件,任务系统只消费正向充值金币,避免“钱包余额增加”被误算成充值任务。 func taskEventFromWalletMessage(body []byte) (taskdomain.Event, string, bool, error) { message, err := walletmq.DecodeWalletOutboxMessage(body) if err != nil { return taskdomain.Event{}, "", false, err } // 返回 ok=false 表示这条钱包事件和任务无关,MQ 可以直接确认;只有解析错误或任务写入错误才触发重试。 if message.EventType != taskWalletRechargeRecorded || message.UserID <= 0 { return taskdomain.Event{}, appcode.Normalize(message.AppCode), false, nil } decoded := decodeTaskPayload(message.PayloadJSON) // coin_amount 是新事件主字段;amount/available_delta/message.AvailableDelta 是兼容旧 outbox 或不同钱包实现的兜底来源。 coinAmount := firstNonZeroInt64( int64FromDecoded(decoded, "coin_amount"), int64FromDecoded(decoded, "amount"), int64FromDecoded(decoded, "available_delta"), message.AvailableDelta, ) if coinAmount <= 0 { return taskdomain.Event{}, appcode.Normalize(message.AppCode), false, nil } // 充值维度保留交易号、命令号、资产类型和商品信息,后台可按渠道或商品做任务过滤,也方便排查进度来源。 dimensions := map[string]any{ "transaction_id": message.TransactionID, "command_id": message.CommandID, "asset_type": message.AssetType, "recharge_type": firstNonEmptyString(stringFromDecoded(decoded, "recharge_type"), stringFromDecoded(decoded, "channel"), stringFromDecoded(decoded, "provider")), "google_product_id": firstNonEmptyString(stringFromDecoded(decoded, "google_product_id"), stringFromDecoded(decoded, "product_name"), stringFromDecoded(decoded, "product_code")), } return taskdomain.Event{ EventID: message.EventID + ":task:recharge_coin", EventType: message.EventType, SourceService: "wallet-service", UserID: message.UserID, MetricType: taskdomain.MetricRechargeCoin, Value: coinAmount, OccurredAtMS: message.OccurredAtMS, DimensionsJSON: mustTaskDimensions(dimensions), }, appcode.Normalize(message.AppCode), true, nil } // taskEventsFromUserMessage 按 user-service outbox 类型拆分任务事件。 // 这里不让 App 自报 CP、麦位或心情进度,所有进度都来自 user-service 已提交事实。 func taskEventsFromUserMessage(body []byte) ([]taskdomain.Event, string, error) { message, err := usermq.DecodeUserOutboxMessage(body) if err != nil { return nil, "", err } appCode := appcode.Normalize(message.AppCode) switch message.EventType { case taskCPRelationshipCreated: return cpTaskEventsFromUserMessage(message), appCode, nil case taskMicDailyStatsUpdated: return micTaskEventsFromUserMessage(message), appCode, nil case taskMoodPublished: return moodTaskEventsFromUserMessage(message), appCode, nil default: return nil, appCode, nil } } func cpTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event { payload := decodeTaskPayload(message.PayloadJSON) // CP 创建事件可能嵌套 relationship/application,也可能只带 aggregate_id;按多种字段名读取是为了兼容不同生产者版本。 relationship := mapFromTaskPayload(payload, "relationship", "Relationship") application := mapFromTaskPayload(payload, "application", "Application") requester := mapFromTaskPayload(application, "requester", "Requester") target := mapFromTaskPayload(application, "target", "Target") relationType := firstNonEmptyString( stringFromTaskPayload(relationship, "relation_type", "RelationType"), stringFromTaskPayload(application, "relation_type", "RelationType"), ) // CP 是双方共同完成的事实;为双方各生成一条任务事件,event_id 带 user_id 后缀保证双方幂等键互不冲突。 userIDs := uniquePositiveInt64s( int64FromTaskPayload(relationship, "user_a_id", "UserAID", "userAId"), int64FromTaskPayload(relationship, "user_b_id", "UserBID", "userBId"), int64FromTaskPayload(requester, "user_id", "UserID", "userId"), int64FromTaskPayload(target, "user_id", "UserID", "userId"), message.AggregateID, ) events := make([]taskdomain.Event, 0, len(userIDs)) for _, userID := range userIDs { events = append(events, taskdomain.Event{ EventID: message.EventID + ":task:cp_relationship_created:" + strconv.FormatInt(userID, 10), EventType: message.EventType, SourceService: "user-service", UserID: userID, MetricType: taskdomain.MetricCPRelationshipCreated, Value: 1, OccurredAtMS: message.OccurredAtMS, DimensionsJSON: mustTaskDimensions(map[string]any{ "relationship_id": stringFromTaskPayload(relationship, "relationship_id", "RelationshipID"), "relation_type": relationType, }), }) } return events } func micTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event { payload := decodeTaskPayload(message.PayloadJSON) // 麦上任务按服务端统计的增量分钟累计;不足 1 分钟的残余毫秒不写任务,避免重复消费时需要维护小数状态。 micOnlineMS := int64FromTaskPayload(payload, "mic_online_delta_ms", "MicOnlineDeltaMS") minutes := micOnlineMS / int64(time.Minute/time.Millisecond) userID := firstNonZeroInt64(int64FromTaskPayload(payload, "user_id", "UserID"), message.AggregateID) if userID <= 0 || minutes <= 0 { return nil } statDate := stringFromTaskPayload(payload, "stat_date", "StatDate") // 日统计事件用 stat_date 的 UTC 中午作为任务发生时间,避免跨时区边界把同一天统计归到前一天或后一天。 occurredAtMS := taskStatDateOccurredAtMS(statDate, message.OccurredAtMS) return []taskdomain.Event{{ EventID: message.EventID + ":task:mic_online_minute", EventType: message.EventType, SourceService: "user-service", UserID: userID, MetricType: taskdomain.MetricMicOnlineMinute, Value: minutes, OccurredAtMS: occurredAtMS, DimensionsJSON: mustTaskDimensions(map[string]any{ "stat_date": statDate, "mic_session_id": stringFromTaskPayload(payload, "mic_session_id", "MicSessionID"), }), }} } func moodTaskEventsFromUserMessage(message usermq.UserOutboxMessage) []taskdomain.Event { // 心情发布先预留 owner API 事件口径;只要 user-service 发出已提交 outbox,就按一次发布累计。 userID := message.AggregateID if userID <= 0 { payload := decodeTaskPayload(message.PayloadJSON) userID = int64FromTaskPayload(payload, "user_id", "UserID") } if userID <= 0 { return nil } return []taskdomain.Event{{ EventID: message.EventID + ":task:mood_publish_count", EventType: message.EventType, SourceService: "user-service", UserID: userID, MetricType: taskdomain.MetricMoodPublishCount, Value: 1, OccurredAtMS: message.OccurredAtMS, DimensionsJSON: mustTaskDimensions(decodeTaskPayload(message.PayloadJSON)), }} } func decodeTaskPayload(payload string) map[string]any { // 无效 payload 不阻断无维度任务,返回空对象后仍可累计通用指标;需要指定维度的任务会在仓储匹配阶段自然跳过。 var decoded map[string]any if err := json.Unmarshal([]byte(payload), &decoded); err != nil || decoded == nil { return map[string]any{} } return decoded } func mapFromTaskPayload(decoded map[string]any, keys ...string) map[string]any { for _, key := range keys { if value, ok := decoded[key].(map[string]any); ok { return value } } return map[string]any{} } func stringFromTaskPayload(decoded map[string]any, keys ...string) string { for _, key := range keys { if value, ok := decoded[key].(string); ok && strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func int64FromTaskPayload(decoded map[string]any, keys ...string) int64 { for _, key := range keys { if value := int64FromDecoded(decoded, key); value != 0 { return value } } return 0 } func uniquePositiveInt64s(values ...int64) []int64 { // 同一个用户可能同时出现在 relationship 和 requester 字段,去重后再生成任务事件,避免一次 CP 事实给同一人加两次进度。 seen := make(map[int64]bool, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func taskStatDateOccurredAtMS(statDate string, fallback int64) int64 { parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(statDate), time.UTC) if err != nil { // 旧消息没有 stat_date 或格式异常时退回 outbox 发生时间,保证事件仍可消费并由幂等表记录排障。 return fallback } return parsed.Add(12 * time.Hour).UnixMilli() } func mustTaskDimensions(payload map[string]any) string { data, err := json.Marshal(payload) if err != nil { // 维度只用于过滤和排查,序列化失败不能阻断主指标;返回 {} 后只有无维度过滤的任务会累计。 return "{}" } return string(data) }