238 lines
9.5 KiB
Go
238 lines
9.5 KiB
Go
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)
|
||
}
|