成就,财富魅力等级

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; bool created = 2;
} }
message DeleteAchievementDefinitionRequest {
RequestMeta meta = 1;
string achievement_id = 2;
int64 operator_admin_id = 3;
}
message DeleteAchievementDefinitionResponse {
AchievementDefinition achievement = 1;
}
message LuckyGiftMeta { message LuckyGiftMeta {
RequestMeta meta = 1; RequestMeta meta = 1;
string command_id = 2; string command_id = 2;
@ -2743,6 +2753,7 @@ service AdminGrowthLevelService {
service AdminAchievementService { service AdminAchievementService {
rpc ListAchievementDefinitions(ListAchievementsRequest) returns (ListAchievementsResponse); rpc ListAchievementDefinitions(ListAchievementsRequest) returns (ListAchievementsResponse);
rpc UpsertAchievementDefinition(UpsertAchievementDefinitionRequest) returns (UpsertAchievementDefinitionResponse); rpc UpsertAchievementDefinition(UpsertAchievementDefinitionRequest) returns (UpsertAchievementDefinitionResponse);
rpc DeleteAchievementDefinition(DeleteAchievementDefinitionRequest) returns (DeleteAchievementDefinitionResponse);
} }
// AdminLuckyGiftService is the admin entry for lucky gift rule versions and draw audit. // 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 ( const (
AdminAchievementService_ListAchievementDefinitions_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/ListAchievementDefinitions" AdminAchievementService_ListAchievementDefinitions_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/ListAchievementDefinitions"
AdminAchievementService_UpsertAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/UpsertAchievementDefinition" AdminAchievementService_UpsertAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/UpsertAchievementDefinition"
AdminAchievementService_DeleteAchievementDefinition_FullMethodName = "/hyapp.activity.v1.AdminAchievementService/DeleteAchievementDefinition"
) )
// AdminAchievementServiceClient is the client API for AdminAchievementService service. // AdminAchievementServiceClient is the client API for AdminAchievementService service.
@ -6185,6 +6186,7 @@ const (
type AdminAchievementServiceClient interface { type AdminAchievementServiceClient interface {
ListAchievementDefinitions(ctx context.Context, in *ListAchievementsRequest, opts ...grpc.CallOption) (*ListAchievementsResponse, error) ListAchievementDefinitions(ctx context.Context, in *ListAchievementsRequest, opts ...grpc.CallOption) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, in *UpsertAchievementDefinitionRequest, opts ...grpc.CallOption) (*UpsertAchievementDefinitionResponse, 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 { type adminAchievementServiceClient struct {
@ -6215,6 +6217,16 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
return out, nil 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. // AdminAchievementServiceServer is the server API for AdminAchievementService service.
// All implementations must embed UnimplementedAdminAchievementServiceServer // All implementations must embed UnimplementedAdminAchievementServiceServer
// for forward compatibility. // for forward compatibility.
@ -6223,6 +6235,7 @@ func (c *adminAchievementServiceClient) UpsertAchievementDefinition(ctx context.
type AdminAchievementServiceServer interface { type AdminAchievementServiceServer interface {
ListAchievementDefinitions(context.Context, *ListAchievementsRequest) (*ListAchievementsResponse, error) ListAchievementDefinitions(context.Context, *ListAchievementsRequest) (*ListAchievementsResponse, error)
UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error) UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error)
DeleteAchievementDefinition(context.Context, *DeleteAchievementDefinitionRequest) (*DeleteAchievementDefinitionResponse, error)
mustEmbedUnimplementedAdminAchievementServiceServer() mustEmbedUnimplementedAdminAchievementServiceServer()
} }
@ -6239,6 +6252,9 @@ func (UnimplementedAdminAchievementServiceServer) ListAchievementDefinitions(con
func (UnimplementedAdminAchievementServiceServer) UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error) { func (UnimplementedAdminAchievementServiceServer) UpsertAchievementDefinition(context.Context, *UpsertAchievementDefinitionRequest) (*UpsertAchievementDefinitionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertAchievementDefinition not implemented") 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) mustEmbedUnimplementedAdminAchievementServiceServer() {
} }
func (UnimplementedAdminAchievementServiceServer) testEmbeddedByValue() {} func (UnimplementedAdminAchievementServiceServer) testEmbeddedByValue() {}
@ -6297,6 +6313,24 @@ func _AdminAchievementService_UpsertAchievementDefinition_Handler(srv interface{
return interceptor(ctx, in, info, handler) 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. // AdminAchievementService_ServiceDesc is the grpc.ServiceDesc for AdminAchievementService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -6312,6 +6346,10 @@ var AdminAchievementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertAchievementDefinition", MethodName: "UpsertAchievementDefinition",
Handler: _AdminAchievementService_UpsertAchievementDefinition_Handler, Handler: _AdminAchievementService_UpsertAchievementDefinition_Handler,
}, },
{
MethodName: "DeleteAchievementDefinition",
Handler: _AdminAchievementService_DeleteAchievementDefinition_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto", 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) SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error) ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error)
UpsertAchievementDefinition(ctx context.Context, req *activityv1.UpsertAchievementDefinitionRequest) (*activityv1.UpsertAchievementDefinitionResponse, 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) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error) UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, 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) 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) { func (c *GRPCClient) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) {
return c.registrationRewardClient.GetRegistrationRewardConfig(ctx, req) return c.registrationRewardClient.GetRegistrationRewardConfig(ctx, req)
} }

View File

@ -142,6 +142,27 @@ func (h *Handler) UpdateAchievementDefinition(c *gin.Context) {
response.OK(c, item) 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 { func (r achievementRequest) toProto(c *gin.Context, achievementID string, meta *activityv1.RequestMeta) *activityv1.UpsertAchievementDefinitionRequest {
conditions := make([]*activityv1.AchievementCondition, 0, len(r.Conditions)) conditions := make([]*activityv1.AchievementCondition, 0, len(r.Conditions))
for _, condition := range 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.GET("/admin/activity/achievements", middleware.RequirePermission("achievement:view"), h.ListAchievementDefinitions)
protected.POST("/admin/activity/achievements", middleware.RequirePermission("achievement:create"), h.CreateAchievementDefinition) 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.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, Page: options.Page,
PageSize: options.PageSize, PageSize: options.PageSize,
UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")), UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")),
BizType: firstQuery(c, "biz_type", "bizType"),
StartAtMS: startAtMS, StartAtMS: startAtMS,
EndAtMS: endAtMS, EndAtMS: endAtMS,
}), true }), true

