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

357 lines
13 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 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 idMQ 重投或上次发放失败时复用同一命令,交给 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
}