2026-05-25 15:45:57 +08:00

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
}