View File

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

View File

@ -671,6 +671,12 @@ func userDTOFromProfile(profile userProfile) coinLedgerUserDTO {
func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) { func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ?" where := "WHERE e.app_code = ? AND e.asset_type = ?"
args := []any{appCode, coinAssetType} 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 { if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?" where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS) args = append(args, query.StartAtMS)
@ -759,6 +765,7 @@ func normalizeListQuery(query listQuery) listQuery {
query.PageSize = 100 query.PageSize = 100
} }
query.UserKeyword = strings.TrimSpace(query.UserKeyword) query.UserKeyword = strings.TrimSpace(query.UserKeyword)
query.BizType = strings.TrimSpace(query.BizType)
return query 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) { func TestCoinAdjustmentWhereLimitsManualCreditCoinEntries(t *testing.T) {
query := listQuery{StartAtMS: 100, EndAtMS: 200} query := listQuery{StartAtMS: 100, EndAtMS: 200}
where, args := coinAdjustmentWhere("lalu", query, []int64{11}) where, args := coinAdjustmentWhere("lalu", query, []int64{11})

View File

@ -24,7 +24,9 @@ const (
statusActive = "active" statusActive = "active"
statusDisabled = "disabled" 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 { type Handler struct {
@ -63,6 +65,8 @@ type ruleDTO struct {
RewardResourceGroupID int64 `json:"reward_resource_group_id"` RewardResourceGroupID int64 `json:"reward_resource_group_id"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
LongBadgeResourceID int64 `json:"long_badge_resource_id"` 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"` DisplayConfigJSON string `json:"display_config_json"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"`
@ -101,6 +105,8 @@ type ruleRequest struct {
RewardResourceGroupID *int64 `json:"reward_resource_group_id"` RewardResourceGroupID *int64 `json:"reward_resource_group_id"`
SortOrder *int32 `json:"sort_order"` SortOrder *int32 `json:"sort_order"`
LongBadgeResourceID *int64 `json:"long_badge_resource_id"` 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"` DisplayConfigJSON *string `json:"display_config_json"`
} }
@ -277,7 +283,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
if req.DisplayConfigJSON != nil { if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON) displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
} }
displayConfig, err = mergeLongBadgeResourceID(displayConfig, req.LongBadgeResourceID) displayConfig, err = mergeRuleDisplayResources(displayConfig, req.LongBadgeResourceID, req.AvatarFrameResourceID, req.ShortBadgeResourceID)
if err != nil { if err != nil {
response.BadRequest(c, "展示配置 JSON 不正确") response.BadRequest(c, "展示配置 JSON 不正确")
return return
@ -288,6 +294,18 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return 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{ resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{
Meta: h.meta(c), Meta: h.meta(c),
Track: track, Track: track,
@ -305,7 +323,7 @@ func (h *Handler) UpsertRule(c *gin.Context) {
return return
} }
item := ruleFromProto(resp.GetRule()) 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) response.OK(c, item)
} }
@ -494,6 +512,67 @@ func (h *Handler) validateLevelLongBadgeResource(c *gin.Context, track string, r
return nil 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) { func (h *Handler) currentTrack(c *gin.Context, track string) (trackDTO, bool, error) {
config, err := h.loadConfig(c, track, "") config, err := h.loadConfig(c, track, "")
if err != nil { if err != nil {
@ -577,6 +656,8 @@ func ruleFromProto(item *activityv1.LevelRule) ruleDTO {
RewardResourceGroupID: item.GetRewardResourceGroupId(), RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
LongBadgeResourceID: longBadgeResourceID(item.GetDisplayConfigJson()), LongBadgeResourceID: longBadgeResourceID(item.GetDisplayConfigJson()),
AvatarFrameResourceID: avatarFrameResourceID(item.GetDisplayConfigJson()),
ShortBadgeResourceID: shortBadgeResourceID(item.GetDisplayConfigJson()),
DisplayConfigJSON: item.GetDisplayConfigJson(), DisplayConfigJSON: item.GetDisplayConfigJson(),
CreatedByAdminID: item.GetCreatedByAdminId(), CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(), UpdatedByAdminID: item.GetUpdatedByAdminId(),
@ -634,17 +715,31 @@ func findTier(items []*activityv1.LevelTier, tierID int64) (*activityv1.LevelTie
return nil, false 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) payload, err := objectFromJSON(raw)
if err != nil { if err != nil {
return "", err return "", err
} }
if value != nil { if longBadgeResourceID != nil {
if *value < 0 { if *longBadgeResourceID < 0 {
return "", fmt.Errorf("long badge resource id is invalid") return "", fmt.Errorf("long badge resource id is invalid")
} }
// 长徽章是每一级展示素材;沿用 display_config_json 可避免为展示扩展反复改表。 // 长徽章是每一级展示素材;沿用 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) data, err := json.Marshal(payload)
if err != nil { if err != nil {
@ -683,11 +778,23 @@ func objectFromJSON(raw string) (map[string]any, error) {
} }
func longBadgeResourceID(raw string) int64 { 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) payload, err := objectFromJSON(raw)
if err != nil { if err != nil {
return 0 return 0
} }
value, ok := payload[longBadgeResourceIDKey] value, ok := payload[key]
if !ok { if !ok {
return 0 return 0
} }
@ -716,6 +823,11 @@ type levelBadgeMetadata struct {
LevelTrack string `json:"level_track"` LevelTrack string `json:"level_track"`
} }
type levelAvatarFrameMetadata struct {
AvatarFrameKind string `json:"avatar_frame_kind"`
LevelTrack string `json:"level_track"`
}
func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata { func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
payload := levelBadgeMetadata{} payload := levelBadgeMetadata{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
@ -727,6 +839,16 @@ func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata {
return payload 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) { func parseNonNegativeInt32(raw string) (int32, bool) {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32) value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
if err != nil || value < 0 { if err != nil || value < 0 {

View File

@ -6,15 +6,23 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
) )
func TestMergeLongBadgeResourceIDPreservesDisplayConfig(t *testing.T) { func TestMergeRuleDisplayResourcesPreservesDisplayConfig(t *testing.T) {
badgeID := int64(9001) 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 { if err != nil {
t.Fatalf("mergeLongBadgeResourceID failed: %v", err) t.Fatalf("mergeRuleDisplayResources failed: %v", err)
} }
if got := longBadgeResourceID(raw); got != badgeID { if got := longBadgeResourceID(raw); got != badgeID {
t.Fatalf("long badge id mismatch: got=%d raw=%s", got, raw) 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) config, err := objectFromJSON(raw)
if err != nil { if err != nil {
t.Fatalf("objectFromJSON failed: %v", err) t.Fatalf("objectFromJSON failed: %v", err)
@ -31,7 +39,7 @@ func TestConfigFromProtoGroupsRulesAndTiers(t *testing.T) {
Track: "wealth", Track: "wealth",
Level: 3, Level: 3,
RequiredValue: 300, 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{{ Tiers: []*activityv1.LevelTier{{
TierId: 11, 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 { 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) 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 { if len(dto.Tracks[0].Tiers) != 1 || dto.Tracks[0].Tiers[0].DisplayBadgeResourceID != 9001 {
t.Fatalf("tier grouping mismatch: %+v", dto.Tracks[0].Tiers) t.Fatalf("tier grouping mismatch: %+v", dto.Tracks[0].Tiers)
} }

View File

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

View File

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

View File

@ -94,7 +94,7 @@ func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
} }
func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) { func TestBadgeResourceMetadataUsesSelectedForm(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "") metadata := resourceMetadataJSON(resourceTypeBadge, "long", "", "", "", "")
payload := badgeMetadataPayload{} payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err) 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" { if payload.BadgeForm != badgeFormStrip || payload.BadgeKind != badgeKindNormal || payload.DefaultSlot != "profile_strip" {
t.Fatalf("long badge metadata mismatch: %+v", payload) 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) t.Fatalf("short badge form mismatch: %s", got)
} }
} }
func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) { func TestBadgeResourceMetadataMarksLevelBadge(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth") metadata := resourceMetadataJSON(resourceTypeBadge, badgeFormStrip, "", badgeKindLevel, "wealth", "")
payload := badgeMetadataPayload{} payload := badgeMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("badge metadata json mismatch: %v", err) 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) { func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{ metadata := resourceMetadataJSON(resourceTypeProfileCard, "", `{
"profile_card_layout": { "profile_card_layout": {
@ -132,7 +146,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
"detect_version": 99 "detect_version": 99
}, },
"debug": true "debug": true
}`, "", "") }`, "", "", "")
payload := profileCardMetadataPayload{} payload := profileCardMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card metadata json mismatch: %v", err) t.Fatalf("profile card metadata json mismatch: %v", err)
@ -153,7 +167,7 @@ func TestProfileCardResourceMetadataKeepsLayoutOnly(t *testing.T) {
func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) { func TestResourceMetadataKeepsMP4AlphaLayoutForAnimatedResources(t *testing.T) {
for _, resourceType := range []string{"gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", "mic_seat_animation"} { 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{} payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err) t.Fatalf("%s mp4 metadata json mismatch: %v", resourceType, err)
@ -179,7 +193,7 @@ func TestResourceMetadataKeepsNormalMP4Layout(t *testing.T) {
"confirmed": true, "confirmed": true,
"detect_version": 1 "detect_version": 1
} }
}`, "", "") }`, "", "", "")
payload := resourceMetadataPayload{} payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("normal mp4 metadata json mismatch: %v", err) t.Fatalf("normal mp4 metadata json mismatch: %v", err)
@ -214,7 +228,7 @@ func TestProfileCardResourceMetadataKeepsLayoutAndMP4AlphaLayout(t *testing.T) {
"confirmed": true, "confirmed": true,
"detect_version": 1 "detect_version": 1
} }
}`, "", "") }`, "", "", "")
payload := resourceMetadataPayload{} payload := resourceMetadataPayload{}
if err := json.Unmarshal([]byte(metadata), &payload); err != nil { if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("profile card mp4 metadata json mismatch: %v", err) t.Fatalf("profile card mp4 metadata json mismatch: %v", err)
@ -285,7 +299,7 @@ func TestProfileCardResourceMetadataRejectsInvalidLayout(t *testing.T) {
"content_bottom": 1666, "content_bottom": 1666,
"content_height": 1 "content_height": 1
} }
}`, "", "") }`, "", "", "")
if metadata != "{}" { if metadata != "{}" {
t.Fatalf("invalid profile card layout should be dropped: %s", 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 { func mp4AlphaLayoutMetadataJSON(alphaLayout string, confirmed bool) string {
return `{ return `{
"animation_format": "mp4", "animation_format": "mp4",

View File

@ -19,6 +19,7 @@ const dayMillis int64 = 24 * 60 * 60 * 1000
const ( const (
resourceTypeCoin = "coin" resourceTypeCoin = "coin"
resourceTypeAvatarFrame = "avatar_frame"
resourceTypeBadge = "badge" resourceTypeBadge = "badge"
resourceTypeEmojiPack = "emoji_pack" resourceTypeEmojiPack = "emoji_pack"
resourceTypeProfileCard = "profile_card" resourceTypeProfileCard = "profile_card"
@ -53,6 +54,11 @@ const (
badgeKindLevel = "level" badgeKindLevel = "level"
) )
const (
avatarFrameKindNormal = "normal"
avatarFrameKindLevel = "level"
)
const ( const (
defaultEmojiPackCategory = "默认" defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free" emojiPackPricingFree = "free"
@ -67,6 +73,7 @@ type resourceRequest struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
PriceType string `json:"priceType"` PriceType string `json:"priceType"`
CoinPrice int64 `json:"coinPrice"` CoinPrice int64 `json:"coinPrice"`
AvatarFrameKind string `json:"avatarFrameKind"`
BadgeForm string `json:"badgeForm"` BadgeForm string `json:"badgeForm"`
BadgeKind string `json:"badgeKind"` BadgeKind string `json:"badgeKind"`
LevelTrack string `json:"levelTrack"` LevelTrack string `json:"levelTrack"`
@ -113,6 +120,8 @@ type badgeMetadataPayload struct {
type resourceMetadataPayload struct { type resourceMetadataPayload struct {
AnimationFormat string `json:"animation_format,omitempty"` 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"` MP4AlphaLayout *mp4AlphaLayoutMetadataPayload `json:"mp4_alpha_layout,omitempty"`
ProfileCardLayout *profileCardLayoutMetadataPayload `json:"profile_card_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) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) 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{ return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -304,7 +313,7 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
resourceType := normalizeResourceType(r.ResourceType) resourceType := normalizeResourceType(r.ResourceType)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) 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{ return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
@ -599,6 +608,20 @@ func validateResourceBadgeForm(req resourceRequest) error {
return nil 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 { func validateResourceMetadata(req resourceRequest) error {
resourceType := normalizeResourceType(req.ResourceType) resourceType := normalizeResourceType(req.ResourceType)
payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON) payload, fields, err := parseResourceMetadataPayload(req.MetadataJSON)
@ -636,7 +659,7 @@ func resourceGrantStrategy(resourceType string) string {
switch resourceType { switch resourceType {
case resourceTypeCoin: case resourceTypeCoin:
return "wallet_credit" return "wallet_credit"
case "avatar_frame", "profile_card", "vehicle", "chat_bubble": case resourceTypeAvatarFrame, "profile_card", "vehicle", "chat_bubble":
return "extend_expiry" return "extend_expiry"
case "badge": case "badge":
return "set_active_flag" 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 { if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack) return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
} }
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON) rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil || rawPayload == nil { if err != nil {
return "{}" return "{}"
} }
if rawPayload == nil {
rawPayload = &resourceMetadataPayload{}
}
payload := resourceMetadataPayload{} payload := resourceMetadataPayload{}
if resourceType == resourceTypeProfileCard { if resourceType == resourceTypeProfileCard {
payload.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(rawPayload.ProfileCardLayout) 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 resourceAllowsAnimationMetadata(resourceType) {
if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed { if layout, err := sanitizeMP4AlphaLayoutMetadata(rawPayload.MP4AlphaLayout); err == nil && layout != nil && layout.Confirmed {
payload.AnimationFormat = resourceAnimationFormatMP4 payload.AnimationFormat = resourceAnimationFormatMP4
@ -728,7 +762,7 @@ func parseResourceMetadataPayload(metadataJSON string) (*resourceMetadataPayload
} }
func marshalResourceMetadataPayload(payload resourceMetadataPayload) string { 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 "{}" return "{}"
} }
body, err := json.Marshal(payload) 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 { func normalizeLevelTrack(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
if validLevelTrack(value) { if validLevelTrack(value) {
@ -935,6 +980,26 @@ func badgeLevelTrackFromMetadata(metadataJSON string) string {
return normalizeLevelTrack(payload.LevelTrack) 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 { func badgeFormForResource(resourceType string, metadataJSON string) string {
if normalizeResourceType(resourceType) != resourceTypeBadge { if normalizeResourceType(resourceType) != resourceTypeBadge {
return "" return ""
@ -960,6 +1025,31 @@ func badgeLevelTrackForResource(resourceType string, metadataJSON string) string
return badgeLevelTrackFromMetadata(metadataJSON) 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) { func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs) normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := emojiPackMetadataPayload{ metadata := emojiPackMetadataPayload{

View File

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

View File

@ -24,6 +24,7 @@ type Repository interface {
ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error) ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error)
SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, 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) 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) 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 MarkAchievementRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error
MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, 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 { if err := s.requireRepository(); err != nil {
return nil, 0, err 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.CollectionType = normalizeCollectionType(query.CollectionType)
query.CollectionID = strings.TrimSpace(query.CollectionID) query.CollectionID = strings.TrimSpace(query.CollectionID)
query.Status = normalizeDefinitionStatusFilter(query.Status) 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()) 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) { 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 { if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err return 0, 0, 0, 0, false, err
@ -278,15 +291,16 @@ func validateDefinitionCommand(command domain.DefinitionCommand) error {
if !validDefinitionStatus(command.Status) || !validAchievementType(command.AchievementType) { if !validDefinitionStatus(command.Status) || !validAchievementType(command.AchievementType) {
return xerr.New(xerr.InvalidArgument, "achievement definition status or type is invalid") 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") return xerr.New(xerr.InvalidArgument, "achievement reward resource is invalid")
} }
if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS { if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
return xerr.New(xerr.InvalidArgument, "achievement effective time is invalid") 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 { for _, condition := range command.Conditions {
if condition.MetricType == "" || condition.TargetValue <= 0 { if condition.MetricType == "" || condition.TargetValue <= 0 {
return xerr.New(xerr.InvalidArgument, "achievement condition is invalid") 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) { func newAchievementService(t *testing.T) (*achievementservice.Service, *fakeAchievementWallet) {
t.Helper() t.Helper()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -19,6 +19,11 @@ import (
const levelRewardReason = "growth_level_reward" const levelRewardReason = "growth_level_reward"
const (
levelAvatarFrameRewardDays = int64(9999)
levelAvatarFrameRewardDurationMS = levelAvatarFrameRewardDays * 24 * 60 * 60 * 1000
)
const ( const (
registrationLevelBadgeReason = "growth_level_registration_badge" registrationLevelBadgeReason = "growth_level_registration_badge"
registrationLevelBadgeSourceLevel = int32(1) registrationLevelBadgeSourceLevel = int32(1)
@ -381,25 +386,13 @@ func (s *Service) ProcessLevelRewardBatch(ctx context.Context, runID string, wor
} }
for _, job := range jobs { for _, job := range jobs {
processed++ processed++
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{ walletGrantID, grantErr := s.grantLevelRewardJob(ctx, job)
CommandId: job.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: job.UserID,
GroupId: job.ResourceGroupID,
Reason: levelRewardReason,
OperatorUserId: job.UserID,
GrantSource: domain.GrantSourceGrowthLevel,
})
if grantErr != nil { if grantErr != nil {
failure++ failure++
nextRetry := s.now().Add(time.Minute).UnixMilli() nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli()) _ = s.repository.MarkLevelRewardFailed(ctx, job.RewardJobID, xerr.MessageOf(grantErr), nextRetry, s.now().UnixMilli())
continue continue
} }
walletGrantID := ""
if resp.GetGrant() != nil {
walletGrantID = resp.GetGrant().GetGrantId()
}
if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil { if markErr := s.repository.MarkLevelRewardGranted(ctx, job.RewardJobID, walletGrantID, s.now().UnixMilli()); markErr != nil {
failure++ failure++
continue 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 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) { func roomGiftDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
payload := map[string]any{ payload := map[string]any{
"room_id": envelope.GetRoomId(), "room_id": envelope.GetRoomId(),
@ -478,11 +566,23 @@ func normalizeJSON(value string) string {
} }
func longBadgeResourceIDFromDisplayConfig(raw string) int64 { 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{} payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0 return 0
} }
value, ok := payload["long_badge_resource_id"] value, ok := payload[key]
if !ok { if !ok {
return 0 return 0
} }
@ -500,6 +600,18 @@ func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
return 0 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 { func validTrack(value string) bool {
switch value { switch value {
case domain.TrackWealth, domain.TrackGame, domain.TrackCharm: 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) { func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) {
svc, _ := newGrowthService(t) svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu") 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 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) { 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 { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") 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 { for _, condition := range definition.Conditions {
targetTotal += condition.TargetValue targetTotal += condition.TargetValue
} }
if targetTotal == 0 && definition.PrimaryBadgeResourceID > 0 {
targetTotal = 1
}
progressTotal := int64(0) progressTotal := int64(0)
completed := len(definition.Conditions) > 0 completed := len(definition.Conditions) > 0
for _, condition := range definition.Conditions { for _, condition := range definition.Conditions {
@ -522,6 +566,17 @@ func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID i
if err != nil { if err != nil {
return domain.UserAchievement{}, err 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 userStatus := domain.ProgressStatusInProgress
if completed { if completed {
userStatus = domain.ProgressStatusCompleted userStatus = domain.ProgressStatusCompleted
@ -1018,6 +1073,8 @@ func achievementDefinitionAdminWhere(ctx context.Context, query domain.ListQuery
if query.Status != "" { if query.Status != "" {
conditions = append(conditions, "status = ?") conditions = append(conditions, "status = ?")
args = append(args, query.Status) args = append(args, query.Status)
} else {
conditions = append(conditions, "status <> 'archived'")
} }
if query.CollectionType != "" { if query.CollectionType != "" {
conditions = append(conditions, "collection_type = ?") conditions = append(conditions, "collection_type = ?")

View File

@ -249,7 +249,8 @@ func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEv
rewardJobCount = count rewardJobCount = count
} }
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel) 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 return domain.EventResult{}, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@ -353,7 +354,8 @@ func (r *Repository) SetUserLevel(ctx context.Context, command domain.SetUserLev
return domain.SetUserLevelResult{}, err return domain.SetUserLevelResult{}, err
} }
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel) 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 return domain.SetUserLevelResult{}, err
} }
if err := tx.Commit(); err != nil { 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) tier := tierByID(tiers, account.CurrentTierID)
badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel) badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, account.CurrentLevel)
avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, account.CurrentLevel, tier.DisplayAvatarFrameResourceID)
currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel) currentRequired, nextLevel, nextRequired := levelProgress(rules, account.CurrentLevel)
pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track) pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
if err != nil { if err != nil {
@ -589,7 +592,7 @@ func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track
CurrentLevelRequiredValue: currentRequired, CurrentLevelRequiredValue: currentRequired,
NextLevel: nextLevel, NextLevel: nextLevel,
NextLevelRequiredValue: nextRequired, NextLevelRequiredValue: nextRequired,
DisplayAvatarFrameResourceID: tier.DisplayAvatarFrameResourceID, DisplayAvatarFrameResourceID: avatarFrameResourceID,
DisplayBadgeResourceID: badgeResourceID, DisplayBadgeResourceID: badgeResourceID,
DisplayBadgeSourceLevel: badgeSourceLevel, DisplayBadgeSourceLevel: badgeSourceLevel,
RewardPendingCount: pendingCount, 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) { 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) created := int64(0)
for _, rule := range rules { 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 continue
} }
changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.RewardResourceGroupID, nowMS) 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 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 := "" columnPrefix := ""
switch track { switch track {
case domain.TrackWealth: 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, 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 return err
} }
@ -1029,12 +1034,48 @@ func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int
return badgeResourceID, badgeSourceLevel 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 { 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{} payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return 0 return 0
} }
value, ok := payload["long_badge_resource_id"] value, ok := payload[key]
if !ok { if !ok {
return 0 return 0
} }

View File

@ -157,6 +157,15 @@ func (s *AdminAchievementServer) UpsertAchievementDefinition(ctx context.Context
return &activityv1.UpsertAchievementDefinitionResponse{Achievement: achievementDefinitionToProto(item), Created: created}, nil 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 { func userAchievementToProto(item domain.UserAchievement) *activityv1.UserAchievement {
return &activityv1.UserAchievement{ return &activityv1.UserAchievement{
Definition: achievementDefinitionToProto(item.Definition), 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) 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")
}
} }