374 lines
13 KiB
Go
374 lines
13 KiB
Go
package achievement
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/achievement"
|
|
)
|
|
|
|
const achievementRewardReason = "achievement_reward"
|
|
|
|
// Repository is the persistence boundary for achievements and badge display slots.
|
|
type Repository interface {
|
|
ListAchievements(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.UserAchievement, int64, error)
|
|
ListAchievementDefinitions(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.Definition, int64, error)
|
|
ConsumeAchievementEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error)
|
|
ConsumeWalletBadgeGrantEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error)
|
|
ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error)
|
|
SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error)
|
|
UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand, nowMS int64) (domain.Definition, bool, error)
|
|
ClaimPendingAchievementRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error)
|
|
MarkAchievementRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
|
|
MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
|
|
}
|
|
|
|
// WalletClient grants achievement reward resource groups.
|
|
type WalletClient interface {
|
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
|
}
|
|
|
|
// Service handles achievement reads, fact consumption, badge display and reward retries.
|
|
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) ListAchievements(ctx context.Context, query domain.ListQuery) ([]domain.UserAchievement, int64, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
query.CollectionType = normalizeCollectionType(query.CollectionType)
|
|
query.CollectionID = strings.TrimSpace(query.CollectionID)
|
|
query.Status = strings.TrimSpace(query.Status)
|
|
if query.UserID <= 0 {
|
|
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
|
|
}
|
|
if query.CollectionType == "" && query.CollectionID == "" {
|
|
query.CollectionType = domain.CollectionOrdinary
|
|
}
|
|
if query.Page <= 0 {
|
|
query.Page = 1
|
|
}
|
|
if query.PageSize <= 0 {
|
|
query.PageSize = 50
|
|
}
|
|
if query.PageSize > 100 {
|
|
query.PageSize = 100
|
|
}
|
|
return s.repository.ListAchievements(ctx, query, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) ListAchievementDefinitions(ctx context.Context, query domain.ListQuery) ([]domain.Definition, int64, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
// Admin reads must include draft/paused/archived definitions so operators can recover and edit rules that App reads hide.
|
|
query.CollectionType = normalizeCollectionType(query.CollectionType)
|
|
query.CollectionID = strings.TrimSpace(query.CollectionID)
|
|
query.Status = normalizeDefinitionStatusFilter(query.Status)
|
|
if query.Status != "" && !validDefinitionStatus(query.Status) {
|
|
return nil, 0, xerr.New(xerr.InvalidArgument, "achievement definition status is invalid")
|
|
}
|
|
if query.Page <= 0 {
|
|
query.Page = 1
|
|
}
|
|
if query.PageSize <= 0 {
|
|
query.PageSize = 50
|
|
}
|
|
if query.PageSize > 100 {
|
|
query.PageSize = 100
|
|
}
|
|
return s.repository.ListAchievementDefinitions(ctx, query, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) ConsumeAchievementEvent(ctx context.Context, event domain.Event) (domain.EventResult, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return domain.EventResult{}, err
|
|
}
|
|
event = normalizeEvent(event)
|
|
if event.EventID == "" || event.EventType == "" || event.SourceService == "" || event.UserID <= 0 || event.MetricType == "" || event.Value <= 0 {
|
|
return domain.EventResult{}, xerr.New(xerr.InvalidArgument, "achievement event is incomplete")
|
|
}
|
|
if event.OccurredAtMS <= 0 {
|
|
event.OccurredAtMS = s.now().UnixMilli()
|
|
}
|
|
if isWalletBadgeGrantEvent(event) {
|
|
return s.repository.ConsumeWalletBadgeGrantEvent(ctx, event, s.now().UnixMilli())
|
|
}
|
|
return s.repository.ConsumeAchievementEvent(ctx, event, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) ListBadgeProfile(ctx context.Context, userID int64) (domain.BadgeProfile, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return domain.BadgeProfile{}, err
|
|
}
|
|
if userID <= 0 {
|
|
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
|
}
|
|
return s.repository.ListBadgeProfile(ctx, userID, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand) (domain.BadgeProfile, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return domain.BadgeProfile{}, err
|
|
}
|
|
command.Slot = normalizeSlot(command.Slot)
|
|
command.CommandID = strings.TrimSpace(command.CommandID)
|
|
if command.UserID <= 0 || command.CommandID == "" || command.Slot == "" {
|
|
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "badge display command is incomplete")
|
|
}
|
|
if command.Slot != domain.BadgeSlotProfileTile {
|
|
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "only profile_tile can be changed by user")
|
|
}
|
|
if len(command.Items) > 6 {
|
|
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "too many display badges")
|
|
}
|
|
for index := range command.Items {
|
|
item := &command.Items[index]
|
|
item.Slot = command.Slot
|
|
item.BadgeForm = domain.BadgeFormTile
|
|
item.SourceType = normalizeSourceType(item.SourceType)
|
|
if item.SourceType == "" {
|
|
item.SourceType = domain.SourceAchievement
|
|
}
|
|
item.PinMode = "user"
|
|
if item.Position <= 0 {
|
|
item.Position = int32(index + 1)
|
|
}
|
|
if item.ResourceID <= 0 {
|
|
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "badge resource_id is required")
|
|
}
|
|
}
|
|
return s.repository.SetBadgeDisplay(ctx, command, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand) (domain.Definition, bool, error) {
|
|
if err := s.requireRepository(); err != nil {
|
|
return domain.Definition{}, false, err
|
|
}
|
|
command = normalizeDefinitionCommand(command)
|
|
if err := validateDefinitionCommand(command); err != nil {
|
|
return domain.Definition{}, false, err
|
|
}
|
|
return s.repository.UpsertAchievementDefinition(ctx, command, s.now().UnixMilli())
|
|
}
|
|
|
|
func (s *Service) ProcessAchievementRewardBatch(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-achievement"
|
|
}
|
|
jobs, err := s.repository.ClaimPendingAchievementRewardJobs(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: achievementRewardReason,
|
|
OperatorUserId: job.UserID,
|
|
GrantSource: domain.GrantSourceAchievement,
|
|
})
|
|
if grantErr != nil {
|
|
failure++
|
|
nextRetry := s.now().Add(time.Minute).UnixMilli()
|
|
_ = s.repository.MarkAchievementRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
|
|
continue
|
|
}
|
|
walletGrantID := ""
|
|
if resp.GetGrant() != nil {
|
|
walletGrantID = resp.GetGrant().GetGrantId()
|
|
}
|
|
if markErr := s.repository.MarkAchievementRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil {
|
|
failure++
|
|
continue
|
|
}
|
|
success++
|
|
}
|
|
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
|
|
}
|
|
|
|
func (s *Service) requireRepository() error {
|
|
if s == nil || s.repository == nil {
|
|
return xerr.New(xerr.Unavailable, "achievement repository is not configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeEvent(event domain.Event) domain.Event {
|
|
event.EventID = strings.TrimSpace(event.EventID)
|
|
event.EventType = strings.TrimSpace(event.EventType)
|
|
event.SourceService = strings.TrimSpace(event.SourceService)
|
|
event.MetricType = strings.ToLower(strings.TrimSpace(event.MetricType))
|
|
event.DimensionsJSON = normalizeJSON(event.DimensionsJSON)
|
|
return event
|
|
}
|
|
|
|
func isWalletBadgeGrantEvent(event domain.Event) bool {
|
|
if event.SourceService != "wallet-service" {
|
|
return false
|
|
}
|
|
switch event.EventType {
|
|
case "ResourceGranted", "ResourceGroupGranted":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeDefinitionCommand(command domain.DefinitionCommand) domain.DefinitionCommand {
|
|
command.AchievementID = strings.TrimSpace(command.AchievementID)
|
|
command.CollectionID = strings.TrimSpace(command.CollectionID)
|
|
command.CollectionType = normalizeCollectionType(command.CollectionType)
|
|
command.AchievementType = normalizeAchievementType(command.AchievementType)
|
|
command.Title = strings.TrimSpace(command.Title)
|
|
command.Description = strings.TrimSpace(command.Description)
|
|
command.Status = normalizeDefinitionStatus(command.Status)
|
|
command.AutoPinPolicy = normalizeAutoPinPolicy(command.AutoPinPolicy)
|
|
command.DisplayConfigJSON = normalizeJSON(command.DisplayConfigJSON)
|
|
if command.CollectionID == "" && command.CollectionType == domain.CollectionOrdinary {
|
|
command.CollectionID = domain.CollectionOrdinary
|
|
}
|
|
for index := range command.Conditions {
|
|
condition := &command.Conditions[index]
|
|
condition.MetricType = strings.ToLower(strings.TrimSpace(condition.MetricType))
|
|
condition.TargetUnit = strings.TrimSpace(condition.TargetUnit)
|
|
condition.DimensionFilterJSON = normalizeJSON(condition.DimensionFilterJSON)
|
|
}
|
|
return command
|
|
}
|
|
|
|
func validateDefinitionCommand(command domain.DefinitionCommand) error {
|
|
if command.CollectionType == "" || command.AchievementType == "" || command.Title == "" || command.OperatorAdminID <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "achievement definition command is incomplete")
|
|
}
|
|
if !validDefinitionStatus(command.Status) || !validAchievementType(command.AchievementType) {
|
|
return xerr.New(xerr.InvalidArgument, "achievement definition status or type is invalid")
|
|
}
|
|
if command.RewardResourceGroupID < 0 || command.PrimaryBadgeResourceID < 0 {
|
|
return xerr.New(xerr.InvalidArgument, "achievement reward resource is invalid")
|
|
}
|
|
if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
|
|
return xerr.New(xerr.InvalidArgument, "achievement effective time is invalid")
|
|
}
|
|
if len(command.Conditions) == 0 {
|
|
return xerr.New(xerr.InvalidArgument, "achievement condition is required")
|
|
}
|
|
for _, condition := range command.Conditions {
|
|
if condition.MetricType == "" || condition.TargetValue <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "achievement condition is invalid")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeAchievementType(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return domain.TypeOrdinary
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeCollectionType(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeDefinitionStatus(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return domain.StatusDraft
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeDefinitionStatusFilter(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeAutoPinPolicy(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return domain.AutoPinNone
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeSlot(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func normalizeSourceType(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func validDefinitionStatus(value string) bool {
|
|
switch value {
|
|
case domain.StatusDraft, domain.StatusActive, domain.StatusPaused, domain.StatusArchived:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAchievementType(value string) bool {
|
|
switch value {
|
|
case domain.TypeOrdinary, domain.TypeActivityLimited:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeJSON(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "{}"
|
|
}
|
|
if !json.Valid([]byte(value)) {
|
|
return "{}"
|
|
}
|
|
return value
|
|
}
|