324 lines
11 KiB
Go
324 lines
11 KiB
Go
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
|
||
}
|