2026-06-11 13:33:44 +08:00

238 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}