2026-06-15 23:53:30 +08:00

521 lines
21 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 roomturnoverreward
import (
"context"
"sort"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
)
const rewardReason = "room_turnover_reward"
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
type Repository interface {
GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error)
UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error)
GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error)
GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error)
ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error)
ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error)
InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error)
ListPendingRoomTurnoverRewardSettlements(ctx context.Context, limit int) ([]domain.Settlement, error)
GetRoomTurnoverRewardSettlement(ctx context.Context, settlementID string) (domain.Settlement, bool, error)
ResetRoomTurnoverRewardSettlementForRetry(ctx context.Context, settlementID string, ownerUserID int64, nowMS int64) (domain.Settlement, error)
MarkRoomTurnoverRewardSettlementGranted(ctx context.Context, settlementID string, walletTransactionID string, grantedAtMS int64) (domain.Settlement, error)
MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
}
// WalletClient 是房间流水奖励唯一外部发币副作用。
type WalletClient interface {
CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
}
// RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。
type RoomQueryClient interface {
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
}
// Service 承载房间流水奖励 App 查询、后台配置、事件聚合和每周结算。
type Service struct {
repository Repository
wallet WalletClient
room RoomQueryClient
now func() time.Time
}
func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service {
return &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
if err != nil {
return domain.Config{}, err
}
if !exists {
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil
}
return config, nil
}
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
config.AppCode = appcode.FromContext(ctx)
config.Tiers = normalizeTiers(config.Tiers)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
}
return s.repository.UpdateRoomTurnoverRewardConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) {
if err := s.requireRepository(); err != nil {
return domain.StatusResult{}, err
}
if userID <= 0 {
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
// App 展示和每周结算必须使用同一套 UTC 周窗口;这里用 service clock 统一可测试时间源,避免 H5、本地测试和 cron 看到不同周期。
now := s.now().UTC()
start, end := WeekWindow(now)
result := domain.StatusResult{
RoomID: strings.TrimSpace(roomID),
OwnerUserID: ownerUserID,
PeriodStartMS: start.UnixMilli(),
PeriodEndMS: end.UnixMilli(),
ServerTimeMS: now.UnixMilli(),
}
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
if err != nil {
return domain.StatusResult{}, err
}
if exists {
config.Tiers = sortTiersForDisplay(config.Tiers)
result.Config = config
} else {
// 未配置不是异常后台首次进入或活动未上线时H5 仍需要周期时间和空档位来渲染默认态。
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
}
if result.RoomID == "" {
// 当前用户没有自己的房间时不能查询流水聚合;直接返回配置和周期,让 H5 显示无房间/未参与状态。
return result, nil
}
progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, result.RoomID, result.PeriodStartMS)
if err != nil {
return domain.StatusResult{}, err
}
if found {
result.CurrentCoinSpent = progress.CoinSpent
}
if tier, matched := MatchHighestTier(activeTiers(result.Config.Tiers), result.CurrentCoinSpent); matched {
result.MatchedTier = tier
result.ExpectedRewardCoinAmount = tier.RewardCoinAmount
}
if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, result.RoomID); err != nil {
return domain.StatusResult{}, err
} else if found {
// 最近结算不限定当前周期;周切换后当前流水会清零,但 H5 仍要展示上一周期是否已经发奖或失败。
result.LatestSettlement = settlement
}
return result, nil
}
// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应 UTC 周期房间流水。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (domain.EventResult, error) {
if err := s.requireRepository(); err != nil {
return domain.EventResult{}, err
}
if envelope == nil {
return domain.EventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
}
if envelope.GetEventType() != "RoomGiftSent" {
// activity-service 订阅的是 room outbox 大类事件;非送礼事实不参与流水奖励,直接标记 skipped避免无意义重试。
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
}
var gift roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return domain.EventResult{}, err
}
if gift.GetIsRobotGift() {
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
}
if strings.TrimSpace(envelope.GetRoomId()) == "" || gift.GetCoinSpent() <= 0 {
// 缺房间或 0 金币不会形成有效房间贡献;这类事件本身没有可补偿数据,跳过比失败重放更安全。
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
}
occurred := time.UnixMilli(envelope.GetOccurredAtMs()).UTC()
if envelope.GetOccurredAtMs() <= 0 {
// 老事件缺 occurred_at 时使用消费时间兜底,只影响本地兼容,正常 outbox 事件仍以事实发生时间归属周期。
occurred = s.now().UTC()
}
start, end := WeekWindow(occurred)
// AppCode 以事件信封为准,防止消费者默认上下文把不同 App 的房间流水写进同一组配置和聚合表。
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
return s.repository.ConsumeRoomTurnoverGiftEvent(eventCtx, domain.RoomGiftEvent{
EventID: envelope.GetEventId(),
EventType: envelope.GetEventType(),
AppCode: envelope.GetAppCode(),
RoomID: strings.TrimSpace(envelope.GetRoomId()),
CoinSpent: gift.GetCoinSpent(),
OccurredAtMS: occurred.UnixMilli(),
}, start.UnixMilli(), end.UnixMilli(), s.now().UTC().UnixMilli())
}
func (s *Service) ListSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = strings.TrimSpace(query.Status)
query.RoomID = strings.TrimSpace(query.RoomID)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repository.ListRoomTurnoverRewardSettlements(ctx, query)
}
// ProcessSettlementBatch 先为上一 UTC 周期补齐 settlement再处理 pending 发币。
func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (domain.BatchResult, error) {
if err := s.requireRepository(); err != nil {
return domain.BatchResult{}, err
}
if batchSize <= 0 {
batchSize = 100
}
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
if err != nil {
return domain.BatchResult{}, err
}
if !exists || !config.Enabled {
// 活动未配置或关闭时不创建 settlement也不触碰历史 pending运营重新开启后再由 cron 正常补偿。
return domain.BatchResult{}, nil
}
// 只结算上一个完整 UTC 周期;当前周仍在持续接收 RoomGiftSent不能提前冻结或清空。
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
result := domain.BatchResult{}
progresses, err := s.repository.ListUnsettledRoomTurnoverRewardProgress(ctx, periodStart.UnixMilli(), batchSize)
if err != nil {
return domain.BatchResult{}, err
}
nowMS := s.now().UTC().UnixMilli()
for _, progress := range progresses {
// settlement 先固化当周流水和命中档位快照,后续运营改配置不会改变已经进入结算队列的周期结果。
settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS)
if settlement.TierID <= 0 {
// 有流水但未达档位也要落失败记录,后台可以解释为什么该房间本周期没有发奖。
settlement.Status = domain.SettlementStatusFailed
settlement.FailureReason = "tier_not_matched"
}
if settlement.TierID > 0 && settlement.OwnerUserID <= 0 {
// 房主归属以结算时 room-service 快照为准activity-service 不复制房间 owner 状态,只在需要发奖时同步读取。
ownerID, ownerErr := s.fetchRoomOwner(ctx, progress.RoomID)
if ownerErr != nil || ownerID <= 0 {
settlement.Status = domain.SettlementStatusFailed
settlement.FailureReason = "room_owner_not_found"
} else {
settlement.OwnerUserID = ownerID
}
}
if _, created, err := s.repository.InsertRoomTurnoverRewardSettlement(ctx, settlement, nowMS); err != nil {
return result, err
} else if created {
// Insert 使用 room_id + period_start_ms 唯一键兜底;重复 cron 只会读回旧记录,不会重复认领同一周期。
result.ClaimedCount++
}
}
remaining := batchSize - int(result.ClaimedCount)
if remaining <= 0 {
remaining = batchSize
}
pending, err := s.repository.ListPendingRoomTurnoverRewardSettlements(ctx, remaining)
if err != nil {
return result, err
}
for _, settlement := range pending {
result.ProcessedCount++
if _, err := s.grantSettlement(ctx, settlement); err != nil {
// grantSettlement 已把失败原因写回 settlementbatch 继续处理后续房间,避免单房间阻塞整周结算。
result.FailureCount++
continue
}
result.SuccessCount++
}
result.HasMore = len(progresses) >= batchSize || len(pending) >= remaining
return result, nil
}
func (s *Service) RetrySettlement(ctx context.Context, settlementID string) (domain.Settlement, error) {
if err := s.requireRepository(); err != nil {
return domain.Settlement{}, err
}
settlementID = strings.TrimSpace(settlementID)
if settlementID == "" {
return domain.Settlement{}, xerr.New(xerr.InvalidArgument, "settlement_id is required")
}
settlement, exists, err := s.repository.GetRoomTurnoverRewardSettlement(ctx, settlementID)
if err != nil {
return domain.Settlement{}, err
}
if !exists {
return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
}
if settlement.Status == domain.SettlementStatusGranted {
// 已发奖记录直接返回,后台重复点击 retry 不会再次调用钱包。
return settlement, nil
}
if settlement.TierID <= 0 {
// tier_not_matched 不是外部依赖失败而是该房间当周没有达到任何档位retry 不能把这类 0 档位记录重置成 granted。
return settlement, xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
}
ownerID := settlement.OwnerUserID
if ownerID <= 0 {
// 失败记录可能是当时没读到房主retry 时重新取 room-service 快照,允许房间修复后继续发奖。
ownerID, err = s.fetchRoomOwner(ctx, settlement.RoomID)
if err != nil || ownerID <= 0 {
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, "room_owner_not_found", s.now().UTC().UnixMilli())
return domain.Settlement{}, xerr.New(xerr.NotFound, "room owner not found")
}
}
settlement, err = s.repository.ResetRoomTurnoverRewardSettlementForRetry(ctx, settlement.SettlementID, ownerID, s.now().UTC().UnixMilli())
if err != nil {
return domain.Settlement{}, err
}
return s.grantSettlement(ctx, settlement)
}
func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Progress, periodEndMS int64, tiers []domain.Tier, nowMS int64) domain.Settlement {
tier, matched := MatchHighestTier(tiers, progress.CoinSpent)
settlement := domain.Settlement{
AppCode: appcode.FromContext(ctx),
RoomID: progress.RoomID,
PeriodStartMS: progress.PeriodStartMS,
PeriodEndMS: periodEndMS,
CoinSpent: progress.CoinSpent,
Status: domain.SettlementStatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
if matched {
// 结算记录保存档位快照;后续配置变更不反推历史奖励金额。
settlement.TierID = tier.TierID
settlement.TierCode = tier.TierCode
settlement.ThresholdCoinSpent = tier.ThresholdCoinSpent
settlement.RewardCoinAmount = tier.RewardCoinAmount
}
return settlement
}
func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) {
if settlement.Status == domain.SettlementStatusGranted {
// 发奖入口保持幂等,避免 cron 和人工 retry 同时进来时重复入账。
return settlement, nil
}
if settlement.OwnerUserID <= 0 || settlement.RewardCoinAmount < 0 || settlement.WalletCommandID == "" {
err := xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
if settlement.RewardCoinAmount == 0 {
// 0 金币档位是合法配置,用于运营只记录达标但不发币的周期;钱包不接受 0 金额命令,所以这里直接收敛结算状态。
nowMS := s.now().UTC().UnixMilli()
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
}
if s.wallet == nil {
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
// 钱包 command_id 使用 app + 周期 + room_id保证同一房间同一周期无论 cron 重跑还是 retry都只产生一笔账务交易。
resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{
CommandId: settlement.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: settlement.OwnerUserID,
Amount: settlement.RewardCoinAmount,
SettlementId: settlement.SettlementID,
RoomId: settlement.RoomID,
PeriodStartMs: settlement.PeriodStartMS,
PeriodEndMs: settlement.PeriodEndMS,
CoinSpent: settlement.CoinSpent,
TierId: settlement.TierID,
TierCode: settlement.TierCode,
Reason: rewardReason,
})
if err != nil {
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Settlement{}, err
}
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
if s.room == nil {
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
}
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
}
resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: &roomv1.RequestMeta{
// 这里不是用户请求链路,使用可读的内部 request_id 方便按房间追踪结算时的 owner 查询。
RequestId: "room-turnover-reward-owner:" + roomID,
AppCode: appcode.FromContext(ctx),
SentAtMs: s.now().UTC().UnixMilli(),
},
RoomId: roomID,
})
if err != nil {
return 0, err
}
return resp.GetRoom().GetOwnerUserId(), nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "room turnover reward repository is not configured")
}
return nil
}
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
items := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
status := strings.ToLower(strings.TrimSpace(tier.Status))
if status == "" {
// 后台表单可以省略 status默认 active 保持配置体验简单,但 validate 仍会拦截非法状态。
status = domain.TierStatusActive
}
items = append(items, domain.Tier{
TierID: tier.TierID,
TierCode: strings.TrimSpace(tier.TierCode),
TierName: strings.TrimSpace(tier.TierName),
ThresholdCoinSpent: tier.ThresholdCoinSpent,
RewardCoinAmount: tier.RewardCoinAmount,
Status: status,
SortOrder: tier.SortOrder,
})
}
return sortTiersForDisplay(items)
}
func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
items := append([]domain.Tier(nil), tiers...)
sort.SliceStable(items, func(i, j int) bool {
if items[i].SortOrder == items[j].SortOrder {
return items[i].ThresholdCoinSpent < items[j].ThresholdCoinSpent
}
return items[i].SortOrder < items[j].SortOrder
})
return items
}
func validateConfig(config domain.Config) error {
if config.UpdatedByAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
// tier_code 是后台配置稳定标识,阈值和奖励可以修改,但同一 App 内不能出现两个相同编码。
seen := make(map[string]struct{}, len(config.Tiers))
activeCount := 0
for _, tier := range config.Tiers {
if tier.TierCode == "" {
return xerr.New(xerr.InvalidArgument, "tier_code is required")
}
if _, exists := seen[tier.TierCode]; exists {
return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
}
seen[tier.TierCode] = struct{}{}
if tier.TierName == "" {
return xerr.New(xerr.InvalidArgument, "tier_name is required")
}
if tier.ThresholdCoinSpent <= 0 {
return xerr.New(xerr.InvalidArgument, "threshold_coin_spent must be greater than zero")
}
if tier.RewardCoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "reward_coin_amount must not be negative")
}
switch tier.Status {
case domain.TierStatusActive:
activeCount++
case domain.TierStatusInactive:
default:
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
}
}
if config.Enabled && activeCount == 0 {
// 开启活动但没有有效档位会让 H5 显示可参与却永远无法发奖,直接在配置入口拒绝。
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
}
return nil
}
func activeTiers(tiers []domain.Tier) []domain.Tier {
out := make([]domain.Tier, 0, len(tiers))
for _, tier := range tiers {
if tier.Status == domain.TierStatusActive {
out = append(out, tier)
}
}
return out
}
// MatchHighestTier 返回达到流水阈值的最高有效档。
func MatchHighestTier(tiers []domain.Tier, coinSpent int64) (domain.Tier, bool) {
var matched domain.Tier
found := false
for _, tier := range tiers {
if tier.Status != domain.TierStatusActive || tier.ThresholdCoinSpent <= 0 || tier.RewardCoinAmount < 0 {
continue
}
if coinSpent >= tier.ThresholdCoinSpent && (!found || tier.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
matched = tier
found = true
}
}
return matched, found
}
// WeekWindow 返回给定时间所在 UTC 周一 00:00 到下一周一 00:00 的左闭右开窗口。
func WeekWindow(now time.Time) (time.Time, time.Time) {
utc := now.UTC()
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
start := dayStart.AddDate(0, 0, 1-weekday)
return start, start.AddDate(0, 0, 7)
}
// PreviousWeekWindow 返回当前时间之前的完整 UTC 周期。
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
start, _ := WeekWindow(now)
previousStart := start.AddDate(0, 0, -7)
return previousStart, start
}