316 lines
10 KiB
Go
316 lines
10 KiB
Go
package firstrecharge
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/xerr"
|
|
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
|
)
|
|
|
|
const (
|
|
grantSource = "first_recharge_reward"
|
|
grantReason = "first_recharge_reward"
|
|
)
|
|
|
|
// Repository 是首冲奖励配置、claim 幂等和发放事实的唯一持久化边界。
|
|
type Repository interface {
|
|
GetFirstRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
|
UpdateFirstRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
|
GetFirstRechargeRewardClaimByUser(ctx context.Context, userID int64) (domain.Claim, bool, error)
|
|
PrepareFirstRechargeRewardClaim(ctx context.Context, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error)
|
|
MarkFirstRechargeRewardClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error)
|
|
MarkFirstRechargeRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
|
ListFirstRechargeRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
|
|
}
|
|
|
|
// WalletClient 是首冲奖励唯一外部副作用;资源组展开由 wallet-service 原子完成。
|
|
type WalletClient interface {
|
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
|
}
|
|
|
|
// Service 承载首冲奖励 App 查询、后台配置和充值事实消费用例。
|
|
type Service struct {
|
|
repository Repository
|
|
wallet WalletClient
|
|
now func() time.Time
|
|
}
|
|
|
|
func New(repository Repository, wallet WalletClient) *Service {
|
|
return &Service{repository: repository, wallet: wallet, now: time.Now}
|
|
}
|
|
|
|
func (s *Service) SetClock(now func() time.Time) {
|
|
if now != nil {
|
|
s.now = now
|
|
}
|
|
}
|
|
|
|
// GetStatus 返回 App 可展示的当前配置和用户发放状态;未配置时不是错误。
|
|
func (s *Service) GetStatus(ctx context.Context, userID 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")
|
|
}
|
|
nowMS := s.now().UnixMilli()
|
|
result := domain.StatusResult{ServerTimeMS: nowMS}
|
|
config, exists, err := s.repository.GetFirstRechargeRewardConfig(ctx)
|
|
if err != nil {
|
|
return domain.StatusResult{}, err
|
|
}
|
|
if exists {
|
|
result.Enabled = config.Enabled
|
|
result.Tiers = activeTiers(config.Tiers)
|
|
}
|
|
claim, claimed, err := s.repository.GetFirstRechargeRewardClaimByUser(ctx, userID)
|
|
if err != nil {
|
|
return domain.StatusResult{}, err
|
|
}
|
|
if claimed {
|
|
result.Claim = claim
|
|
result.Rewarded = claim.Status == domain.StatusGranted
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
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.GetFirstRechargeRewardConfig(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.UpdateFirstRechargeRewardConfig(ctx, config, s.now().UnixMilli())
|
|
}
|
|
|
|
// Consume 处理 wallet 成功充值事实;只有 recharge_sequence=1 且命中启用档位才发奖。
|
|
func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) (domain.Claim, bool, string, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return domain.Claim{}, false, "", err
|
|
}
|
|
event = normalizeRechargeEvent(event)
|
|
if err := validateRechargeEvent(event); err != nil {
|
|
return domain.Claim{}, false, "", err
|
|
}
|
|
if event.RechargeSequence != 1 {
|
|
return domain.Claim{}, false, domain.ReasonNotFirst, nil
|
|
}
|
|
prepared, err := s.repository.PrepareFirstRechargeRewardClaim(ctx, event, s.now().UnixMilli())
|
|
if err != nil {
|
|
return domain.Claim{}, false, "", err
|
|
}
|
|
if prepared.AlreadyGranted {
|
|
return prepared.Claim, true, prepared.Reason, nil
|
|
}
|
|
if !prepared.Prepared {
|
|
return prepared.Claim, false, prepared.Reason, nil
|
|
}
|
|
if s.wallet == nil {
|
|
return domain.Claim{}, false, "", xerr.New(xerr.Unavailable, "wallet client is not configured")
|
|
}
|
|
|
|
claim := prepared.Claim
|
|
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
|
CommandId: claim.WalletCommandID,
|
|
AppCode: appcode.FromContext(ctx),
|
|
TargetUserId: claim.UserID,
|
|
GroupId: claim.ResourceGroupID,
|
|
Reason: grantReason,
|
|
OperatorUserId: claim.UserID,
|
|
GrantSource: grantSource,
|
|
})
|
|
if err != nil {
|
|
_ = s.repository.MarkFirstRechargeRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
|
|
return domain.Claim{}, false, "", err
|
|
}
|
|
walletGrantID := ""
|
|
if resp.GetGrant() != nil {
|
|
walletGrantID = resp.GetGrant().GetGrantId()
|
|
}
|
|
claim, err = s.repository.MarkFirstRechargeRewardClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UnixMilli())
|
|
if err != nil {
|
|
return domain.Claim{}, false, "", err
|
|
}
|
|
return claim, true, domain.ReasonEligible, nil
|
|
}
|
|
|
|
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
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 s.repository.ListFirstRechargeRewardClaims(ctx, query)
|
|
}
|
|
|
|
// ConsumeWalletRechargeEvent handles one MQ-delivered wallet recharge fact.
|
|
func (s *Service) ConsumeWalletRechargeEvent(ctx context.Context, event domain.RechargeEvent) error {
|
|
if s == nil || s.repository == nil {
|
|
return xerr.New(xerr.Unavailable, "first recharge reward repository is not configured")
|
|
}
|
|
if event.EventType != domain.RechargeEventWalletRecorded {
|
|
return nil
|
|
}
|
|
_, _, _, err := s.Consume(appcode.WithContext(ctx, event.AppCode), event)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) requireRepository() error {
|
|
if s == nil || s.repository == nil {
|
|
return xerr.New(xerr.Unavailable, "first recharge 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 = domain.TierStatusActive
|
|
}
|
|
items = append(items, domain.Tier{
|
|
TierID: tier.TierID,
|
|
TierCode: strings.TrimSpace(tier.TierCode),
|
|
TierName: strings.TrimSpace(tier.TierName),
|
|
MinCoinAmount: tier.MinCoinAmount,
|
|
MaxCoinAmount: tier.MaxCoinAmount,
|
|
ResourceGroupID: tier.ResourceGroupID,
|
|
Status: status,
|
|
SortOrder: tier.SortOrder,
|
|
})
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].SortOrder == items[j].SortOrder {
|
|
return items[i].MinCoinAmount < items[j].MinCoinAmount
|
|
}
|
|
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")
|
|
}
|
|
seen := make(map[string]struct{}, len(config.Tiers))
|
|
active := make([]domain.Tier, 0, len(config.Tiers))
|
|
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.MinCoinAmount <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "min_coin_amount must be greater than zero")
|
|
}
|
|
if tier.MaxCoinAmount > 0 && tier.MaxCoinAmount < tier.MinCoinAmount {
|
|
return xerr.New(xerr.InvalidArgument, "max_coin_amount must be greater than min_coin_amount")
|
|
}
|
|
if tier.ResourceGroupID <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
|
|
}
|
|
switch tier.Status {
|
|
case domain.TierStatusActive:
|
|
active = append(active, tier)
|
|
case domain.TierStatusInactive:
|
|
default:
|
|
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
|
|
}
|
|
}
|
|
if config.Enabled && len(active) == 0 {
|
|
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
|
|
}
|
|
if hasOverlappedTiers(active) {
|
|
return xerr.New(xerr.InvalidArgument, "active tiers overlap")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasOverlappedTiers(tiers []domain.Tier) bool {
|
|
sort.SliceStable(tiers, func(i, j int) bool { return tiers[i].MinCoinAmount < tiers[j].MinCoinAmount })
|
|
var previousMax int64
|
|
for i, tier := range tiers {
|
|
if i > 0 && (previousMax == 0 || tier.MinCoinAmount <= previousMax) {
|
|
return true
|
|
}
|
|
previousMax = tier.MaxCoinAmount
|
|
}
|
|
return false
|
|
}
|
|
|
|
func activeTiers(tiers []domain.Tier) []domain.Tier {
|
|
items := make([]domain.Tier, 0, len(tiers))
|
|
for _, tier := range tiers {
|
|
if tier.Status == domain.TierStatusActive {
|
|
items = append(items, tier)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent {
|
|
event.AppCode = appcode.Normalize(event.AppCode)
|
|
event.EventID = strings.TrimSpace(event.EventID)
|
|
event.EventType = strings.TrimSpace(event.EventType)
|
|
event.TransactionID = strings.TrimSpace(event.TransactionID)
|
|
event.CommandID = strings.TrimSpace(event.CommandID)
|
|
event.RechargeType = strings.TrimSpace(event.RechargeType)
|
|
if event.RechargeType == "" {
|
|
event.RechargeType = domain.RechargeTypeCoinSeller
|
|
}
|
|
return event
|
|
}
|
|
|
|
func validateRechargeEvent(event domain.RechargeEvent) error {
|
|
if event.EventID == "" || event.TransactionID == "" || event.CommandID == "" || event.UserID <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "first recharge event is incomplete")
|
|
}
|
|
if event.EventType != "" && event.EventType != domain.RechargeEventWalletRecorded {
|
|
return xerr.New(xerr.InvalidArgument, "first recharge event type is invalid")
|
|
}
|
|
if event.RechargeCoinAmount <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must be greater than zero")
|
|
}
|
|
if event.OccurredAtMS <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
|
|
}
|
|
return nil
|
|
}
|