315 lines
11 KiB
Go
315 lines
11 KiB
Go
// 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 ""
|
||
}
|