367 lines
12 KiB
Go
367 lines
12 KiB
Go
package firstrecharge
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"sort"
|
||
"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/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)
|
||
}
|
||
|
||
// WalletOutboxReader 扫描 wallet-service 的充值事实;activity 不修改 wallet_outbox 状态。
|
||
type WalletOutboxReader interface {
|
||
ListRechargeEventsAfter(ctx context.Context, cursor domain.WalletRechargeCursor, batchSize int) ([]domain.RechargeEvent, error)
|
||
}
|
||
|
||
// WorkerOptions 控制本地 wallet_outbox 轮询。
|
||
type WorkerOptions struct {
|
||
WorkerID string
|
||
PollInterval time.Duration
|
||
BatchSize int
|
||
}
|
||
|
||
// Service 承载首冲奖励 App 查询、后台配置和充值事实消费用例。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
reader WalletOutboxReader
|
||
now func() time.Time
|
||
}
|
||
|
||
func New(repository Repository, wallet WalletClient, reader WalletOutboxReader) *Service {
|
||
return &Service{repository: repository, wallet: wallet, reader: reader, 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)
|
||
}
|
||
|
||
// RunWalletRechargeWorker 轮询 wallet_outbox 的充值事实;游标只在内存保存,claim 表唯一键保证重复扫描安全。
|
||
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
|
||
if s == nil || s.repository == nil || s.reader == nil {
|
||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "first_recharge_reward"), slog.String("reason", "missing_dependency"))
|
||
return
|
||
}
|
||
if options.PollInterval <= 0 {
|
||
options.PollInterval = 5 * time.Second
|
||
}
|
||
if options.BatchSize <= 0 {
|
||
options.BatchSize = 100
|
||
}
|
||
var cursor domain.WalletRechargeCursor
|
||
for {
|
||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||
if err != nil {
|
||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "first_recharge_reward"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||
}
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
if processed >= options.BatchSize {
|
||
continue
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-time.After(options.PollInterval):
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) processBatch(ctx context.Context, cursor *domain.WalletRechargeCursor, batchSize int) (int, error) {
|
||
events, err := s.reader.ListRechargeEventsAfter(ctx, *cursor, batchSize)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
for _, event := range events {
|
||
eventCtx := appcode.WithContext(ctx, event.AppCode)
|
||
if _, _, _, err := s.Consume(eventCtx, event); err != nil {
|
||
return 0, err
|
||
}
|
||
cursor.CreatedAtMS = event.OccurredAtMS
|
||
cursor.EventID = event.EventID
|
||
}
|
||
return len(events), nil
|
||
}
|
||
|
||
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
|
||
}
|