341 lines
12 KiB
Go
341 lines
12 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)
|
||
ListFirstRechargeRewardClaimsByUser(ctx context.Context, userID int64) ([]domain.Claim, 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
|
||
}
|
||
active := []domain.Tier{}
|
||
if exists {
|
||
result.Enabled = config.Enabled
|
||
active = activeTiers(config.Tiers)
|
||
}
|
||
claims, err := s.repository.ListFirstRechargeRewardClaimsByUser(ctx, userID)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
result.Claims = claims
|
||
if len(claims) > 0 {
|
||
result.Claim = claims[0]
|
||
}
|
||
claimedTierIDs := grantedFirstRechargeTierIDs(claims)
|
||
result.Tiers = remainingFirstRechargeTiers(active, claimedTierIDs)
|
||
result.Rewarded = len(active) > 0 && len(result.Tiers) == 0
|
||
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 成功充值事实;只有 Google 商品 ID 命中启用档位才发奖。
|
||
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
|
||
}
|
||
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,
|
||
USDMinorAmount: tier.USDMinorAmount,
|
||
GoogleProductID: strings.TrimSpace(tier.GoogleProductID),
|
||
DiscountPercent: tier.DiscountPercent,
|
||
})
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
if items[i].SortOrder == items[j].SortOrder {
|
||
return items[i].USDMinorAmount < items[j].USDMinorAmount
|
||
}
|
||
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))
|
||
seenGoogleProducts := 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.USDMinorAmount <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "usd_minor_amount must be greater than zero")
|
||
}
|
||
googleProductID := strings.ToLower(strings.TrimSpace(tier.GoogleProductID))
|
||
if googleProductID == "" {
|
||
return xerr.New(xerr.InvalidArgument, "google_product_id is required")
|
||
}
|
||
if _, exists := seenGoogleProducts[googleProductID]; exists {
|
||
return xerr.New(xerr.InvalidArgument, "google_product_id is duplicated")
|
||
}
|
||
seenGoogleProducts[googleProductID] = struct{}{}
|
||
if tier.DiscountPercent < 0 || tier.DiscountPercent > 100 {
|
||
return xerr.New(xerr.InvalidArgument, "discount_percent must be between 0 and 100")
|
||
}
|
||
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")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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 grantedFirstRechargeTierIDs(claims []domain.Claim) map[int64]struct{} {
|
||
ids := make(map[int64]struct{}, len(claims))
|
||
for _, claim := range claims {
|
||
if claim.TierID > 0 && claim.Status == domain.StatusGranted {
|
||
ids[claim.TierID] = struct{}{}
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
func remainingFirstRechargeTiers(tiers []domain.Tier, granted map[int64]struct{}) []domain.Tier {
|
||
items := make([]domain.Tier, 0, len(tiers))
|
||
for _, tier := range tiers {
|
||
if _, ok := granted[tier.TierID]; ok {
|
||
continue
|
||
}
|
||
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)
|
||
event.GoogleProductID = strings.TrimSpace(event.GoogleProductID)
|
||
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.GoogleProductID == "" {
|
||
return xerr.New(xerr.InvalidArgument, "google_product_id is required")
|
||
}
|
||
// Google 首充档位按 google_product_id 命中,wallet 商品金币可为 0;
|
||
// 这让后台能保留 Google 验单壳 SKU,同时避免重复给普通金币。
|
||
if event.RechargeCoinAmount < 0 {
|
||
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must not be negative")
|
||
}
|
||
if event.OccurredAtMS <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
|
||
}
|
||
return nil
|
||
}
|