成就,财富魅力等级
This commit is contained in:
parent
538668014b
commit
fe736780e0
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
UserKeyword string
|
||||
BizType string
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -5,6 +5,7 @@ const (
|
||||
TypeActivityLimited = "activity_limited"
|
||||
|
||||
CollectionOrdinary = "ordinary"
|
||||
CollectionEvent = "event"
|
||||
CycleLifetime = "lifetime"
|
||||
|
||||
StatusDraft = "draft"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 = ?")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user