2026-06-18 22:41:52 +08:00

324 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 wheel
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
type Repository interface {
PublishWheelRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
GetWheelRuleConfig(ctx context.Context, wheelID string) (domain.RuleConfig, bool, error)
ExecuteWheelDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetWheelDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
MarkWheelDrawsGranted(ctx context.Context, appCode string, drawIDs []string, transactionID string, nowMS int64) error
}
type WalletClient interface {
CreditWheelReward(ctx context.Context, req *walletv1.CreditWheelRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditWheelRewardResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
type Service struct {
repository Repository
wallet WalletClient
now func() time.Time
}
type Option func(*Service)
func WithWallet(client WalletClient) Option {
return func(s *Service) {
s.wallet = client
}
}
func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.DrawResult, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.WheelID = NormalizeWheelID(cmd.WheelID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
if cmd.DrawCount <= 0 {
cmd.DrawCount = 1
}
if cmd.CommandID == "" || cmd.WheelID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.CoinSpent <= 0 {
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw command is incomplete")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 来自钱包扣费事实;缺失时使用 UTC 服务端时间,保证 RTP 窗口和审计时间不受本地时区影响。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
result, err := s.repository.ExecuteWheelDraw(ctx, cmd, s.now().UTC().UnixMilli())
if err != nil {
return domain.DrawResult{}, err
}
return s.fulfillWheelRewardFastPath(ctx, cmd, result), nil
}
func (s *Service) UpsertConfig(ctx context.Context, config domain.RuleConfig) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
config = NormalizeRuleConfig(config)
if err := ValidateRuleConfig(config); err != nil {
return domain.RuleConfig{}, err
}
return s.repository.PublishWheelRuleConfig(ctx, config, s.now().UTC().UnixMilli())
}
func (s *Service) GetConfig(ctx context.Context, wheelID string) (domain.RuleConfig, error) {
if err := s.requireRepository(); err != nil {
return domain.RuleConfig{}, err
}
config, exists, err := s.repository.GetWheelRuleConfig(ctx, NormalizeWheelID(wheelID))
if err != nil {
return domain.RuleConfig{}, err
}
if !exists {
return domain.RuleConfig{}, xerr.New(xerr.NotFound, "wheel config not found")
}
return config, nil
}
func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
return s.repository.ListWheelDraws(ctx, normalizeDrawQuery(query))
}
func (s *Service) GetDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
if err := s.requireRepository(); err != nil {
return domain.DrawSummary{}, err
}
return s.repository.GetWheelDrawSummary(ctx, normalizeDrawQuery(query))
}
func (s *Service) fulfillWheelRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
if result.RewardStatus == domain.StatusGranted {
return result
}
if s.wallet == nil {
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
return result
}
rewards := wheelResultRewards(result)
if len(rewards) == 0 {
return result
}
receipts := make([]string, 0, len(rewards))
for index, reward := range rewards {
receipt, coinBalance, err := s.fulfillWheelRewardItem(ctx, cmd, result, reward, index, len(rewards))
if err != nil {
// 抽奖结果已经在事务内确认,发放失败时不能回滚抽奖事实;保留 pending/outbox补偿 worker 或重试可以用同一 command_id 幂等补发。
logx.Error(ctx, "wheel_reward_fast_path_failed", err,
slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID),
slog.String("reward_type", reward.RewardType),
slog.String("selected_tier_id", reward.SelectedTierID),
)
return result
}
if receipt != "" {
receipts = append(receipts, receipt)
}
if coinBalance > 0 {
result.CoinBalanceAfter = coinBalance
}
}
result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = strings.Join(receipts, ",")
drawIDs := []string{result.DrawID}
if result.DrawID == "" {
drawIDs = append([]string(nil), result.DrawIDs...)
}
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), drawIDs, result.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
logx.Error(ctx, "wheel_reward_fast_path_mark_granted_failed", err, slog.String("draw_id", result.DrawID), slog.String("wallet_transaction_id", result.WalletTransactionID))
}
return result
}
func (s *Service) fulfillWheelRewardItem(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
reward.RewardType = strings.TrimSpace(strings.ToLower(reward.RewardType))
switch reward.RewardType {
case domain.RewardTypeCoin:
return s.creditWheelCoinReward(ctx, cmd, result, reward, index, total)
case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress:
return s.grantWheelResourceReward(ctx, cmd, result, reward, index, total)
default:
return "", 0, fmt.Errorf("unsupported wheel reward type %q", reward.RewardType)
}
}
func (s *Service) creditWheelCoinReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
if reward.RewardCoins <= 0 {
return "", 0, fmt.Errorf("wheel coin reward amount must be positive")
}
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
CommandId: wheelRewardCommandID("wheel_reward", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
Amount: reward.RewardCoins,
DrawId: result.DrawID,
WheelId: result.WheelID,
SelectedTierId: reward.SelectedTierID,
Reason: "wheel_reward",
VisibleRegionId: cmd.VisibleRegionID,
})
if err != nil {
return "", 0, err
}
return resp.GetTransactionId(), resp.GetBalance().GetAvailableAmount(), nil
}
func (s *Service) grantWheelResourceReward(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult, reward domain.DrawReward, index, total int) (string, int64, error) {
resourceID := wheelRewardResourceID(reward)
if resourceID <= 0 {
// gift/prop/dress 的真实发放 owner 是 wallet 资源权益;缺 resource_id 时不能用展示 ID 猜测,必须让记录保持 pending 以便后台修配置后补偿。
return "", 0, fmt.Errorf("wheel resource reward missing resource_id")
}
quantity := reward.RewardCount
if quantity <= 0 {
quantity = 1
}
resp, err := s.wallet.GrantResource(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.GrantResourceRequest{
CommandId: wheelRewardCommandID("wheel_resource", result.DrawID, index, total),
AppCode: appcode.FromContext(ctx),
TargetUserId: cmd.UserID,
ResourceId: resourceID,
Quantity: quantity,
DurationMs: wheelRewardDurationMS(reward),
Reason: "wheel_reward",
OperatorUserId: cmd.UserID,
GrantSource: "wheel_reward",
})
if err != nil {
return "", 0, err
}
return resp.GetGrant().GetGrantId(), 0, nil
}
func wheelResultRewards(result domain.DrawResult) []domain.DrawReward {
if len(result.Rewards) > 0 {
return result.Rewards
}
if result.RewardType == "" {
return nil
}
return []domain.DrawReward{{
SelectedTierID: result.SelectedTierID,
RewardType: result.RewardType,
RewardID: result.RewardID,
RewardCount: result.RewardCount,
RewardCoins: result.RewardCoins,
RTPValueCoins: result.RTPValueCoins,
RewardStatus: result.RewardStatus,
MetadataJSON: result.MetadataJSON,
}}
}
func wheelRewardCommandID(prefix, drawID string, index, total int) string {
if total <= 1 {
return prefix + ":" + drawID
}
return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1)
}
func wheelRewardResourceID(reward domain.DrawReward) int64 {
if value := wheelRewardMetadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 {
return value
}
value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64)
return value
}
func wheelRewardDurationMS(reward domain.DrawReward) int64 {
return wheelRewardMetadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs")
}
func wheelRewardMetadataInt64(raw string, keys ...string) int64 {
metadata := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
return 0
}
for _, key := range keys {
if value, ok := metadata[key]; ok {
if parsed := anyToInt64(value); parsed > 0 {
return parsed
}
}
}
return 0
}
func anyToInt64(value any) int64 {
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
return 0
}
}
func normalizeDrawQuery(query domain.DrawQuery) domain.DrawQuery {
query.WheelID = NormalizeWheelID(query.WheelID)
query.Status = strings.TrimSpace(query.Status)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return query
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "wheel repository is not configured")
}
return nil
}