243 lines
9.4 KiB
Go
243 lines
9.4 KiB
Go
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")
|
||
}
|