2026-06-06 13:50:46 +08:00

243 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package registrationreward
import (
"context"
"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/registrationreward"
)
const (
registrationRewardTaskID = "registration_reward"
registrationRewardReason = "registration_reward"
)
// Repository 是注册奖励的持久化边界;配置、领取事实和每日份数必须同属 activity-service。
type Repository interface {
GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error)
UpdateRegistrationRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
GetRegistrationRewardEligibility(ctx context.Context, userID int64, rewardDay string, nowMS int64) (domain.Eligibility, error)
PrepareRegistrationRewardClaim(ctx context.Context, command domain.IssueCommand, rewardDay string, nowMS int64) (domain.PrepareResult, error)
MarkRegistrationRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, resourceGrantID string, grantedAtMS int64) (domain.Claim, error)
MarkRegistrationRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
CountRegistrationRewardGrantedByDay(ctx context.Context, rewardDay string) (int64, error)
}
// WalletClient 是注册奖励发放的唯一外部副作用;金币和资源组都依赖 wallet-service 幂等命令。
type WalletClient interface {
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service 承载注册奖励配置、资格判断和注册后自动发放用例。
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
}
}
// GetConfig 返回当前配置;未配置时返回 disabled 默认值,避免后台首屏把 404 当异常。
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.GetRegistrationRewardConfig(ctx)
if err != nil {
return domain.Config{}, err
}
if !exists {
return domain.Config{AppCode: appcode.FromContext(ctx), RewardType: domain.RewardTypeCoin}, nil
}
return config, nil
}
// UpdateConfig 更新后台配置;关闭状态也校验奖励参数,避免下次打开时发放一个无效配置。
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.RewardType = normalizeRewardType(config.RewardType)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
}
return s.repository.UpdateRegistrationRewardConfig(ctx, config, s.now().UnixMilli())
}
// GetEligibility 判断“没领过且今天还有份数”;日维度严格使用 UTC不能读取服务器本地时区。
func (s *Service) GetEligibility(ctx context.Context, userID int64) (domain.Eligibility, error) {
if err := s.requireRepository(); err != nil {
return domain.Eligibility{}, err
}
if userID <= 0 {
return domain.Eligibility{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
now := s.now()
return s.repository.GetRegistrationRewardEligibility(ctx, userID, rewardDay(now), now.UnixMilli())
}
// IssueRegistrationReward 在用户注册事务提交后调用;不可领取不是错误,钱包失败才返回错误以便调用方打日志。
func (s *Service) IssueRegistrationReward(ctx context.Context, command domain.IssueCommand) (domain.Claim, domain.Eligibility, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
command.CommandID = strings.TrimSpace(command.CommandID)
if command.UserID <= 0 || command.CommandID == "" {
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.InvalidArgument, "registration reward command is incomplete")
}
now := s.now()
day := rewardDay(now)
prepared, err := s.repository.PrepareRegistrationRewardClaim(ctx, command, day, now.UnixMilli())
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
if !prepared.Prepared {
eligibility, eligibilityErr := s.repository.GetRegistrationRewardEligibility(ctx, command.UserID, day, s.now().UnixMilli())
if eligibilityErr != nil {
return domain.Claim{}, domain.Eligibility{}, false, eligibilityErr
}
if prepared.AlreadyGranted {
return prepared.Claim, eligibility, true, nil
}
return prepared.Claim, eligibility, false, nil
}
if s.wallet == nil {
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
claim := prepared.Claim
var walletTransactionID string
var resourceGrantID string
grantedAtMS := s.now().UnixMilli()
switch claim.RewardType {
case domain.RewardTypeCoin:
resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
Amount: claim.CoinAmount,
TaskType: "exclusive",
TaskId: registrationRewardTaskID,
CycleKey: "lifetime",
Reason: registrationRewardReason,
})
if err != nil {
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, err
}
walletTransactionID = resp.GetTransactionId()
if resp.GetGrantedAtMs() > 0 {
grantedAtMS = resp.GetGrantedAtMs()
}
case domain.RewardTypeResourceGroup:
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
GroupId: claim.ResourceGroupID,
Reason: registrationRewardReason,
OperatorUserId: claim.UserID,
GrantSource: registrationRewardReason,
})
if err != nil {
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, err
}
if resp.GetGrant() != nil {
resourceGrantID = resp.GetGrant().GetGrantId()
}
default:
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, domain.ReasonInvalidConfig, s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
claim, err = s.repository.MarkRegistrationRewardClaimGranted(ctx, claim.ClaimID, walletTransactionID, resourceGrantID, grantedAtMS)
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
eligibility, err := s.repository.GetRegistrationRewardEligibility(ctx, command.UserID, day, s.now().UnixMilli())
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
return claim, eligibility, true, nil
}
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, 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
}
claims, total, err := s.repository.ListRegistrationRewardClaims(ctx, query)
if err != nil {
return nil, 0, 0, err
}
// 今日已领数量用于后台配置页展示,它必须使用同一套 UTC reward_day 口径,避免后台本地时区和发放额度统计出现错位。
todayClaimedCount, err := s.repository.CountRegistrationRewardGrantedByDay(ctx, rewardDay(s.now()))
if err != nil {
return nil, 0, 0, err
}
return claims, total, todayClaimedCount, nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "registration reward repository is not configured")
}
return nil
}
func normalizeRewardType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validateConfig(config domain.Config) error {
if config.UpdatedByAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
if config.DailyLimit < 0 {
return xerr.New(xerr.InvalidArgument, "daily_limit cannot be negative")
}
switch config.RewardType {
case domain.RewardTypeCoin:
if config.CoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "coin_amount must be greater than zero")
}
case domain.RewardTypeResourceGroup:
if config.ResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
}
default:
return xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
return nil
}
func rewardDay(t time.Time) string {
utc := t.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Format("2006-01-02")
}