357 lines
13 KiB
Go
357 lines
13 KiB
Go
package cumulativerecharge
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"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/cumulativerecharge"
|
||
)
|
||
|
||
const (
|
||
grantSource = "cumulative_recharge_reward"
|
||
grantReason = "cumulative_recharge_reward"
|
||
)
|
||
|
||
// Repository 是累充奖励配置、周期累计、event 幂等和发放事实的唯一持久化边界。
|
||
type Repository interface {
|
||
GetCumulativeRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
||
UpdateCumulativeRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||
GetCumulativeRechargeRewardProgress(ctx context.Context, cycleKey string, userID int64) (domain.Progress, bool, error)
|
||
ListCumulativeRechargeRewardGrantsByUserCycle(ctx context.Context, cycleKey string, userID int64) ([]domain.Grant, error)
|
||
PrepareCumulativeRechargeRewardGrants(ctx context.Context, event domain.RechargeEvent, cycle domain.Cycle, nowMS int64) (domain.PrepareResult, error)
|
||
MarkCumulativeRechargeRewardGrantGranted(ctx context.Context, grantID string, walletGrantID string, grantedAtMS int64) (domain.Grant, error)
|
||
MarkCumulativeRechargeRewardGrantFailed(ctx context.Context, grantID string, failureReason string, nowMS int64) error
|
||
ListCumulativeRechargeRewardGrants(ctx context.Context, query domain.GrantQuery) ([]domain.Grant, int64, error)
|
||
}
|
||
|
||
// WalletClient 是累充奖励唯一外部副作用;资源组展开仍由 wallet-service 原子完成。
|
||
type WalletClient interface {
|
||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
}
|
||
|
||
// Service 承载累充奖励 App 查询、后台配置和 wallet 充值事实消费用例。
|
||
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 返回当前 UTC 自然周内的用户累计、档位和发放状态;未配置时返回空配置而不是错误。
|
||
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")
|
||
}
|
||
now := s.now().UTC()
|
||
cycle := CycleForTime(now)
|
||
result := domain.StatusResult{Cycle: cycle, ServerTimeMS: now.UnixMilli()}
|
||
|
||
// H5 只展示当前生效配置里的 active 档位;inactive 档位保留给后台编辑和历史 grant 审计。
|
||
config, exists, err := s.repository.GetCumulativeRechargeRewardConfig(ctx)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if exists {
|
||
config.Tiers = activeTiers(config.Tiers)
|
||
result.Config = config
|
||
} else {
|
||
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
|
||
}
|
||
progress, _, err := s.repository.GetCumulativeRechargeRewardProgress(ctx, cycle.Key, userID)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if progress.UserID == 0 {
|
||
progress = domain.Progress{AppCode: appcode.FromContext(ctx), CycleKey: cycle.Key, UserID: userID}
|
||
}
|
||
result.Progress = progress
|
||
grants, err := s.repository.ListCumulativeRechargeRewardGrantsByUserCycle(ctx, cycle.Key, userID)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
result.Grants = grants
|
||
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.GetCumulativeRechargeRewardConfig(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
|
||
}
|
||
// 后台配置是“未来事件规则”:保存时不扫描历史充值,也不改写已经创建的 grant 快照。
|
||
config.AppCode = appcode.FromContext(ctx)
|
||
config.Tiers = normalizeTiers(config.Tiers)
|
||
if err := validateConfig(config); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
return s.repository.UpdateCumulativeRechargeRewardConfig(ctx, config, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
// Consume 处理成功充值事实;一次充值可能让用户跨过多个累充档位,所以会返回多条 grant。
|
||
func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) ([]domain.Grant, bool, string, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, false, "", err
|
||
}
|
||
event = normalizeRechargeEvent(event)
|
||
if err := validateRechargeEvent(event); err != nil {
|
||
return nil, false, "", err
|
||
}
|
||
cycle := CycleForTime(time.UnixMilli(event.OccurredAtMS).UTC())
|
||
|
||
// Prepare 在一个 MySQL 事务里完成 event 幂等、周期累计和待发放 grant 创建;
|
||
// 这里不直接发资源,避免外部 wallet 副作用进入数据库事务导致长锁或半提交。
|
||
prepared, err := s.repository.PrepareCumulativeRechargeRewardGrants(ctx, event, cycle, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return nil, false, "", err
|
||
}
|
||
if len(prepared.Grants) == 0 {
|
||
return nil, prepared.Consumed, prepared.Reason, nil
|
||
}
|
||
if s.wallet == nil {
|
||
return nil, false, "", xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
|
||
granted := make([]domain.Grant, 0, len(prepared.Grants))
|
||
for _, grant := range prepared.Grants {
|
||
// 每个档位使用稳定 wallet command id;MQ 重投或上次发放失败时复用同一命令,交给 wallet-service 幂等。
|
||
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||
CommandId: grant.WalletCommandID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: grant.UserID,
|
||
GroupId: grant.ResourceGroupID,
|
||
Reason: grantReason,
|
||
OperatorUserId: grant.UserID,
|
||
GrantSource: grantSource,
|
||
})
|
||
if err != nil {
|
||
// wallet 调用失败只把 grant 标记为 failed,不删除记录;下一次同 event 重试会重新置回 pending 后继续发放。
|
||
_ = s.repository.MarkCumulativeRechargeRewardGrantFailed(ctx, grant.GrantID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return granted, false, prepared.Reason, err
|
||
}
|
||
walletGrantID := ""
|
||
if resp.GetGrant() != nil {
|
||
walletGrantID = resp.GetGrant().GetGrantId()
|
||
}
|
||
// 只有拿到 wallet 回执后才落 granted,后台记录因此可以区分“已命中但未到账”和“已到账”。
|
||
updated, err := s.repository.MarkCumulativeRechargeRewardGrantGranted(ctx, grant.GrantID, walletGrantID, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return granted, false, prepared.Reason, err
|
||
}
|
||
granted = append(granted, updated)
|
||
}
|
||
return granted, true, domain.ReasonEligible, nil
|
||
}
|
||
|
||
func (s *Service) ListGrants(ctx context.Context, query domain.GrantQuery) ([]domain.Grant, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.Status = strings.TrimSpace(query.Status)
|
||
query.CycleKey = strings.TrimSpace(query.CycleKey)
|
||
if query.Page <= 0 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize <= 0 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
return s.repository.ListCumulativeRechargeRewardGrants(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, "cumulative 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, "cumulative recharge reward repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CycleForTime returns the UTC natural-week cycle that contains value.
|
||
func CycleForTime(value time.Time) domain.Cycle {
|
||
utc := value.UTC()
|
||
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||
// Go 的 Weekday 从 Sunday 开始,这里转换成 Monday=0,用于计算 UTC 自然周起点。
|
||
daysSinceMonday := (int(dayStart.Weekday()) + 6) % 7
|
||
start := dayStart.AddDate(0, 0, -daysSinceMonday)
|
||
end := start.AddDate(0, 0, 7)
|
||
isoYear, isoWeek := start.ISOWeek()
|
||
return domain.Cycle{
|
||
Key: fmt.Sprintf("%04d-W%02d", isoYear, isoWeek),
|
||
StartMS: start.UnixMilli(),
|
||
EndMS: end.UnixMilli() - 1,
|
||
}
|
||
}
|
||
|
||
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),
|
||
ThresholdUSDMinor: tier.ThresholdUSDMinor,
|
||
ResourceGroupID: tier.ResourceGroupID,
|
||
Status: status,
|
||
SortOrder: tier.SortOrder,
|
||
})
|
||
}
|
||
// 排序同时服务后台回显和 H5 展示;sort_order 相同则按门槛金额升序保证跨档发放顺序稳定。
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
if items[i].SortOrder == items[j].SortOrder {
|
||
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
|
||
}
|
||
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 {
|
||
// tier_code 是后台编辑和审计展示标识,不参与唯一发放;真正的发放唯一性由 tier_id 绑定。
|
||
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.ThresholdUSDMinor <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "threshold_usd_minor must be greater than zero")
|
||
}
|
||
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")
|
||
}
|
||
// 同一个生效配置里 active 门槛不能重复,否则用户达标后无法用金额判断哪个档位先展示。
|
||
if hasDuplicateThreshold(active) {
|
||
return xerr.New(xerr.InvalidArgument, "active tier threshold_usd_minor is duplicated")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func hasDuplicateThreshold(tiers []domain.Tier) bool {
|
||
seen := make(map[int64]struct{}, len(tiers))
|
||
for _, tier := range tiers {
|
||
if _, exists := seen[tier.ThresholdUSDMinor]; exists {
|
||
return true
|
||
}
|
||
seen[tier.ThresholdUSDMinor] = struct{}{}
|
||
}
|
||
return false
|
||
}
|
||
|
||
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.ToLower(strings.TrimSpace(event.RechargeType))
|
||
if event.RechargeType == "" {
|
||
event.RechargeType = domain.RechargeTypeCoinSeller
|
||
}
|
||
if strings.TrimSpace(event.PayloadJSON) == "" {
|
||
event.PayloadJSON = "{}"
|
||
}
|
||
return event
|
||
}
|
||
|
||
func validateRechargeEvent(event domain.RechargeEvent) error {
|
||
if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" {
|
||
return xerr.New(xerr.InvalidArgument, "recharge event identity is incomplete")
|
||
}
|
||
if event.UserID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "user_id is required")
|
||
}
|
||
if event.QualifyingUSDMinor <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "qualifying_usd_minor must be greater than zero")
|
||
}
|
||
if event.OccurredAtMS <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
if items[i].SortOrder == items[j].SortOrder {
|
||
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
|
||
}
|
||
return items[i].SortOrder < items[j].SortOrder
|
||
})
|
||
return items
|
||
}
|