2026-06-08 19:00:23 +08:00

526 lines
19 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 growth
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/growth"
)
const levelRewardReason = "growth_level_reward"
const (
registrationLevelBadgeReason = "growth_level_registration_badge"
registrationLevelBadgeSourceLevel = int32(1)
)
// Repository 是等级体系的 activity MySQL 持久化边界。
type Repository interface {
ListLevelOverview(ctx context.Context, userID int64, nowMS int64) (domain.Overview, error)
GetLevelTrack(ctx context.Context, userID int64, track string, nowMS int64) (domain.TrackDetail, error)
BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.LevelDisplayProfiles, error)
ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error)
ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error)
ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error)
SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand, nowMS int64) (domain.SetUserLevelResult, error)
UpsertLevelTrack(ctx context.Context, command domain.TrackCommand, nowMS int64) (domain.Track, bool, error)
UpsertLevelRule(ctx context.Context, command domain.RuleCommand, nowMS int64) (domain.Rule, bool, error)
UpsertLevelTier(ctx context.Context, command domain.TierCommand, nowMS int64) (domain.Tier, bool, error)
ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error)
MarkLevelRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
MarkLevelRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
}
// WalletClient 是等级奖励发放的唯一外部副作用。
type WalletClient interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。
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
}
}
func (s *Service) GetMyLevelOverview(ctx context.Context, userID int64) (domain.Overview, error) {
if err := s.requireRepository(); err != nil {
return domain.Overview{}, err
}
if userID <= 0 {
return domain.Overview{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return s.repository.ListLevelOverview(ctx, userID, s.now().UnixMilli())
}
func (s *Service) GetLevelTrack(ctx context.Context, userID int64, track string) (domain.TrackDetail, error) {
if err := s.requireRepository(); err != nil {
return domain.TrackDetail{}, err
}
track = normalizeTrack(track)
if userID <= 0 || !validTrack(track) {
return domain.TrackDetail{}, xerr.New(xerr.InvalidArgument, "user_id and track are required")
}
return s.repository.GetLevelTrack(ctx, userID, track, s.now().UnixMilli())
}
func (s *Service) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64) (domain.LevelDisplayProfiles, error) {
if err := s.requireRepository(); err != nil {
return domain.LevelDisplayProfiles{}, err
}
normalized := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
normalized = append(normalized, userID)
}
if len(normalized) == 0 {
return domain.LevelDisplayProfiles{Profiles: []domain.UserLevelDisplayProfile{}, ServerTimeMS: s.now().UnixMilli()}, nil
}
return s.repository.BatchGetUserLevelDisplayProfiles(ctx, normalized, s.now().UnixMilli())
}
func (s *Service) IssueRegistrationLevelBadges(ctx context.Context, userID int64, commandID string) (domain.RegistrationLevelBadgeGrants, error) {
if err := s.requireRepository(); err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
if s.wallet == nil {
return domain.RegistrationLevelBadgeGrants{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
commandID = strings.TrimSpace(commandID)
if userID <= 0 || commandID == "" {
return domain.RegistrationLevelBadgeGrants{}, xerr.New(xerr.InvalidArgument, "registration level badge command is incomplete")
}
nowMS := s.now().UnixMilli()
config, err := s.repository.ListLevelConfig(ctx, domain.ConfigQuery{Status: domain.StatusActive}, nowMS)
if err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
grants := make([]domain.RegistrationLevelBadgeGrant, 0, 3)
for _, track := range []string{domain.TrackWealth, domain.TrackGame, domain.TrackCharm} {
resourceID := levelRuleBadgeForExactLevel(config.Rules, track, registrationLevelBadgeSourceLevel)
if resourceID <= 0 {
// 没有配置 1 级等级徽章时跳过该轨道;注册主流程和其它轨道不受影响。
continue
}
// wallet 的 command_id 是实际发放幂等边界;按轨道拆分后,部分成功的重试只会补齐失败轨道。
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: fmt.Sprintf("%s:%s", commandID, track),
AppCode: appcode.FromContext(ctx),
TargetUserId: userID,
ResourceId: resourceID,
Quantity: 1,
DurationMs: 0,
Reason: registrationLevelBadgeReason,
OperatorUserId: userID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return domain.RegistrationLevelBadgeGrants{}, err
}
grants = append(grants, domain.RegistrationLevelBadgeGrant{
Track: track,
Level: registrationLevelBadgeSourceLevel,
ResourceID: resourceID,
GrantID: resp.GetGrant().GetGrantId(),
})
}
return domain.RegistrationLevelBadgeGrants{Grants: grants, ServerTimeMS: nowMS}, nil
}
func (s *Service) ListLevelConfig(ctx context.Context, query domain.ConfigQuery) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
query.Track = normalizeTrack(query.Track)
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
if query.Status == "all" {
query.Status = ""
}
if query.Track != "" && !validTrack(query.Track) {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "track is invalid")
}
if query.Status != "" && !validStatus(query.Status) {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "status is invalid")
}
return s.repository.ListLevelConfig(ctx, query, s.now().UnixMilli())
}
func levelRuleBadgeForExactLevel(rules []domain.Rule, track string, level int32) int64 {
for _, rule := range rules {
if rule.Track != track || rule.Level != level || rule.Status != domain.StatusActive {
continue
}
return longBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
}
return 0
}
func (s *Service) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Track = normalizeTrack(query.Track)
query.Status = strings.TrimSpace(query.Status)
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if query.Track != "" && !validTrack(query.Track) {
return nil, 0, xerr.New(xerr.InvalidArgument, "track is invalid")
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repository.ListLevelRewards(ctx, query)
}
func (s *Service) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent) (domain.EventResult, error) {
if err := s.requireRepository(); err != nil {
return domain.EventResult{}, err
}
event = normalizeValueEvent(event)
if err := validateValueEvent(event); err != nil {
return domain.EventResult{}, err
}
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = s.now().UnixMilli()
}
return s.repository.ConsumeLevelEvent(ctx, event, s.now().UnixMilli())
}
// SetUserLevel 是经理中心的直接等级调整入口。
// 它不接收累计值避免调用方伪造进度service 只校验轨道、等级和操作者,阈值读取与奖励补发在 repository 事务里完成。
func (s *Service) SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand) (domain.SetUserLevelResult, error) {
if err := s.requireRepository(); err != nil {
return domain.SetUserLevelResult{}, err
}
command.CommandID = strings.TrimSpace(command.CommandID)
command.Track = normalizeTrack(command.Track)
command.Reason = strings.TrimSpace(command.Reason)
if command.CommandID == "" || command.UserID <= 0 || command.OperatorUserID <= 0 {
return domain.SetUserLevelResult{}, xerr.New(xerr.InvalidArgument, "set user level command is incomplete")
}
if !validTrack(command.Track) {
return domain.SetUserLevelResult{}, xerr.New(xerr.InvalidArgument, "track is invalid")
}
if command.Level < 1 || command.Level > 11 {
return domain.SetUserLevelResult{}, xerr.New(xerr.InvalidArgument, "level must be between 1 and 11")
}
if command.Reason == "" {
command.Reason = "manager_center_level"
}
return s.repository.SetUserLevel(ctx, command, s.now().UnixMilli())
}
// HandleRoomEvent 把 room-service 已提交送礼事实映射为财富和魅力两条等级增量。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
if envelope == nil {
return 0, xerr.New(xerr.InvalidArgument, "room event envelope is required")
}
if envelope.GetEventType() != "RoomGiftSent" {
return 0, nil
}
var gift roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
if gift.GetSenderUserId() <= 0 || gift.GetTargetUserId() <= 0 || gift.GetGiftValue() <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "room gift event is incomplete")
}
dimensions, err := roomGiftDimensions(envelope, &gift)
if err != nil {
return 0, err
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
events := []domain.ValueEvent{
{
EventID: envelope.GetEventId() + ":wealth",
SourceEventID: envelope.GetEventId(),
SourceService: "room-service",
SourceEventType: envelope.GetEventType(),
UserID: gift.GetSenderUserId(),
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: gift.GetGiftValue(),
OccurredAtMS: envelope.GetOccurredAtMs(),
DimensionsJSON: dimensions,
},
{
EventID: envelope.GetEventId() + ":charm",
SourceEventID: envelope.GetEventId(),
SourceService: "room-service",
SourceEventType: envelope.GetEventType(),
UserID: gift.GetTargetUserId(),
Track: domain.TrackCharm,
MetricType: domain.MetricReceivedGiftValue,
ValueDelta: gift.GetGiftValue(),
OccurredAtMS: envelope.GetOccurredAtMs(),
DimensionsJSON: dimensions,
},
}
consumed := int32(0)
for _, event := range events {
result, err := s.ConsumeLevelEvent(eventCtx, event)
if err != nil {
return consumed, err
}
if result.Status == domain.EventStatusConsumed {
consumed++
}
}
return consumed, nil
}
func (s *Service) UpsertLevelTrack(ctx context.Context, command domain.TrackCommand) (domain.Track, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Track{}, false, err
}
command.Track = normalizeTrack(command.Track)
command.Name = strings.TrimSpace(command.Name)
command.Status = normalizeStatus(command.Status)
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
if !validTrack(command.Track) || command.Name == "" || !validStatus(command.Status) {
return domain.Track{}, false, xerr.New(xerr.InvalidArgument, "level track command is invalid")
}
return s.repository.UpsertLevelTrack(ctx, command, s.now().UnixMilli())
}
func (s *Service) UpsertLevelRule(ctx context.Context, command domain.RuleCommand) (domain.Rule, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Rule{}, false, err
}
command.Track = normalizeTrack(command.Track)
command.Name = strings.TrimSpace(command.Name)
command.Status = normalizeStatus(command.Status)
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
if !validTrack(command.Track) || command.Level < 0 || command.RequiredValue < 0 || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 {
return domain.Rule{}, false, xerr.New(xerr.InvalidArgument, "level rule command is invalid")
}
if command.RewardResourceGroupID < 0 {
return domain.Rule{}, false, xerr.New(xerr.InvalidArgument, "reward_resource_group_id is invalid")
}
return s.repository.UpsertLevelRule(ctx, command, s.now().UnixMilli())
}
func (s *Service) UpsertLevelTier(ctx context.Context, command domain.TierCommand) (domain.Tier, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Tier{}, false, err
}
command.Track = normalizeTrack(command.Track)
command.Name = strings.TrimSpace(command.Name)
command.Status = normalizeStatus(command.Status)
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
if !validTrack(command.Track) || command.MinLevel < 0 || command.MaxLevel < command.MinLevel || command.Name == "" || !validStatus(command.Status) || command.OperatorAdminID <= 0 {
return domain.Tier{}, false, xerr.New(xerr.InvalidArgument, "level tier command is invalid")
}
if command.DisplayAvatarFrameResourceID < 0 || command.DisplayBadgeResourceID < 0 || command.RewardResourceGroupID < 0 {
return domain.Tier{}, false, xerr.New(xerr.InvalidArgument, "level tier resource id is invalid")
}
return s.repository.UpsertLevelTier(ctx, command, s.now().UnixMilli())
}
func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
}
if s.wallet == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
if batchSize <= 0 {
batchSize = 100
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if strings.TrimSpace(workerID) == "" {
workerID = "activity-growth"
}
jobs, err := s.repository.ClaimPendingLevelRewardJobs(ctx, workerID, s.now().UnixMilli(), lockTTL, batchSize)
if err != nil {
return 0, 0, 0, 0, false, err
}
for _, job := range jobs {
processed++
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: job.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
GroupId: job.ResourceGroupID,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if grantErr != nil {
failure++
nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
continue
}
walletGrantID := ""
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil {
failure++
continue
}
success++
}
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
}
func roomGiftDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
payload := map[string]any{
"room_id": envelope.GetRoomId(),
"room_version": envelope.GetRoomVersion(),
"gift_id": gift.GetGiftId(),
"gift_count": gift.GetGiftCount(),
"billing_receipt_id": gift.GetBillingReceiptId(),
"command_id": gift.GetCommandId(),
"visible_region_id": gift.GetVisibleRegionId(),
}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(data), nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "growth repository is not configured")
}
return nil
}
func normalizeValueEvent(event domain.ValueEvent) domain.ValueEvent {
event.EventID = strings.TrimSpace(event.EventID)
event.SourceEventID = strings.TrimSpace(event.SourceEventID)
event.SourceService = strings.TrimSpace(event.SourceService)
event.SourceEventType = strings.TrimSpace(event.SourceEventType)
event.Track = normalizeTrack(event.Track)
event.MetricType = strings.ToLower(strings.TrimSpace(event.MetricType))
event.DimensionsJSON = normalizeJSON(event.DimensionsJSON)
return event
}
func validateValueEvent(event domain.ValueEvent) error {
if event.EventID == "" || event.SourceEventID == "" || event.SourceService == "" || event.SourceEventType == "" || event.UserID <= 0 || event.ValueDelta <= 0 {
return xerr.New(xerr.InvalidArgument, "level event is incomplete")
}
if !validTrack(event.Track) {
return xerr.New(xerr.InvalidArgument, "track is invalid")
}
if !validMetricForTrack(event.Track, event.MetricType) {
return xerr.New(xerr.InvalidArgument, "metric_type is invalid for track")
}
return nil
}
func normalizeTrack(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return domain.StatusActive
}
return value
}
func normalizeJSON(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "{}"
}
return value
}
func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0
}
value, ok := payload["long_badge_resource_id"]
if !ok {
return 0
}
switch typed := value.(type) {
case float64:
if typed > 0 {
return int64(typed)
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func validTrack(value string) bool {
switch value {
case domain.TrackWealth, domain.TrackGame, domain.TrackCharm:
return true
default:
return false
}
}
func validStatus(value string) bool {
switch value {
case domain.StatusActive, domain.StatusDisabled:
return true
default:
return false
}
}
func validMetricForTrack(track string, metric string) bool {
switch track {
case domain.TrackWealth:
return metric == domain.MetricGiftSpendCoin
case domain.TrackGame:
return metric == domain.MetricGameSpendCoin
case domain.TrackCharm:
return metric == domain.MetricReceivedGiftValue
default:
return false
}
}