177 lines
6.2 KiB
Go
177 lines
6.2 KiB
Go
package wheel
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"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)
|
||
}
|
||
|
||
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.creditCoinRewardFastPath(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) creditCoinRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||
if result.RewardType != domain.RewardTypeCoin || result.RewardCoins <= 0 || result.RewardStatus == domain.StatusGranted || len(result.DrawIDs) != 1 {
|
||
return result
|
||
}
|
||
if s.wallet == nil {
|
||
logx.Warn(ctx, "wheel_reward_wallet_not_configured", slog.String("draw_id", result.DrawID))
|
||
return result
|
||
}
|
||
resp, err := s.wallet.CreditWheelReward(appcode.WithContext(ctx, appcode.FromContext(ctx)), &walletv1.CreditWheelRewardRequest{
|
||
CommandId: "wheel_reward:" + result.DrawID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: cmd.UserID,
|
||
Amount: result.RewardCoins,
|
||
DrawId: result.DrawID,
|
||
WheelId: result.WheelID,
|
||
SelectedTierId: result.SelectedTierID,
|
||
Reason: "wheel_reward",
|
||
VisibleRegionId: cmd.VisibleRegionID,
|
||
})
|
||
if err != nil {
|
||
// 钱包不可用不能回滚已经确认的抽奖事实;pending 记录和 outbox 保留,后续补偿 worker 可以继续处理。
|
||
logx.Error(ctx, "wheel_reward_fast_path_failed", err, slog.String("draw_id", result.DrawID), slog.String("command_id", result.CommandID))
|
||
return result
|
||
}
|
||
result.RewardStatus = domain.StatusGranted
|
||
result.WalletTransactionID = resp.GetTransactionId()
|
||
result.CoinBalanceAfter = resp.GetBalance().GetAvailableAmount()
|
||
if err := s.repository.MarkWheelDrawsGranted(appcode.WithContext(context.WithoutCancel(ctx), appcode.FromContext(ctx)), appcode.FromContext(ctx), result.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 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
|
||
}
|