2026-07-23 19:51:18 +08:00

315 lines
11 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 rewardnotice turns committed wallet reward facts into one idempotent activity inbox message.
package rewardnotice
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
messageservice "hyapp/services/activity-service/internal/service/message"
)
const (
rewardTypeResource = "resource"
rewardTypeCoin = "coin"
millisecondsPerDay = int64(24 * 60 * 60 * 1000)
)
// Activity 固定活动英文名和稳定代码,避免各发奖链路自行拼出不同文案或幂等键。
type Activity struct {
Code string
Name string
}
var (
CumulativeRecharge = Activity{Code: "cumulative_recharge", Name: "Cumulative Recharge Reward"}
WeeklyStar = Activity{Code: "weekly_star", Name: "Weekly Star Reward"}
RoomRocket = Activity{Code: "room_rocket", Name: "Room Rocket Reward"}
SevenDayCheckIn = Activity{Code: "seven_day_checkin", Name: "Seven-Day Check-In Reward"}
FirstRecharge = Activity{Code: "first_recharge", Name: "First Recharge Gift"}
Registration = Activity{Code: "registration", Name: "Registration Reward"}
DailyTask = Activity{Code: "daily_task", Name: "Daily Task Reward"}
CPRanking = Activity{Code: "cp_ranking", Name: "CP Ranking Reward"}
RoomTurnover = Activity{Code: "room_turnover", Name: "Room Turnover Reward"}
)
// Command 只接受钱包已成功返回的 grant/coin 事实;调用方不能用活动配置伪造实际到账内容。
type Command struct {
Activity Activity
TargetUserID int64
ProducerEventID string
AggregateType string
AggregateID string
Grants []*walletv1.ResourceGrant
GrantIDs []string
CoinAmount int64
SentAtMS int64
Metadata map[string]any
}
// Notifier 是九类活动共享的最小依赖面,便于业务服务和 MQ 消费者统一接入。
type Notifier interface {
NotifyReward(ctx context.Context, command Command) error
}
// ActivityNoticeWriter keeps reward formatting separate from the inbox owner.
type ActivityNoticeWriter interface {
CreateActivityNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
}
// ResourceGrantReader 供异步房间火箭事件按精确 grant_id 补取钱包发放快照。
type ResourceGrantReader interface {
GetResourceGrant(ctx context.Context, req *walletv1.GetResourceGrantRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service owns reward snapshot conversion and activity-message idempotency.
type Service struct {
notices ActivityNoticeWriter
wallet ResourceGrantReader
}
func New(notices ActivityNoticeWriter, wallet ResourceGrantReader) *Service {
return &Service{notices: notices, wallet: wallet}
}
// NotifyReward creates exactly one activity message for one business reward event.
// 钱包成功、消息失败时调用方保留 pending/failed 状态并重试;稳定 producer_event_id 会让重试只补齐一次消息。
func (s *Service) NotifyReward(ctx context.Context, command Command) error {
if s == nil || s.notices == nil {
return xerr.New(xerr.Unavailable, "reward notice service is not configured")
}
command = normalizeCommand(command)
if err := validateCommand(command); err != nil {
return err
}
grants := append([]*walletv1.ResourceGrant(nil), command.Grants...)
for _, grantID := range command.GrantIDs {
if s.wallet == nil {
return xerr.New(xerr.Unavailable, "wallet resource grant reader is not configured")
}
// grant_id 来自已提交的 room outbox精确读取避免分页扫描误拿同用户其它活动奖励。
resp, err := s.wallet.GetResourceGrant(ctx, &walletv1.GetResourceGrantRequest{
RequestId: "reward-notice:" + command.Activity.Code + ":" + command.ProducerEventID + ":" + grantID,
AppCode: appcode.FromContext(ctx),
GrantId: grantID,
})
if err != nil {
return err
}
if resp.GetGrant() == nil {
return xerr.New(xerr.Unavailable, "wallet resource grant response is empty")
}
grants = append(grants, resp.GetGrant())
}
for _, grant := range grants {
if grant == nil || grant.GetTargetUserId() != command.TargetUserID {
// 站内信收件人必须与钱包 grant 的真实归属完全一致,不能让事件或调用方传错用户后展示他人奖励。
return xerr.New(xerr.Conflict, "reward notice target does not match wallet grant")
}
}
rewardItems, grantIDs, err := rewardItemsFromFacts(grants, command.CoinAmount)
if err != nil {
return err
}
imageURLs := rewardImageURLs(rewardItems)
metadata := cloneMetadata(command.Metadata)
metadata["activity_code"] = command.Activity.Code
metadata["activity_name"] = command.Activity.Name
if len(grantIDs) > 0 {
metadata["wallet_grant_ids"] = grantIDs
}
if command.CoinAmount > 0 {
metadata["coin_amount"] = command.CoinAmount
}
body := "You received the " + command.Activity.Name + ":"
_, err = s.notices.CreateActivityNotice(ctx, messageservice.NoticeCommand{
TargetUserID: command.TargetUserID,
Producer: "activity-reward",
ProducerEventID: "reward:" + command.Activity.Code + ":" + command.ProducerEventID,
ProducerEventType: "activity_reward_granted",
AggregateType: command.AggregateType,
AggregateID: command.AggregateID,
Title: command.Activity.Name,
Summary: body,
Body: body,
ImageURLs: imageURLs,
RewardItems: rewardItems,
SentAtMS: command.SentAtMS,
Metadata: metadata,
})
return err
}
func normalizeCommand(command Command) Command {
command.Activity.Code = strings.ToLower(strings.TrimSpace(command.Activity.Code))
command.Activity.Name = strings.TrimSpace(command.Activity.Name)
command.ProducerEventID = strings.TrimSpace(command.ProducerEventID)
command.AggregateType = strings.TrimSpace(command.AggregateType)
command.AggregateID = strings.TrimSpace(command.AggregateID)
if command.AggregateType == "" {
command.AggregateType = "activity_reward"
}
if command.AggregateID == "" {
command.AggregateID = command.ProducerEventID
}
grantIDs := make([]string, 0, len(command.GrantIDs))
seen := make(map[string]struct{}, len(command.GrantIDs))
for _, grantID := range command.GrantIDs {
grantID = strings.TrimSpace(grantID)
if grantID == "" {
continue
}
if _, exists := seen[grantID]; exists {
continue
}
seen[grantID] = struct{}{}
grantIDs = append(grantIDs, grantID)
}
command.GrantIDs = grantIDs
return command
}
func validateCommand(command Command) error {
if command.Activity.Code == "" || command.Activity.Name == "" || command.TargetUserID <= 0 || command.ProducerEventID == "" {
return xerr.New(xerr.InvalidArgument, "reward notice command is incomplete")
}
if command.CoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "reward notice coin amount cannot be negative")
}
if command.CoinAmount == 0 && len(command.Grants) == 0 && len(command.GrantIDs) == 0 {
return xerr.New(xerr.InvalidArgument, "reward notice facts are required")
}
return nil
}
func rewardItemsFromFacts(grants []*walletv1.ResourceGrant, coinAmount int64) ([]messagedomain.RewardItem, []string, error) {
items := make([]messagedomain.RewardItem, 0)
grantIDs := make([]string, 0, len(grants))
totalCoin := coinAmount
for _, grant := range grants {
if grant == nil {
continue
}
if grantID := strings.TrimSpace(grant.GetGrantId()); grantID != "" {
grantIDs = append(grantIDs, grantID)
}
for _, grantItem := range grant.GetItems() {
if grantItem == nil {
continue
}
snapshot := parseSnapshot(grantItem.GetResourceSnapshotJson())
if strings.EqualFold(grantItem.GetResultType(), "wallet_credit") {
amount := grantItem.GetQuantity()
// 普通 wallet-credit 资源的 quantity 是资源份数,实际金币为快照单份金额乘份数;
// 资源组原生 wallet_asset 项的 quantity 已经是最终入账额,不能再次相乘。
if grantItem.GetResourceId() > 0 {
perItem := snapshotInt64(snapshot, "WalletAssetAmount", "wallet_asset_amount")
if perItem > 0 {
if grantItem.GetQuantity() > math.MaxInt64/perItem {
return nil, nil, xerr.New(xerr.InvalidArgument, "reward coin amount overflows")
}
amount = perItem * grantItem.GetQuantity()
}
}
if amount > math.MaxInt64-totalCoin {
return nil, nil, xerr.New(xerr.InvalidArgument, "reward coin amount overflows")
}
totalCoin += amount
continue
}
items = append(items, messagedomain.RewardItem{
RewardType: rewardTypeResource,
Name: firstText(snapshotString(snapshot, "Name", "name"), "Reward"),
ImageURL: firstText(snapshotString(snapshot, "PreviewURL", "preview_url"), snapshotString(snapshot, "AssetURL", "asset_url"), snapshotString(snapshot, "AnimationURL", "animation_url")),
Quantity: grantItem.GetQuantity(),
DurationDays: durationDays(grantItem.GetDurationMs()),
})
}
}
if totalCoin > 0 {
items = append(items, messagedomain.RewardItem{RewardType: rewardTypeCoin, Name: "Coins", Quantity: totalCoin})
}
if len(items) == 0 {
return nil, nil, xerr.New(xerr.InvalidArgument, "reward notice has no displayable items")
}
return items, grantIDs, nil
}
func rewardImageURLs(items []messagedomain.RewardItem) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if item.RewardType == rewardTypeResource && strings.TrimSpace(item.ImageURL) != "" {
result = append(result, item.ImageURL)
}
}
return result
}
func durationDays(durationMS int64) int64 {
if durationMS <= 0 {
return 0
}
// 非整日历史配置向上取整,避免实际仍有效的奖励在消息里显示 0 天。
return (durationMS + millisecondsPerDay - 1) / millisecondsPerDay
}
func parseSnapshot(raw string) map[string]any {
var snapshot map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &snapshot); err != nil || snapshot == nil {
return map[string]any{}
}
return snapshot
}
func snapshotString(snapshot map[string]any, keys ...string) string {
for _, key := range keys {
if value, exists := snapshot[key]; exists {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func snapshotInt64(snapshot map[string]any, keys ...string) int64 {
for _, key := range keys {
switch value := snapshot[key].(type) {
case float64:
return int64(value)
case int64:
return value
case json.Number:
parsed, _ := value.Int64()
return parsed
}
}
return 0
}
func cloneMetadata(source map[string]any) map[string]any {
result := make(map[string]any, len(source)+3)
for key, value := range source {
result[key] = value
}
return result
}
func firstText(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}