成就,财富魅力等级

This commit is contained in:
zhx 2026-06-26 16:20:58 +08:00
parent 538668014b
commit fe736780e0
25 changed files with 3862 additions and 3005 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1711,6 +1711,16 @@ message UpsertAchievementDefinitionResponse {
bool created = 2;
}
message DeleteAchievementDefinitionRequest {
RequestMeta meta = 1;
string achievement_id = 2;
int64 operator_admin_id = 3;
}
message DeleteAchievementDefinitionResponse {
AchievementDefinition achievement = 1;
}
message LuckyGiftMeta {
RequestMeta meta = 1;
string command_id = 2;
@ -2743,6 +2753,7 @@ service AdminGrowthLevelService {
service AdminAchievementService {
rpc ListAchievementDefinitions(ListAchievementsRequest) returns (ListAchievementsResponse);
rpc UpsertAchievementDefinition(UpsertAchievementDefinitionRequest) returns (UpsertAchievementDefinitionResponse);
rpc DeleteAchievementDefinition(DeleteAchievementDefinitionRequest) returns (DeleteAchievementDefinitionResponse);
}
// AdminLuckyGiftService is the admin entry for lucky gift rule versions and draw audit.

View File

@ -6175,6 +6175,7 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
const (
AdminAchievementService_ListAchievementDefinitions_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/ListAchievementDefinitions"
AdminAchievementService_UpsertAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/UpsertAchievementDefinition"
AdminAchievementService_DeleteAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/DeleteAchievementDefinition"
)
// AdminAchievementServiceClient is the client API for AdminAchievementService service.
@ -6185,6 +6186,7 @@ const (
type AdminAchievementServiceClient interface {
ListAchievementDefinitions(ctx context.Context, in *ListAchievementsRequest, opts ...grpc.CallOption) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, in *UpsertAchievementDefinitionRequest, opts ...grpc.CallOption) (*UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(ctx context.Context, in *DeleteAchievementDefinitionRequest, opts ...grpc.CallOption) (*DeleteAchievementDefinitionResponse, error)
}
type adminAchievementServiceClient struct {
@ -6215,6 +6217,16 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
return out, nil
}
func (c *adminAchievementServiceClient) DeleteAchievementDefinition(ctx context.Context, in *DeleteAchievementDefinitionRequest, opts ...grpc.CallOption) (*DeleteAchievementDefinitionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteAchievementDefinitionResponse)
err := c.cc.Invoke(ctx, AdminAchievementService_DeleteAchievementDefinition_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminAchievementServiceServer is the server API for AdminAchievementService service.
// All implementations must embed UnimplementedAdminAchievementServiceServer
// for forward compatibility.
@ -6223,6 +6235,7 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
type AdminAchievementServiceServer interface {
ListAchievementDefinitions(context.Context, *ListAchievementsRequest) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(context.Context, *DeleteAchievementDefinitionRequest) (*DeleteAchievementDefinitionResponse, error)
mustEmbedUnimplementedAdminAchievementServiceServer()
}
@ -6239,6 +6252,9 @@ func (UnimplementedAdminAchievementServiceServer) ListAchievementDefinitions(con
func (UnimplementedAdminAchievementServiceServer) UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertAchievementDefinition not implemented")
}
func (UnimplementedAdminAchievementServiceServer) DeleteAchievementDefinition(context.Context, *DeleteAchievementDefinitionRequest) (*DeleteAchievementDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteAchievementDefinition not implemented")
}
func (UnimplementedAdminAchievementServiceServer) mustEmbedUnimplementedAdminAchievementServiceServer() {
}
func (UnimplementedAdminAchievementServiceServer) testEmbeddedByValue() {}
@ -6297,6 +6313,24 @@ func _AdminAchievementService_UpsertAchievementDefinition_Handler(srv interface{
return interceptor(ctx, in, info, handler)
}
func _AdminAchievementService_DeleteAchievementDefinition_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteAchievementDefinitionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAchievementServiceServer).DeleteAchievementDefinition(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAchievementService_DeleteAchievementDefinition_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAchievementServiceServer).DeleteAchievementDefinition(ctx, req.(*DeleteAchievementDefinitionRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminAchievementService_ServiceDesc is the grpc.ServiceDesc for AdminAchievementService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -6312,6 +6346,10 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertAchievementDefinition",
Handler: _AdminAchievementService_UpsertAchievementDefinition_Handler,
},
{
MethodName: "DeleteAchievementDefinition",
Handler: _AdminAchievementService_DeleteAchievementDefinition_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

View File

@ -15,6 +15,7 @@ type Client interface {
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, req *activityv1.UpsertAchievementDefinitionRequest) (*activityv1.UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error)
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
@ -133,6 +134,10 @@ func (c *GRPCClient) UpsertAchievementDefinition(ctx context.Context, req *activ
return c.achievementClient.UpsertAchievementDefinition(ctx, req)
}
func (c *GRPCClient) DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error) {
return c.achievementClient.DeleteAchievementDefinition(ctx, req)
}
func (c *GRPCClient) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) {
return c.registrationRewardClient.GetRegistrationRewardConfig(ctx, req)
}

View File

@ -142,6 +142,27 @@ func (h *Handler) UpdateAchievementDefinition(c *gin.Context) {
response.OK(c, item)
}
// DeleteAchievementDefinition 采用归档删除,避免后台删除动作破坏用户已获得徽章和历史成就事实。
func (h *Handler) DeleteAchievementDefinition(c *gin.Context) {
achievementID := strings.TrimSpace(c.Param("achievement_id"))
if achievementID == "" {
response.BadRequest(c, "ID 参数不正确")
return
}
resp, err := h.activity.DeleteAchievementDefinition(c.Request.Context(), &activityv1.DeleteAchievementDefinitionRequest{
Meta: h.meta(c),
AchievementId: achievementID,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := achievementFromProto(resp.GetAchievement())
h.auditLog(c, "delete-achievement", item.AchievementID, item.Title)
response.OK(c, item)
}
func (r achievementRequest) toProto(c *gin.Context, achievementID string, meta *activityv1.RequestMeta) *activityv1.UpsertAchievementDefinitionRequest {
conditions := make([]*activityv1.AchievementCondition, 0, len(r.Conditions))
for _, condition := range r.Conditions {

View File

@ -14,4 +14,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/activity/achievements", middleware.RequirePermission("achievement:view"), h.ListAchievementDefinitions)
protected.POST("/admin/activity/achievements", middleware.RequirePermission("achievement:create"), h.CreateAchievementDefinition)
protected.PUT("/admin/activity/achievements/:achievement_id", middleware.RequirePermission("achievement:update"), h.UpdateAchievementDefinition)
protected.DELETE("/admin/activity/achievements/:achievement_id", middleware.RequirePermission("achievement:update"), h.DeleteAchievementDefinition)
}

View File

@ -149,6 +149,7 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
Page: options.Page,
PageSize: options.PageSize,
UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")),
BizType: firstQuery(c, "biz_type", "bizType"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), true

View File

@ -4,6 +4,7 @@ type listQuery struct {
Page int
PageSize int
UserKeyword string
BizType string
StartAtMS int64
EndAtMS int64
}

View File

@ -671,6 +671,12 @@ func userDTOFromProfile(profile userProfile) coinLedgerUserDTO {
func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ?"
args := []any{appCode, coinAssetType}
if query.BizType != "" {
// 普通金币流水的类型就是 wallet_transactions.biz_type这里做精确参数匹配
// 不把用户输入拼进 SQL保证新增业务类型也能通过同一个查询入口按真实账务类型定位。
where += " AND wt.biz_type = ?"
args = append(args, query.BizType)
}
if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS)
@ -759,6 +765,7 @@ func normalizeListQuery(query listQuery) listQuery {
query.PageSize = 100
}
query.UserKeyword = strings.TrimSpace(query.UserKeyword)
query.BizType = strings.TrimSpace(query.BizType)
return query
}

View File

@ -20,6 +20,17 @@ func TestCoinLedgerWhereUsesTimeWindowAndUserFilter(t *testing.T) {
}
}
func TestCoinLedgerWhereUsesBizTypeFilter(t *testing.T) {
query := listQuery{BizType: "game_debit", StartAtMS: 100, EndAtMS: 200}
where, args := coinLedgerWhere("lalu", query, []int64{11})
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.user_id IN (?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 6 || args[0] != "lalu" || args[1] != coinAssetType || args[2] != "game_debit" || args[3] != int64(100) || args[4] != int64(200) || args[5] != int64(11) {
t.Fatalf("args mismatch: %#v", args)
}
}
func TestCoinAdjustmentWhereLimitsManualCreditCoinEntries(t *testing.T) {
query := listQuery{StartAtMS: 100, EndAtMS: 200}
where, args := coinAdjustmentWhere("lalu", query, []int64{11})

View File

@ -24,7 +24,9 @@ const (
statusActive = "active"
statusDisabled = "disabled"
longBadgeResourceIDKey = "long_badge_resource_id"
longBadgeResourceIDKey = "long_badge_resource_id"
avatarFrameResourceIDKey = "avatar_frame_resource_id"
shortBadgeResourceIDKey = "short_badge_resource_id"
)
type Handler struct {
@ -63,6 +65,8 @@ type ruleDTO struct {
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
SortOrder int32 `json:"sort_order"`
LongBadgeResourceID int64 `json:"long_badge_resource_id"`
AvatarFrameResourceID int64 `json:"avatar_frame_resource_id"`
ShortBadgeResourceID int64 `json:"short_badge_resource_id"`
DisplayConfigJSON string `json:"display_config_json"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
@ -101,6 +105,8 @@ type ruleRequest struct {
RewardResourceGroupID *int64 `json:"reward_resource_group_id"`
SortOrder *int32 `json:"sort_order"`
LongBadgeResourceID *int64 `json:"long_badge_resource_id"`
AvatarFrameResourceID *int64 `json:"avatar_frame_resource_id"`
ShortBadgeResourceID *int64 `json:"short_badge_resource_id"`
DisplayConfigJSON *string `json:"display_config_json"`
}
@ -277,7 +283,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err = mergeLongBadgeResourceID(displayConfig, req.LongBadgeResourceID)
displayConfig, err = mergeRuleDisplayResources(displayConfig, req.LongBadgeResourceID, req.AvatarFrameResourceID, req.ShortBadgeResourceID)
if err != nil {
response.BadRequest(c, "展示配置 JSON 不正确")
return
@ -288,6 +294,18 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return
}
}
if avatarFrameID := avatarFrameResourceID(displayConfig); avatarFrameID > 0 {
if err := h.validateLevelAvatarFrameResource(c, track, avatarFrameID); err != nil {
response.BadRequest(c, err.Error())
return
}
}
if shortBadgeID := shortBadgeResourceID(displayConfig); shortBadgeID > 0 {
if err := h.validateLevelShortBadgeResource(c, track, shortBadgeID); err != nil {
response.BadRequest(c, err.Error())
return
}
}
resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{
Meta: h.meta(c),
Track: track,
@ -305,7 +323,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return
}
item := ruleFromProto(resp.GetRule())
h.auditLog(c, "upsert-level-rule", "growth_level_rules", fmt.Sprintf("%s:%d", track, level), fmt.Sprintf("required_value=%d long_badge_resource_id=%d", item.RequiredValue, item.LongBadgeResourceID))
h.auditLog(c, "upsert-level-rule", "growth_level_rules", fmt.Sprintf("%s:%d", track, level), fmt.Sprintf("required_value=%d long_badge_resource_id=%d avatar_frame_resource_id=%d short_badge_resource_id=%d", item.RequiredValue, item.LongBadgeResourceID, item.AvatarFrameResourceID, item.ShortBadgeResourceID))
response.OK(c, item)
}
@ -494,6 +512,67 @@ func (h *Handler) validateLevelLongBadgeResource(c *gin.Context, track string, r
return nil
}
func (h *Handler) validateLevelAvatarFrameResource(c *gin.Context, track string, resourceID int64) error {
if resourceID <= 0 {
return nil
}
if h.wallet == nil {
return fmt.Errorf("资源服务不可用")
}
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return fmt.Errorf("等级头像框资源不存在")
}
resource := resp.GetResource()
if resource.GetResourceType() != "avatar_frame" {
return fmt.Errorf("等级头像框必须选择头像框资源")
}
metadata := levelAvatarFrameMetadataFromJSON(resource.GetMetadataJson())
if metadata.AvatarFrameKind != "level" {
return fmt.Errorf("等级头像框必须选择等级头像框素材")
}
if metadata.LevelTrack != track {
return fmt.Errorf("等级头像框所属等级不匹配")
}
return nil
}
func (h *Handler) validateLevelShortBadgeResource(c *gin.Context, track string, resourceID int64) error {
if resourceID <= 0 {
return nil
}
if h.wallet == nil {
return fmt.Errorf("资源服务不可用")
}
resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
})
if err != nil {
return fmt.Errorf("等级短徽章资源不存在")
}
resource := resp.GetResource()
if resource.GetResourceType() != "badge" {
return fmt.Errorf("等级短徽章必须选择徽章资源")
}
metadata := levelBadgeMetadataFromJSON(resource.GetMetadataJson())
if metadata.BadgeForm != "tile" {
return fmt.Errorf("等级短徽章必须选择短徽章素材")
}
if metadata.BadgeKind != "level" {
return fmt.Errorf("等级短徽章必须选择等级徽章素材")
}
if metadata.LevelTrack != track {
return fmt.Errorf("等级短徽章所属等级不匹配")
}
return nil
}
func (h *Handler) currentTrack(c *gin.Context, track string) (trackDTO, bool, error) {
config, err := h.loadConfig(c, track, "")
if err != nil {
@ -577,6 +656,8 @@ func ruleFromProto(item *activityv1.LevelRule) ruleDTO {
RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(),
LongBadgeResourceID: longBadgeResourceID(item.GetDisplayConfigJson()),
AvatarFrameResourceID: avatarFrameResourceID(item.GetDisplayConfigJson()),
ShortBadgeResourceID: shortBadgeResourceID(item.GetDisplayConfigJson()),
DisplayConfigJSON: item.GetDisplayConfigJson(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
@ -634,17 +715,31 @@ func findTier(items []*activityv1.LevelTier, tierID int64) (*activityv1.LevelTie
return nil, false
}
func mergeLongBadgeResourceID(raw string, value *int64) (string, error) {
func mergeRuleDisplayResources(raw string, longBadgeResourceID *int64, avatarFrameResourceID *int64, shortBadgeResourceID *int64) (string, error) {
payload, err := objectFromJSON(raw)
if err != nil {
return "", err
}
if value != nil {
if *value < 0 {
if longBadgeResourceID != nil {
if *longBadgeResourceID < 0 {
return "", fmt.Errorf("long badge resource id is invalid")
}
// 长徽章是每一级展示素材;沿用 display_config_json 可避免为展示扩展反复改表。
payload[longBadgeResourceIDKey] = *value
payload[longBadgeResourceIDKey] = *longBadgeResourceID
}
if avatarFrameResourceID != nil {
if *avatarFrameResourceID < 0 {
return "", fmt.Errorf("avatar frame resource id is invalid")
}
// 等级头像框也是每一级独立物料;和长徽章共用 display_config_json避免为等级展示资源反复扩表。
payload[avatarFrameResourceIDKey] = *avatarFrameResourceID
}
if shortBadgeResourceID != nil {
if *shortBadgeResourceID < 0 {
return "", fmt.Errorf("short badge resource id is invalid")
}
// 短徽章是每一级发放给用户的等级奖励;放在 display_config_json 内保持规则表结构稳定。
payload[shortBadgeResourceIDKey] = *shortBadgeResourceID
}
data, err := json.Marshal(payload)
if err != nil {
@ -683,11 +778,23 @@ func objectFromJSON(raw string) (map[string]any, error) {
}
func longBadgeResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, longBadgeResourceIDKey)
}
func avatarFrameResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, avatarFrameResourceIDKey)
}
func shortBadgeResourceID(raw string) int64 {
return positiveInt64FromDisplayConfig(raw, shortBadgeResourceIDKey)
}
func positiveInt64FromDisplayConfig(raw string, key string) int64 {
payload, err := objectFromJSON(raw)
if err != nil {
return 0
}
value, ok := payload[longBadgeResourceIDKey]
value, ok := payload[key]
if !ok {
return 0
}
@ -716,6 +823,11 @@ type levelBadgeMetadata struct {
LevelTrack string `json:"level_track"`
}
type levelAvatarFrameMetadata struct {
AvatarFrameKind string `json:"avatar_frame_kind"`
LevelTrack string `json:"level_track"`
}
func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
payload := levelBadgeMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
@ -727,6 +839,16 @@ func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
return payload
}
func levelAvatarFrameMetadataFromJSON(raw string) levelAvatarFrameMetadata {
payload := levelAvatarFrameMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return levelAvatarFrameMetadata{}
}
payload.AvatarFrameKind = strings.ToLower(strings.TrimSpace(payload.AvatarFrameKind))
payload.LevelTrack = normalizeTrack(payload.LevelTrack)
return payload
}
func parseNonNegativeInt32(raw string) (int32, bool) {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
if err != nil || value < 0 {

View File

@ -6,15 +6,23 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestMergeLongBadgeResourceIDPreservesDisplayConfig(t *testing.T) {
func TestMergeRuleDisplayResourcesPreservesDisplayConfig(t *testing.T) {
badgeID := int64(9001)
raw, err := mergeLongBadgeResourceID(`{"theme":"gold"}`, &badgeID)
avatarFrameID := int64(9101)
shortBadgeID := int64(9201)
raw, err := mergeRuleDisplayResources(`{"theme":"gold"}`, &badgeID, &avatarFrameID, &shortBadgeID)
if err != nil {
t.Fatalf("mergeLongBadgeResourceID failed: %v", err)
t.Fatalf("mergeRuleDisplayResources failed: %v", err)
}
if got := longBadgeResourceID(raw); got != badgeID {
t.Fatalf("long badge id mismatch: got=%d raw=%s", got, raw)
}
if got := avatarFrameResourceID(raw); got != avatarFrameID {
t.Fatalf("avatar frame id mismatch: got=%d raw=%s", got, raw)
}
if got := shortBadgeResourceID(raw); got != shortBadgeID {
t.Fatalf("short badge id mismatch: got=%d raw=%s", got, raw)
}
config, err := objectFromJSON(raw)
if err != nil {
t.Fatalf("objectFromJSON failed: %v", err)
@ -31,7 +39,7 @@ func TestConfigFromProtoGroupsRulesAndTiers(t *testing.T) {
Track: "wealth",
Level: 3,
RequiredValue: 300,
DisplayConfigJson: `{"long_badge_resource_id":7003}`,
DisplayConfigJson: `{"long_badge_resource_id":7003,"avatar_frame_resource_id":8103,"short_badge_resource_id":7103}`,
}},
Tiers: []*activityv1.LevelTier{{
TierId: 11,
@ -49,6 +57,12 @@ func TestConfigFromProtoGroupsRulesAndTiers(t *testing.T) {
if len(dto.Tracks[0].Rules) != 1 || dto.Tracks[0].Rules[0].LongBadgeResourceID != 7003 {
t.Fatalf("rule long badge mismatch: %+v", dto.Tracks[0].Rules)
}
if dto.Tracks[0].Rules[0].AvatarFrameResourceID != 8103 {
t.Fatalf("rule avatar frame mismatch: %+v", dto.Tracks[0].Rules)
}
if dto.Tracks[0].Rules[0].ShortBadgeResourceID != 7103 {
t.Fatalf("rule short badge mismatch: %+v", dto.Tracks[0].Rules)
}
if len(dto.Tracks[0].Tiers) != 1 || dto.Tracks[0].Tiers[0].DisplayBadgeResourceID != 9001 {
t.Fatalf("tier grouping mismatch: %+v", dto.Tracks[0].Tiers)
}

View File

@ -16,6 +16,7 @@ type resourceDTO struct {
WalletAssetAmount int64 `json:"walletAssetAmount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind,omitempty"`
BadgeForm string `json:"badgeForm,omitempty"`
BadgeKind string `json:"badgeKind,omitempty"`
LevelTrack string `json:"levelTrack,omitempty"`
@ -221,9 +222,10 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO {
WalletAssetAmount: item.GetWalletAssetAmount(),
PriceType: item.GetPriceType(),
CoinPrice: item.GetCoinPrice(),
AvatarFrameKind: avatarFrameKindForResource(item.GetResourceType(), item.GetMetadataJson()),
BadgeForm: badgeFormForResource(item.GetResourceType(), item.GetMetadataJson()),
BadgeKind: badgeKindForResource(item.GetResourceType(), item.GetMetadataJson()),
LevelTrack: badgeLevelTrackForResource(item.GetResourceType(), item.GetMetadataJson()),
LevelTrack: levelTrackForResource(item.GetResourceType(), item.GetMetadataJson()),
UsageScopes: item.GetUsageScopes(),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),

View File

@ -96,6 +96,10 @@ func (h *Handler) CreateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceAvatarFrameKind(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return
@ -208,6 +212,10 @@ func (h *Handler) UpdateResource(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceAvatarFrameKind(req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := validateResourceMetadata(req); err != nil {
response.BadRequest(c, err.Error())
return

View File

@ -94,7 +94,7 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
}
func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "")
metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "", "")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
@ -102,13 +102,13 @@ func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
if payload.BadgeForm != badgeFormStrip || payload.BadgeKind != badgeKindNormal || payload.DefaultSlot != "profile_strip" {
t.Fatalf("long badge metadata mismatch: %+v", payload)
}
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short", "", "", "")); got != badgeFormTile {
if got := badgeFormFromMetadata(resourceMetadataJSON(resourceTypeBadge, "short", "", "", "", "")); got != badgeFormTile {
t.Fatalf("short badge form mismatch: %s", got)
}
}
func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth")
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth", "")
payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err)
@ -118,6 +118,20 @@ func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
}
}
func TestAvatarFrameResourceMetadataMarksLevelFrame(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeAvatarFrame, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "game", avatarFrameKindLevel)
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("avatar frame metadata json mismatch: %v", err)
}
if payload.AvatarFrameKind != avatarFrameKindLevel || payload.LevelTrack != "game" {
t.Fatalf("level avatar frame metadata mismatch: %+v", payload)
}
if payload.AnimationFormat != resourceAnimationFormatMP4 || payload.MP4AlphaLayout == nil {
t.Fatalf("level avatar frame should keep animation metadata: %+v", payload)
}
}
func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": {
@ -132,7 +146,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
"detect_version": 99
},
"debug": true
}`, "", "")
}`, "", "", "")
payload := profileCardMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card metadata json mismatch: %v", err)
@ -153,7 +167,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) {
for _, resourceType := range []string{"gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", "mic_seat_animation"} {
metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "")
metadata := resourceMetadataJSON(resourceType, "", mp4AlphaLayoutMetadataJSON("alpha_left_rgb_right", true), "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err)
@ -179,7 +193,7 @@ func TestResourceMetadataKeepsNormalMP4Layout(t *testing.T) {
"confirmed": true,
"detect_version": 1
}
}`, "", "")
}`, "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("normal mp4 metadata json mismatch: %v", err)
@ -214,7 +228,7 @@ func TestProfileCardResourceMetadataKeepsLayoutAndMP4AlphaLayout(t *testing.T) {
"confirmed": true,
"detect_version": 1
}
}`, "", "")
}`, "", "", "")
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card mp4 metadata json mismatch: %v", err)
@ -285,7 +299,7 @@ func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) {
"content_bottom": 1666,
"content_height": 1
}
}`, "", "")
}`, "", "", "")
if metadata != "{}" {
t.Fatalf("invalid profile card layout should be dropped: %s", metadata)
}
@ -309,6 +323,21 @@ func TestValidateBadgeResourceRequiresBadgeForm(t *testing.T) {
}
}
func TestValidateAvatarFrameResourceRequiresLevelTrack(t *testing.T) {
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame}); err != nil {
t.Fatalf("normal avatar frame should be default: %v", err)
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame, AvatarFrameKind: avatarFrameKindLevel}); err == nil {
t.Fatalf("level avatar frame should require level track")
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: resourceTypeAvatarFrame, AvatarFrameKind: avatarFrameKindLevel, LevelTrack: "charm"}); err != nil {
t.Fatalf("valid level avatar frame rejected: %v", err)
}
if err := validateResourceAvatarFrameKind(resourceRequest{ResourceType: "gift", AvatarFrameKind: "bad"}); err != nil {
t.Fatalf("non-avatar-frame resource should not require avatar frame kind: %v", err)
}
}
func mp4AlphaLayoutMetadataJSON(alphaLayout string, confirmed bool) string {
return `{
"animation_format": "mp4",

View File

@ -19,6 +19,7 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
const (
resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card"
@ -53,6 +54,11 @@ const (
badgeKindLevel = "level"
)
const (
avatarFrameKindNormal = "normal"
avatarFrameKindLevel = "level"
)
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
@ -67,6 +73,7 @@ type resourceRequest struct {
Amount int64 `json:"amount"`
PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind"`
BadgeForm string `json:"badgeForm"`
BadgeKind string `json:"badgeKind"`
LevelTrack string `json:"levelTrack"`
@ -113,6 +120,8 @@ type badgeMetadataPayload struct {
type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"`
AvatarFrameKind string `json:"avatar_frame_kind,omitempty"`
LevelTrack string `json:"level_track,omitempty"`
MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"`
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_layout,omitempty"`
}
@ -235,7 +244,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -304,7 +313,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -599,6 +608,20 @@ func validateResourceBadgeForm(req resourceRequest) error {
return nil
}
func validateResourceAvatarFrameKind(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeAvatarFrame {
return nil
}
avatarFrameKind := normalizeAvatarFrameKind(req.AvatarFrameKind)
if avatarFrameKind == "" {
return fmt.Errorf("请选择头像框类型")
}
if avatarFrameKind == avatarFrameKindLevel && !validLevelTrack(req.LevelTrack) {
return fmt.Errorf("请选择等级头像框所属等级")
}
return nil
}
func validateResourceMetadata(req resourceRequest) error {
resourceType := normalizeResourceType(req.ResourceType)
payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON)
@ -636,7 +659,7 @@ func resourceGrantStrategy(resourceType string) string {
switch resourceType {
case resourceTypeCoin:
return "wallet_credit"
case "avatar_frame", "profile_card", "vehicle", "chat_bubble":
case resourceTypeAvatarFrame, "profile_card", "vehicle", "chat_bubble":
return "extend_expiry"
case "badge":
return "set_active_flag"
@ -645,19 +668,30 @@ func resourceGrantStrategy(resourceType string) string {
}
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string) string {
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string {
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
}
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil || rawPayload == nil {
if err != nil {
return "{}"
}
if rawPayload == nil {
rawPayload = &resourceMetadataPayload{}
}
payload := resourceMetadataPayload{}
if resourceType == resourceTypeProfileCard {
payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout)
}
if resourceType == resourceTypeAvatarFrame {
kind := normalizeAvatarFrameKind(avatarFrameKind)
payload.AvatarFrameKind = kind
if kind == avatarFrameKindLevel {
// 等级头像框会被等级奖励任务按轨道过滤并发放;普通头像框不写 level_track避免商店、经理赠送等入口误判。
payload.LevelTrack = normalizeLevelTrack(levelTrack)
}
}
if resourceAllowsAnimationMetadata(resourceType) {
if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4
@ -728,7 +762,7 @@ func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload
}
func marshalResourceMetadataPayload(payload resourceMetadataPayload) string {
if payload.AnimationFormat == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil {
if payload.AnimationFormat == "" && payload.AvatarFrameKind == "" && payload.LevelTrack == "" && payload.MP4AlphaLayout == nil && payload.ProfileCardLayout == nil {
return "{}"
}
body, err := json.Marshal(payload)
@ -894,6 +928,17 @@ func normalizeBadgeKind(value string) string {
}
}
func normalizeAvatarFrameKind(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", avatarFrameKindNormal:
return avatarFrameKindNormal
case avatarFrameKindLevel:
return avatarFrameKindLevel
default:
return ""
}
}
func normalizeLevelTrack(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if validLevelTrack(value) {
@ -935,6 +980,26 @@ func badgeLevelTrackFromMetadata(metadataJSON string) string {
return normalizeLevelTrack(payload.LevelTrack)
}
func avatarFrameKindFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return avatarFrameKindNormal
}
kind := normalizeAvatarFrameKind(payload.AvatarFrameKind)
if kind == "" {
return avatarFrameKindNormal
}
return kind
}
func avatarFrameLevelTrackFromMetadata(metadataJSON string) string {
payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return ""
}
return normalizeLevelTrack(payload.LevelTrack)
}
func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge {
return ""
@ -960,6 +1025,31 @@ func badgeLevelTrackForResource(resourceType string, metadataJSON string) string
return badgeLevelTrackFromMetadata(metadataJSON)
}
func avatarFrameKindForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameKindFromMetadata(metadataJSON)
}
func avatarFrameLevelTrackForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeAvatarFrame {
return ""
}
return avatarFrameLevelTrackFromMetadata(metadataJSON)
}
func levelTrackForResource(resourceType string, metadataJSON string) string {
switch normalizeResourceType(resourceType) {
case resourceTypeBadge:
return badgeLevelTrackFromMetadata(metadataJSON)
case resourceTypeAvatarFrame:
return avatarFrameLevelTrackFromMetadata(metadataJSON)
default:
return ""
}
}
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{

View File

@ -5,6 +5,7 @@ const (
TypeActivityLimited = "activity_limited"
CollectionOrdinary = "ordinary"
CollectionEvent = "event"
CycleLifetime = "lifetime"
StatusDraft = "draft"

View File

@ -24,6 +24,7 @@ type Repository interface {
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
@ -80,7 +81,7 @@ func (s *Service) ListAchievementDefinitions(ctx context.Context, query domain.L
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.
// 后台删除成就只归档定义;默认列表隐藏 archived避免运营误编辑已经从页面移除的成就。
query.CollectionType = normalizeCollectionType(query.CollectionType)
query.CollectionID = strings.TrimSpace(query.CollectionID)
query.Status = normalizeDefinitionStatusFilter(query.Status)
@ -171,6 +172,18 @@ func (s *Service) UpsertAchievementDefinition(ctx context.Context, command domai
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
@ -278,15 +291,16 @@ func validateDefinitionCommand(command domain.DefinitionCommand) error {
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 {
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")
}
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")

View File

@ -171,6 +171,54 @@ func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) {
}
}
func TestDirectBadgeAchievementWithoutConditions(t *testing.T) {
svc, _ := newAchievementService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, _, err := svc.UpsertAchievementDefinition(ctx, domain.DefinitionCommand{
CollectionType: domain.CollectionEvent,
AchievementType: domain.TypeActivityLimited,
Title: "活动徽章",
Description: "活动达成后展示",
Status: domain.StatusActive,
PrimaryBadgeResourceID: 8101,
OperatorAdminID: 90001,
}); err != nil {
t.Fatalf("UpsertAchievementDefinition without conditions failed: %v", err)
}
if _, err := svc.ConsumeAchievementEvent(ctx, domain.Event{
EventID: "wallet-badge-event-direct",
EventType: "ResourceGranted",
SourceService: "wallet-service",
UserID: 10002,
MetricType: "wallet_badge_grant",
Value: 1,
OccurredAtMS: fixedAchievementNow().UnixMilli(),
DimensionsJSON: `{"grant_id":"grant-badge-direct","grant_source":"admin","items":[{"resource_id":8101,"resource_type":"badge","entitlement_id":"ent-badge-direct","metadata_json":"{\"badge_form\":\"tile\"}"}]}`,
}); err != nil {
t.Fatalf("project badge resource failed: %v", err)
}
items, total, err := svc.ListAchievements(ctx, domain.ListQuery{UserID: 10002, CollectionType: domain.CollectionEvent})
if err != nil {
t.Fatalf("ListAchievements direct badge failed: %v", err)
}
if total != 1 || len(items) != 1 || items[0].UserStatus != domain.UnlockStatusUnlocked || items[0].ProgressValue != 1 || items[0].TargetValue != 1 {
t.Fatalf("direct badge achievement read mismatch: total=%d items=%+v", total, items)
}
deleted, err := svc.DeleteAchievementDefinition(ctx, items[0].AchievementID, 90001)
if err != nil || deleted.Status != domain.StatusArchived {
t.Fatalf("DeleteAchievementDefinition failed: item=%+v err=%v", deleted, err)
}
adminItems, adminTotal, err := svc.ListAchievementDefinitions(ctx, domain.ListQuery{CollectionType: domain.CollectionEvent})
if err != nil {
t.Fatalf("ListAchievementDefinitions after delete failed: %v", err)
}
if adminTotal != 0 || len(adminItems) != 0 {
t.Fatalf("archived achievement should be hidden by default: total=%d items=%+v", adminTotal, adminItems)
}
}
func newAchievementService(t *testing.T) (*achievementservice.Service, *fakeAchievementWallet) {
t.Helper()
repository := mysqltest.NewRepository(t)

View File

@ -19,6 +19,11 @@ import (
const levelRewardReason = "growth_level_reward"
const (
levelAvatarFrameRewardDays = int64(9999)
levelAvatarFrameRewardDurationMS = levelAvatarFrameRewardDays * 24 * 60 * 60 * 1000
)
const (
registrationLevelBadgeReason = "growth_level_registration_badge"
registrationLevelBadgeSourceLevel = int32(1)
@ -381,25 +386,13 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
}
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,
})
walletGrantID, grantErr := s.grantLevelRewardJob(ctx, job)
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
@ -409,6 +402,101 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
return int32(len(jobs)), processed, success, failure, len(jobs) == batchSize, nil
}
func (s *Service) grantLevelRewardJob(ctx context.Context, job domain.RewardJob) (string, error) {
walletGrantID := ""
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 "", err
}
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
levelResources, err := s.levelResourceIDsForRewardJob(ctx, job)
if err != nil {
return "", err
}
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: levelAvatarFrameRewardDurationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
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: levelAvatarFrameRewardDurationMS,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if err != nil {
return "", err
}
if walletGrantID == "" && resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
}
return walletGrantID, nil
}
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(),
@ -478,11 +566,23 @@ func normalizeJSON(value string) string {
}
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["long_badge_resource_id"]
value, ok := payload[key]
if !ok {
return 0
}
@ -500,6 +600,18 @@ func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
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:

View File

@ -88,6 +88,78 @@ func TestGrowthLevelEventRewardFlow(t *testing.T) {
}
}
func TestGrowthLevelRewardGrantsLevelResources(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth,
Level: 1,
RequiredValue: 100,
Name: "wealth 1",
Status: domain.StatusActive,
SortOrder: 1,
DisplayConfigJSON: `{"avatar_frame_resource_id":801,"short_badge_resource_id":901}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level resource rule failed: created=%v err=%v", created, err)
}
result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "level-avatar-frame-event-1",
SourceEventID: "room-event-avatar-frame-1",
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: 10002,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: 100,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-1"}`,
})
if err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
if result.RewardJobCount != 1 {
t.Fatalf("level resources should create one reward job: %+v", result)
}
overview, err := svc.GetMyLevelOverview(ctx, 10002)
if err != nil {
t.Fatalf("GetMyLevelOverview failed: %v", err)
}
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if wealth.DisplayAvatarFrameResourceID != 801 {
t.Fatalf("level avatar frame should override display profile: %+v", wealth)
}
profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10002})
if err != nil {
t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err)
}
if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.AvatarFrameResourceID != 801 {
t.Fatalf("room display profile should use level avatar frame: %+v", profiles.Profiles)
}
claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-avatar-frame", "worker-1", 10, time.Second)
if err != nil {
t.Fatalf("ProcessLevelRewardBatch failed: %v", err)
}
if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore {
t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v", claimed, processed, success, failure, hasMore)
}
if len(wallet.grants) != 0 || len(wallet.resourceGrants) != 2 {
t.Fatalf("level resource reward should grant two resources only: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants))
}
grantedResources := make(map[int64]*walletv1.GrantResourceRequest, len(wallet.resourceGrants))
for _, req := range wallet.resourceGrants {
grantedResources[req.GetResourceId()] = req
}
for _, resourceID := range []int64{801, 901} {
req := grantedResources[resourceID]
if req == nil || req.GetDurationMs() != 9999*24*60*60*1000 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel {
t.Fatalf("level resource grant mismatch: id=%d grants=%+v", resourceID, wallet.resourceGrants)
}
}
}
func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")

