497 lines
19 KiB
Go
497 lines
19 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)
|
||
DeleteBadgeDisplayItems(ctx context.Context, userID int64, items []domain.BadgeDisplayItem) 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 rewards and validates entitlement-backed badge display slots.
|
||
type WalletClient interface {
|
||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest, opts ...grpc.CallOption) (*walletv1.ListUserResourcesResponse, 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) {
|
||
// 先按 wallet 当前权益回收旧展示槽,再投影本次发放。否则已到期的体验卡徽章仍会占用
|
||
// profile_strip 的 6 个位置,使后续新体验卡的有效徽章被永久跳过。
|
||
if _, err := s.reconciledBadgeProfile(ctx, event.UserID); err != nil {
|
||
return domain.EventResult{}, err
|
||
}
|
||
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.reconciledBadgeProfile(ctx, userID)
|
||
}
|
||
|
||
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")
|
||
}
|
||
// 用户选择前先移除失效 entitlement 对应的旧槽,避免 repository 仅凭历史投影放行已过期徽章。
|
||
availableProfile, err := s.reconciledBadgeProfile(ctx, command.UserID)
|
||
if err != nil {
|
||
return domain.BadgeProfile{}, err
|
||
}
|
||
activeEntitlements := badgeDisplayEntitlementsByResource(availableProfile)
|
||
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")
|
||
}
|
||
// entitlement_id 是 wallet 事实,不能接受客户端回传的旧实例。成就型徽章没有
|
||
// entitlement_id,会继续保持空值并由 achievement 解锁事实授权。
|
||
item.EntitlementID = activeEntitlements[item.ResourceID]
|
||
}
|
||
if _, err := s.repository.SetBadgeDisplay(ctx, command, s.now().UnixMilli()); err != nil {
|
||
return domain.BadgeProfile{}, err
|
||
}
|
||
// 写入和返回之间权益仍可能刚好到期;最终响应继续以 wallet 当前有效事实收口。
|
||
return s.reconciledBadgeProfile(ctx, command.UserID)
|
||
}
|
||
|
||
func (s *Service) reconciledBadgeProfile(ctx context.Context, userID int64) (domain.BadgeProfile, error) {
|
||
profile, err := s.repository.ListBadgeProfile(ctx, userID, s.now().UnixMilli())
|
||
if err != nil {
|
||
return domain.BadgeProfile{}, err
|
||
}
|
||
if !badgeProfileHasEntitlements(profile) {
|
||
// 成就、等级等计算型展示没有 entitlement_id,不需要为纯投影额外访问 wallet。
|
||
return profile, nil
|
||
}
|
||
if s.wallet == nil {
|
||
// 无法确认权益时失败关闭,不能继续把可能已过期的 VIP/系统赠送徽章返回客户端。
|
||
return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
active, err := s.wallet.ListUserResources(ctx, &walletv1.ListUserResourcesRequest{
|
||
AppCode: appcode.FromContext(ctx),
|
||
UserId: userID,
|
||
ResourceType: "badge",
|
||
ActiveOnly: true,
|
||
})
|
||
if err != nil {
|
||
return domain.BadgeProfile{}, err
|
||
}
|
||
// 同一素材续期可能生成新的 entitlement_id;展示资格按当前有效 resource_id 判断,同时把
|
||
// 返回值替换成最新有效实例,避免客户端继续拿到上一张体验卡的过期 entitlement_id。
|
||
activeEntitlementByResource := make(map[int64]string, len(active.GetResources()))
|
||
for _, item := range active.GetResources() {
|
||
if item.GetResourceId() <= 0 || item.GetEntitlementId() == "" {
|
||
continue
|
||
}
|
||
if _, exists := activeEntitlementByResource[item.GetResourceId()]; !exists {
|
||
activeEntitlementByResource[item.GetResourceId()] = item.GetEntitlementId()
|
||
}
|
||
}
|
||
|
||
stale := make([]domain.BadgeDisplayItem, 0)
|
||
profile.StripBadges, stale = filterActiveBadgeDisplayItems(profile.StripBadges, activeEntitlementByResource, stale)
|
||
profile.ProfileTileBadges, stale = filterActiveBadgeDisplayItems(profile.ProfileTileBadges, activeEntitlementByResource, stale)
|
||
profile.HonorBadges, stale = filterActiveBadgeDisplayItems(profile.HonorBadges, activeEntitlementByResource, stale)
|
||
if len(stale) > 0 {
|
||
// 删除条件包含用户、槽位、位置、资源和旧 entitlement,和并发的新投影不相互覆盖。
|
||
if err := s.repository.DeleteBadgeDisplayItems(ctx, userID, stale); err != nil {
|
||
return domain.BadgeProfile{}, err
|
||
}
|
||
}
|
||
return profile, nil
|
||
}
|
||
|
||
func badgeProfileHasEntitlements(profile domain.BadgeProfile) bool {
|
||
for _, items := range [][]domain.BadgeDisplayItem{profile.StripBadges, profile.ProfileTileBadges, profile.HonorBadges} {
|
||
for _, item := range items {
|
||
if item.EntitlementID != "" {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func badgeDisplayEntitlementsByResource(profile domain.BadgeProfile) map[int64]string {
|
||
result := make(map[int64]string)
|
||
for _, items := range [][]domain.BadgeDisplayItem{profile.ProfileTileBadges, profile.HonorBadges} {
|
||
for _, item := range items {
|
||
if item.ResourceID <= 0 || item.EntitlementID == "" {
|
||
continue
|
||
}
|
||
result[item.ResourceID] = item.EntitlementID
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func filterActiveBadgeDisplayItems(items []domain.BadgeDisplayItem, active map[int64]string, stale []domain.BadgeDisplayItem) ([]domain.BadgeDisplayItem, []domain.BadgeDisplayItem) {
|
||
filtered := make([]domain.BadgeDisplayItem, 0, len(items))
|
||
for _, item := range items {
|
||
if item.EntitlementID == "" {
|
||
filtered = append(filtered, item)
|
||
continue
|
||
}
|
||
entitlementID, ok := active[item.ResourceID]
|
||
if !ok {
|
||
stale = append(stale, item)
|
||
continue
|
||
}
|
||
item.EntitlementID = entitlementID
|
||
filtered = append(filtered, item)
|
||
}
|
||
return filtered, stale
|
||
}
|
||
|
||
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
|
||
}
|