874 lines
34 KiB
Go
874 lines
34 KiB
Go
package growth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"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"
|
||
messageservice "hyapp/services/activity-service/internal/service/message"
|
||
)
|
||
|
||
const levelRewardReason = "growth_level_reward"
|
||
|
||
const (
|
||
levelAvatarFrameRewardDays = int64(9999)
|
||
levelAvatarFrameRewardDurationMS = levelAvatarFrameRewardDays * 24 * 60 * 60 * 1000
|
||
)
|
||
|
||
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)
|
||
BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.AdminLevelProfiles, error)
|
||
AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand, nowMS int64) (domain.AdminUserLevelProfile, 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, grants []domain.RewardWalletGrant, nowMS int64) error
|
||
MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, failureReason string, nextRetryAtMS int64, nowMS int64) error
|
||
ClaimTemporaryLevelWork(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.TemporaryLevelWork, error)
|
||
MarkTemporaryLevelNotices(ctx context.Context, temporaryLevelID string, activationStatus string, expiryStatus string, failureReason string, nextRetryAtMS int64, nowMS int64) error
|
||
ClaimLevelRewardRevocations(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardRevocation, error)
|
||
MarkLevelRewardRevoked(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
|
||
MarkLevelRewardRevokeFailed(ctx context.Context, rewardJobID string, walletGrantID 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)
|
||
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
}
|
||
|
||
// NoticeService 只调用 message service 的公开 helper;growth 不直接写 inbox 表。
|
||
type NoticeService interface {
|
||
CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
|
||
}
|
||
|
||
type Option func(*Service)
|
||
|
||
func WithNoticeService(notices NoticeService) Option {
|
||
return func(service *Service) { service.notices = notices }
|
||
}
|
||
|
||
// Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
notices NoticeService
|
||
now func() time.Time
|
||
}
|
||
|
||
func New(repository Repository, wallet WalletClient, options ...Option) *Service {
|
||
service := &Service{repository: repository, wallet: wallet, now: time.Now}
|
||
for _, option := range options {
|
||
option(service)
|
||
}
|
||
return service
|
||
}
|
||
|
||
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())
|
||
}
|
||
|
||
// BatchGetUserLevelAdminProfiles 返回真实账户和当前有效 overlay;到期毫秒由 repository 读取条件立即排除,
|
||
// 所以后台展示不会依赖 cron 是否已经把记录推进为 expired。
|
||
func (s *Service) BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64) (domain.AdminLevelProfiles, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.AdminLevelProfiles{}, err
|
||
}
|
||
normalized := uniquePositiveUserIDs(userIDs)
|
||
if len(normalized) == 0 {
|
||
return domain.AdminLevelProfiles{Profiles: []domain.AdminUserLevelProfile{}, ServerTimeMS: s.now().UnixMilli()}, nil
|
||
}
|
||
if len(normalized) > 500 {
|
||
return domain.AdminLevelProfiles{}, xerr.New(xerr.InvalidArgument, "too many user ids; maximum is 500")
|
||
}
|
||
return s.repository.BatchGetUserLevelAdminProfiles(ctx, normalized, s.now().UnixMilli())
|
||
}
|
||
|
||
// AdjustTemporaryUserLevels 校验整批命令后只调用一次 repository,保证 wealth/charm 要么一起生效、要么一起回滚。
|
||
func (s *Service) AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand) (domain.AdminUserLevelProfile, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.AdminUserLevelProfile{}, err
|
||
}
|
||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||
command.Reason = strings.TrimSpace(command.Reason)
|
||
if command.CommandID == "" || command.UserID <= 0 || command.OperatorAdminID <= 0 || len(command.Adjustments) == 0 || len(command.Adjustments) > 2 {
|
||
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level command is incomplete")
|
||
}
|
||
seen := make(map[string]struct{}, len(command.Adjustments))
|
||
for index := range command.Adjustments {
|
||
item := &command.Adjustments[index]
|
||
item.Track = normalizeTrack(item.Track)
|
||
if item.Track != domain.TrackWealth && item.Track != domain.TrackCharm {
|
||
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level track must be wealth or charm")
|
||
}
|
||
if item.Level < 1 || item.Level > 50 || item.DurationDays <= 0 {
|
||
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level must be 1..50 and duration_days must be positive")
|
||
}
|
||
if _, exists := seen[item.Track]; exists {
|
||
return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level track is duplicated")
|
||
}
|
||
seen[item.Track] = struct{}{}
|
||
}
|
||
// 排序让同一业务载荷生成稳定 hash,也让 repository 以固定轨道顺序加锁,降低双轨并发死锁概率。
|
||
sort.Slice(command.Adjustments, func(i, j int) bool { return command.Adjustments[i].Track < command.Adjustments[j].Track })
|
||
if command.Reason == "" {
|
||
command.Reason = "admin_temporary_level"
|
||
}
|
||
hashPayload, _ := json.Marshal(struct {
|
||
UserID int64 `json:"user_id"`
|
||
Adjustments []domain.TemporaryLevelAdjustment `json:"adjustments"`
|
||
OperatorAdminID int64 `json:"operator_admin_id"`
|
||
Reason string `json:"reason"`
|
||
}{
|
||
UserID: command.UserID, Adjustments: command.Adjustments,
|
||
OperatorAdminID: command.OperatorAdminID, Reason: command.Reason,
|
||
})
|
||
command.RequestHash = fmt.Sprintf("%x", sha256.Sum256(hashPayload))
|
||
return s.repository.AdjustTemporaryUserLevels(ctx, command, s.now().UTC().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 > 30 {
|
||
return domain.SetUserLevelResult{}, xerr.New(xerr.InvalidArgument, "level must be between 1 and 30")
|
||
}
|
||
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.GetIsRobotGift() {
|
||
return 0, nil
|
||
}
|
||
if gift.GetSenderUserId() <= 0 || gift.GetTargetUserId() <= 0 || gift.GetGiftValue() < 0 {
|
||
return 0, xerr.New(xerr.InvalidArgument, "room gift event is incomplete")
|
||
}
|
||
if gift.GetGiftValue() == 0 {
|
||
// 低价幸运礼物按房间贡献比例折算后可能为 0;这是合法结算结果,增长服务只确认事件并跳过等级增量。
|
||
return 0, nil
|
||
}
|
||
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++
|
||
walletGrants, grantErr := s.grantLevelRewardJob(ctx, job)
|
||
if grantErr != nil {
|
||
failure++
|
||
nextRetry := s.now().Add(time.Minute).UnixMilli()
|
||
// One reward job may span several idempotent wallet commands. Persist every successful prefix even when a
|
||
// later command fails, otherwise an overlay expiry cannot discover and revoke the already-issued asset.
|
||
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, walletGrants, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
|
||
continue
|
||
}
|
||
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrants, s.now().UnixMilli()); markErr != nil {
|
||
failure++
|
||
continue
|
||
}
|
||
success++
|
||
}
|
||
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
|
||
}
|
||
|
||
func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob) ([]domain.RewardWalletGrant, error) {
|
||
grants := make([]domain.RewardWalletGrant, 0, 3)
|
||
nowMS := s.now().UnixMilli()
|
||
if job.ResourceGroupID > 0 {
|
||
// 资源组奖励沿用原有配置快照;command_id 后缀把组奖励和单资源等级物料奖励拆成多个钱包幂等动作。
|
||
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||
CommandId: job.WalletCommandID + ":group",
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: job.UserID,
|
||
GroupId: job.ResourceGroupID,
|
||
Reason: levelRewardReason,
|
||
OperatorUserId: job.UserID,
|
||
GrantSource: domain.GrantSourceGrowthLevel,
|
||
})
|
||
if err != nil {
|
||
return grants, err
|
||
}
|
||
if resp.GetGrant() != nil {
|
||
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "resource_group", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
|
||
}
|
||
}
|
||
levelResources, err := s.levelResourceIDsForRewardJob(ctx, job)
|
||
if err != nil {
|
||
return grants, err
|
||
}
|
||
durationMS := levelAvatarFrameRewardDurationMS
|
||
// 临时直发物料也按自然奖励的长有效期授予,并由记录到子表的 grant 在 overlay 到期时显式撤回。
|
||
// 这样用户在有效期内真实达到该等级后只需把任务提升为永久并跳过撤回,钱包权益不会仍按临时截止时间消失。
|
||
if levelResources.avatarFrameResourceID > 0 {
|
||
// 等级头像框是每级固定物料,不依赖运营额外创建资源组;按产品要求直接发 9999 天权益。
|
||
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
|
||
CommandId: job.WalletCommandID + ":avatar_frame",
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: job.UserID,
|
||
ResourceId: levelResources.avatarFrameResourceID,
|
||
Quantity: 1,
|
||
DurationMs: durationMS,
|
||
Reason: levelRewardReason,
|
||
OperatorUserId: job.UserID,
|
||
GrantSource: domain.GrantSourceGrowthLevel,
|
||
})
|
||
if err != nil {
|
||
return grants, err
|
||
}
|
||
if resp.GetGrant() != nil {
|
||
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "avatar_frame", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
|
||
}
|
||
}
|
||
if levelResources.shortBadgeResourceID > 0 {
|
||
// 短徽章和等级头像框一样来自每级规则;发放为 9999 天资源,展示覆盖仍由长徽章投影负责。
|
||
resp, err := s.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
|
||
CommandId: job.WalletCommandID + ":short_badge",
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: job.UserID,
|
||
ResourceId: levelResources.shortBadgeResourceID,
|
||
Quantity: 1,
|
||
DurationMs: durationMS,
|
||
Reason: levelRewardReason,
|
||
OperatorUserId: job.UserID,
|
||
GrantSource: domain.GrantSourceGrowthLevel,
|
||
})
|
||
if err != nil {
|
||
return grants, err
|
||
}
|
||
if resp.GetGrant() != nil {
|
||
grants = append(grants, domain.RewardWalletGrant{RewardJobID: job.RewardJobID, GrantKind: "short_badge", WalletGrantID: resp.GetGrant().GetGrantId(), CreatedAtMS: nowMS})
|
||
}
|
||
}
|
||
return grants, nil
|
||
}
|
||
|
||
// ProcessTemporaryLevelBatch 推进过期状态、发送幂等系统消息并撤回不再应得的临时奖励。
|
||
// 状态准备由 repository 事务完成,外部副作用失败只回写 retry_at,不会重新污染真实成长账户。
|
||
func (s *Service) ProcessTemporaryLevelBatch(ctx context.Context, 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 || s.notices == nil {
|
||
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "temporary level side effects are not configured")
|
||
}
|
||
if batchSize <= 0 {
|
||
batchSize = 100
|
||
}
|
||
if lockTTL <= 0 {
|
||
lockTTL = 30 * time.Second
|
||
}
|
||
if strings.TrimSpace(workerID) == "" {
|
||
workerID = "activity-temporary-level"
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
works, err := s.repository.ClaimTemporaryLevelWork(ctx, workerID, nowMS, lockTTL, batchSize)
|
||
if err != nil {
|
||
return 0, 0, 0, 0, false, err
|
||
}
|
||
for _, work := range works {
|
||
processed++
|
||
activationStatus, expiryStatus := "", ""
|
||
var workErr error
|
||
if work.SendActivationNotice {
|
||
workErr = s.sendTemporaryLevelNotice(ctx, work, false, nowMS)
|
||
if workErr == nil {
|
||
activationStatus = domain.NoticeStatusSent
|
||
} else {
|
||
activationStatus = domain.NoticeStatusFailed
|
||
}
|
||
}
|
||
if workErr == nil && work.SendExpiryNotice {
|
||
workErr = s.sendTemporaryLevelNotice(ctx, work, true, nowMS)
|
||
if workErr == nil {
|
||
expiryStatus = domain.NoticeStatusSent
|
||
} else {
|
||
expiryStatus = domain.NoticeStatusFailed
|
||
}
|
||
}
|
||
failureReason := ""
|
||
nextRetryAtMS := int64(0)
|
||
if workErr != nil {
|
||
failure++
|
||
failureReason = xerr.MessageOf(workErr)
|
||
nextRetryAtMS = s.now().Add(time.Minute).UnixMilli()
|
||
} else {
|
||
success++
|
||
}
|
||
if markErr := s.repository.MarkTemporaryLevelNotices(ctx, work.TemporaryLevelID, activationStatus, expiryStatus, failureReason, nextRetryAtMS, s.now().UnixMilli()); markErr != nil {
|
||
failure++
|
||
if workErr == nil && success > 0 {
|
||
success--
|
||
}
|
||
}
|
||
}
|
||
remaining := batchSize - len(works)
|
||
revocations := []domain.RewardRevocation{}
|
||
if remaining > 0 {
|
||
revocations, err = s.repository.ClaimLevelRewardRevocations(ctx, workerID, s.now().UnixMilli(), lockTTL, remaining)
|
||
if err != nil {
|
||
return int32(len(works)), processed, success, failure, false, err
|
||
}
|
||
}
|
||
for _, revoke := range revocations {
|
||
processed++
|
||
_, revokeErr := s.wallet.RevokeResourceGrant(ctx, &walletv1.RevokeResourceGrantRequest{
|
||
RequestId: fmt.Sprintf("temporary-level-revoke:%s:%s", revoke.RewardJobID, revoke.WalletGrantID),
|
||
AppCode: appcode.FromContext(ctx),
|
||
GrantId: revoke.WalletGrantID,
|
||
Reason: "temporary_level_expired",
|
||
OperatorUserId: revoke.UserID,
|
||
})
|
||
if revokeErr != nil {
|
||
failure++
|
||
_ = s.repository.MarkLevelRewardRevokeFailed(ctx, revoke.RewardJobID, revoke.WalletGrantID, xerr.MessageOf(revokeErr), s.now().Add(time.Minute).UnixMilli(), s.now().UnixMilli())
|
||
continue
|
||
}
|
||
if markErr := s.repository.MarkLevelRewardRevoked(ctx, revoke.RewardJobID, revoke.WalletGrantID, s.now().UnixMilli()); markErr != nil {
|
||
failure++
|
||
continue
|
||
}
|
||
success++
|
||
}
|
||
claimed = int32(len(works) + len(revocations))
|
||
hasMore = len(works) == batchSize || (remaining > 0 && len(revocations) == remaining)
|
||
return claimed, processed, success, failure, hasMore, nil
|
||
}
|
||
|
||
func (s *Service) sendTemporaryLevelNotice(ctx context.Context, work domain.TemporaryLevelWork, expired bool, nowMS int64) error {
|
||
trackName := map[string]string{domain.TrackWealth: "富豪", domain.TrackCharm: "魅力"}[work.Track]
|
||
body := fmt.Sprintf("尊敬的用户,官方已将您%s等级临时升级为Lv%d,有效期%d天,请在有效期内继续升级哦", trackName, work.TargetLevel, (work.ExpiresAtMS-work.StartedAtMS)/(24*60*60*1000))
|
||
eventSuffix := "activated"
|
||
eventType := "temporary_level_activated"
|
||
if expired {
|
||
body = fmt.Sprintf("您的临时%s等级Lv%d已到期,根据您在有效期内的经验值,现恢复为Lv%d", trackName, work.TargetLevel, work.FinalLevel)
|
||
eventSuffix = "expired"
|
||
eventType = "temporary_level_expired"
|
||
}
|
||
_, err := s.notices.CreateSystemNotice(ctx, messageservice.NoticeCommand{
|
||
TargetUserID: work.UserID,
|
||
Producer: "growth-level",
|
||
ProducerEventID: fmt.Sprintf("temporary-level:%s:%s", work.TemporaryLevelID, eventSuffix),
|
||
ProducerEventType: eventType,
|
||
AggregateType: "temporary_growth_level",
|
||
AggregateID: work.TemporaryLevelID,
|
||
Title: "系统通知",
|
||
Summary: body,
|
||
Body: body,
|
||
SentAtMS: nowMS,
|
||
Metadata: map[string]any{
|
||
"track": work.Track, "target_level": work.TargetLevel, "final_level": work.FinalLevel,
|
||
"started_at_ms": work.StartedAtMS, "expires_at_ms": work.ExpiresAtMS,
|
||
},
|
||
})
|
||
return err
|
||
}
|
||
|
||
type levelRewardResourceIDs struct {
|
||
avatarFrameResourceID int64
|
||
shortBadgeResourceID int64
|
||
}
|
||
|
||
func (s *Service) levelResourceIDsForRewardJob(ctx context.Context, job domain.RewardJob) (levelRewardResourceIDs, error) {
|
||
if job.RewardSourceType != domain.RewardSourceLevel {
|
||
return levelRewardResourceIDs{}, nil
|
||
}
|
||
level, ok := levelFromRewardSourceID(job.RewardSourceID)
|
||
if !ok {
|
||
return levelRewardResourceIDs{}, nil
|
||
}
|
||
config, err := s.repository.ListLevelConfig(ctx, domain.ConfigQuery{Track: job.Track, Status: domain.StatusActive}, s.now().UnixMilli())
|
||
if err != nil {
|
||
return levelRewardResourceIDs{}, err
|
||
}
|
||
for _, rule := range config.Rules {
|
||
if rule.Track == job.Track && rule.Level == level && rule.Status == domain.StatusActive {
|
||
return levelRewardResourceIDs{
|
||
avatarFrameResourceID: avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON),
|
||
shortBadgeResourceID: shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON),
|
||
}, nil
|
||
}
|
||
}
|
||
return levelRewardResourceIDs{}, 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 {
|
||
return positiveInt64FromDisplayConfig(raw, "long_badge_resource_id")
|
||
}
|
||
|
||
func avatarFrameResourceIDFromDisplayConfig(raw string) int64 {
|
||
return positiveInt64FromDisplayConfig(raw, "avatar_frame_resource_id")
|
||
}
|
||
|
||
func shortBadgeResourceIDFromDisplayConfig(raw string) int64 {
|
||
return positiveInt64FromDisplayConfig(raw, "short_badge_resource_id")
|
||
}
|
||
|
||
func positiveInt64FromDisplayConfig(raw string, key string) int64 {
|
||
payload := map[string]any{}
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
|
||
return 0
|
||
}
|
||
value, ok := payload[key]
|
||
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 levelFromRewardSourceID(value string) (int32, bool) {
|
||
levelText := strings.TrimPrefix(strings.TrimSpace(value), "level:")
|
||
if levelText == strings.TrimSpace(value) {
|
||
return 0, false
|
||
}
|
||
parsed, err := strconv.ParseInt(levelText, 10, 32)
|
||
if err != nil || parsed < 0 {
|
||
return 0, false
|
||
}
|
||
return int32(parsed), true
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
func uniquePositiveUserIDs(values []int64) []int64 {
|
||
result := make([]int64, 0, len(values))
|
||
seen := make(map[int64]struct{}, len(values))
|
||
for _, value := range values {
|
||
if value <= 0 {
|
||
continue
|
||
}
|
||
if _, exists := seen[value]; exists {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|