View File

@ -430,6 +430,47 @@ func (r *Repository) UpsertAchievementDefinition(ctx context.Context, command do
return definition, created, err
}
func (r *Repository) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error) {
if r == nil || r.db == nil {
return domain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Definition{}, err
}
defer func() { _ = tx.Rollback() }()
current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID)
if err != nil {
return domain.Definition{}, err
}
version := current.Version
if current.Status != domain.StatusArchived {
version = current.Version + 1
// 归档保留定义主键和历史用户事实,只让 App 与后台默认列表跳过;这样不会破坏用户已有徽章展示引用。
if _, err := tx.ExecContext(ctx, `
UPDATE achievement_definitions
SET status = 'archived', version = ?, updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND achievement_id = ?`,
version, operatorAdminID, nowMS, appcode.FromContext(ctx), achievementID,
); err != nil {
return domain.Definition{}, err
}
// 条件只服务旧事件推进;归档时清掉当前条件,避免恢复脚本或历史事件误匹配已删除配置。
if _, err := tx.ExecContext(ctx, `
DELETE FROM achievement_conditions
WHERE app_code = ? AND achievement_id = ?`,
appcode.FromContext(ctx), achievementID,
); err != nil {
return domain.Definition{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.Definition{}, err
}
return r.getAchievementDefinition(ctx, r.db, achievementID)
}
func (r *Repository) ClaimPendingAchievementRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -509,6 +550,9 @@ func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID i
for _, condition := range definition.Conditions {
targetTotal += condition.TargetValue
}
if targetTotal == 0 && definition.PrimaryBadgeResourceID > 0 {
targetTotal = 1
}
progressTotal := int64(0)
completed := len(definition.Conditions) > 0
for _, condition := range definition.Conditions {
@ -522,6 +566,17 @@ func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID i
if err != nil {
return domain.UserAchievement{}, err
}
if !unlocked && definition.PrimaryBadgeResourceID > 0 {
// 新版成就以徽章资源配置为核心不再强依赖事件条件用户已经拥有该徽章展示资源时H5 直接视为已激活。
hasBadge, err := r.userHasBadgeDisplayResource(ctx, r.db, userID, definition.PrimaryBadgeResourceID)
if err != nil {
return domain.UserAchievement{}, err
}
if hasBadge {
unlocked = true
progressTotal = targetTotal
}
}
userStatus := domain.ProgressStatusInProgress
if completed {
userStatus = domain.ProgressStatusCompleted
@ -1018,6 +1073,8 @@ func achievementDefinitionAdminWhere(ctx context.Context, query domain.ListQuery
if query.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, query.Status)
} else {
conditions = append(conditions, "status <> 'archived'")
}
if query.CollectionType != "" {
conditions = append(conditions, "collection_type = ?")

View File

@ -249,7 +249,8 @@ func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEv
rewardJobCount = count
}
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
return domain.EventResult{}, err
}
if err := tx.Commit(); err != nil {
@ -353,7 +354,8 @@ func (r *Repository) SetUserLevel(ctx context.Context, command domain.SetUserLev
return domain.SetUserLevelResult{}, err
}
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
return domain.SetUserLevelResult{}, err
}
if err := tx.Commit(); err != nil {
@ -575,6 +577,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
}
tier := tierByID(tiers, account.CurrentTierID)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, account.CurrentLevel, tier.DisplayAvatarFrameResourceID)
currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel)
pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
if err != nil {
@ -589,7 +592,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
CurrentLevelRequiredValue: currentRequired,
NextLevel: nextLevel,
NextLevelRequiredValue: nextRequired,
DisplayAvatarFrameResourceID: tier.DisplayAvatarFrameResourceID,
DisplayAvatarFrameResourceID: avatarFrameResourceID,
DisplayBadgeResourceID: badgeResourceID,
DisplayBadgeSourceLevel: badgeSourceLevel,
RewardPendingCount: pendingCount,
@ -663,7 +666,9 @@ func (r *Repository) insertLevelHistory(ctx context.Context, tx *sql.Tx, event d
func (r *Repository) createLevelRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, previousLevel int32, nextLevel int32, previousTierID int64, nextTier domain.Tier, rules []domain.Rule, nowMS int64) (int64, error) {
created := int64(0)
for _, rule := range rules {
if rule.Level <= previousLevel || rule.Level > nextLevel || rule.RewardResourceGroupID <= 0 {
avatarFrameResourceID := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
shortBadgeResourceID := shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
if rule.Level <= previousLevel || rule.Level > nextLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceID <= 0 && shortBadgeResourceID <= 0) {
continue
}
changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.RewardResourceGroupID, nowMS)
@ -703,7 +708,7 @@ func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userI
return affected > 0, err
}
func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, avatarFrameResourceID int64, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
columnPrefix := ""
switch track {
case domain.TrackWealth:
@ -729,7 +734,7 @@ func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
)
_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, tier.DisplayAvatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
return err
}
@ -1029,12 +1034,48 @@ func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int
return badgeResourceID, badgeSourceLevel
}
func levelRuleAvatarFrameForLevel(rules []domain.Rule, currentLevel int32, fallbackResourceID int64) int64 {
if currentLevel < 0 {
return fallbackResourceID
}
avatarFrameResourceID := int64(0)
sourceLevel := int32(0)
for _, rule := range rules {
if rule.Level > currentLevel || rule.Level < sourceLevel {
continue
}
candidate := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
if candidate <= 0 {
continue
}
// 等级头像框按当前等级以内最高配置覆盖;没有每级配置时保留等级段头像框兼容旧展示。
avatarFrameResourceID = candidate
sourceLevel = rule.Level
}
if avatarFrameResourceID > 0 {
return avatarFrameResourceID
}
return fallbackResourceID
}
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["long_badge_resource_id"]
value, ok := payload[key]
if !ok {
return 0
}

View File

@ -157,6 +157,15 @@ func (s *AdminAchievementServer) UpsertAchievementDefinition(ctx context.Context
return &activityv1.UpsertAchievementDefinitionResponse{Achievement: achievementDefinitionToProto(item), Created: created}, nil
}
func (s *AdminAchievementServer) DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, err := s.svc.DeleteAchievementDefinition(ctx, req.GetAchievementId(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.DeleteAchievementDefinitionResponse{Achievement: achievementDefinitionToProto(item)}, nil
}
func userAchievementToProto(item domain.UserAchievement) *activityv1.UserAchievement {
return &activityv1.UserAchievement{
Definition: achievementDefinitionToProto(item.Definition),

View File

@ -20,4 +20,7 @@ func TestGrantDefaultEquippedResourceTypes(t *testing.T) {
t.Fatalf("%s resources must auto equip inside resource grant transactions", resourceType)
}
}
if isAutoEquippedOnGrantResourceType(resourcedomain.TypeAvatarFrame) {
t.Fatalf("avatar_frame grants must not auto equip; level frames are display projection rewards, not user equipment changes")
}
}