2026-06-26 16:20:58 +08:00

388 lines
14 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 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)
DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, 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
}
// 后台删除成就只归档定义;默认列表隐藏 archived避免运营误编辑已经从页面移除的成就。
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) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64) (domain.Definition, error) {
if err := s.requireRepository(); err != nil {
return domain.Definition{}, err
}
achievementID = strings.TrimSpace(achievementID)
if achievementID == "" || operatorAdminID <= 0 {
return domain.Definition{}, xerr.New(xerr.InvalidArgument, "achievement delete command is incomplete")
}
// 删除使用归档状态承载历史用户解锁、徽章展示和审计链路都保留App/后台默认查询会排除归档定义。
return s.repository.DeleteAchievementDefinition(ctx, achievementID, operatorAdminID, 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.PrimaryBadgeResourceID <= 0 {
return xerr.New(xerr.InvalidArgument, "achievement badge resource is required")
}
if command.RewardResourceGroupID < 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")
}
// 新版成就配置不再要求事件条件;历史条件保留兼容,存在时仍可被旧事件流推进。
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
}