package levelconfig import ( "bytes" "encoding/json" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/gin-gonic/gin" ) const ( statusActive = "active" statusDisabled = "disabled" longBadgeResourceIDKey = "long_badge_resource_id" ) type Handler struct { activity activityclient.Client wallet walletclient.Client audit shared.OperationLogger } func New(activity activityclient.Client, wallet walletclient.Client, audit shared.OperationLogger) *Handler { return &Handler{activity: activity, wallet: wallet, audit: audit} } type levelConfigDTO struct { Tracks []trackDTO `json:"tracks"` ServerTimeMS int64 `json:"server_time_ms"` } type trackDTO struct { Track string `json:"track"` Name string `json:"name"` Status string `json:"status"` SortOrder int32 `json:"sort_order"` DisplayConfigJSON string `json:"display_config_json"` Rules []ruleDTO `json:"rules"` Tiers []tierDTO `json:"tiers"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type ruleDTO struct { Track string `json:"track"` Level int32 `json:"level"` RequiredValue int64 `json:"required_value"` Name string `json:"name"` Status string `json:"status"` RewardResourceGroupID int64 `json:"reward_resource_group_id"` SortOrder int32 `json:"sort_order"` LongBadgeResourceID int64 `json:"long_badge_resource_id"` DisplayConfigJSON string `json:"display_config_json"` CreatedByAdminID int64 `json:"created_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type tierDTO struct { TierID int64 `json:"tier_id"` Track string `json:"track"` MinLevel int32 `json:"min_level"` MaxLevel int32 `json:"max_level"` Name string `json:"name"` DisplayAvatarFrameResourceID int64 `json:"display_avatar_frame_resource_id"` DisplayBadgeResourceID int64 `json:"display_badge_resource_id"` RewardResourceGroupID int64 `json:"reward_resource_group_id"` Status string `json:"status"` DisplayConfigJSON string `json:"display_config_json"` CreatedByAdminID int64 `json:"created_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type trackRequest struct { Name *string `json:"name"` Status *string `json:"status"` SortOrder *int32 `json:"sort_order"` DisplayConfigJSON *string `json:"display_config_json"` } type ruleRequest struct { RequiredValue *int64 `json:"required_value"` Name *string `json:"name"` Status *string `json:"status"` RewardResourceGroupID *int64 `json:"reward_resource_group_id"` SortOrder *int32 `json:"sort_order"` LongBadgeResourceID *int64 `json:"long_badge_resource_id"` DisplayConfigJSON *string `json:"display_config_json"` } type tierRequest struct { Track *string `json:"track"` MinLevel *int32 `json:"min_level"` MaxLevel *int32 `json:"max_level"` Name *string `json:"name"` DisplayAvatarFrameResourceID *int64 `json:"display_avatar_frame_resource_id"` DisplayBadgeResourceID *int64 `json:"display_badge_resource_id"` RewardResourceGroupID *int64 `json:"reward_resource_group_id"` Status *string `json:"status"` DisplayConfigJSON *string `json:"display_config_json"` } // ListLevelConfig 返回用户管理 > 等级配置页面需要的三条等级轨道完整配置。 func (h *Handler) ListLevelConfig(c *gin.Context) { track := normalizeTrack(c.Query("track")) status := normalizeStatusFilter(c.Query("status")) if track != "" && !validTrack(track) { response.BadRequest(c, "等级类型不正确") return } if status != "" && !validStatus(status) { response.BadRequest(c, "状态参数不正确") return } resp, err := h.activity.ListLevelConfig(c.Request.Context(), &activityv1.ListLevelConfigRequest{ Meta: h.meta(c), Track: track, Status: status, }) if err != nil { response.ServerError(c, "获取等级配置失败") return } response.OK(c, configFromProto(resp)) } // UpdateTrack 更新 wealth/game/charm 三条固定等级轨道的基础展示配置。 func (h *Handler) UpdateTrack(c *gin.Context) { track := normalizeTrack(c.Param("track")) if !validTrack(track) { response.BadRequest(c, "等级类型不正确") return } var req trackRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "等级轨道参数不正确") return } current, ok, err := h.currentTrack(c, track) if err != nil { response.ServerError(c, "读取等级轨道失败") return } name := current.Name if req.Name != nil { name = strings.TrimSpace(*req.Name) } if name == "" { name = defaultTrackName(track) } status := current.Status if req.Status != nil { status = normalizeStatus(*req.Status) } if status == "" { status = statusActive } if !validStatus(status) { response.BadRequest(c, "状态参数不正确") return } sortOrder := current.SortOrder if !ok { sortOrder = defaultTrackSort(track) } if req.SortOrder != nil { sortOrder = *req.SortOrder } displayConfig := current.DisplayConfigJSON if req.DisplayConfigJSON != nil { displayConfig = strings.TrimSpace(*req.DisplayConfigJSON) } displayConfig, err = normalizeJSONObject(displayConfig) if err != nil { response.BadRequest(c, "展示配置 JSON 不正确") return } resp, err := h.activity.UpsertLevelTrack(c.Request.Context(), &activityv1.UpsertLevelTrackRequest{ Meta: h.meta(c), Track: track, Name: name, Status: status, SortOrder: sortOrder, DisplayConfigJson: displayConfig, }) if err != nil { response.BadRequest(c, err.Error()) return } item := trackFromProto(resp.GetTrack(), nil, nil) h.auditLog(c, "update-level-track", "growth_level_tracks", track, fmt.Sprintf("status=%s", item.Status)) response.OK(c, item) } // UpsertRule 配置某条轨道某一级的升级阈值、等级奖励资源组和每级长徽章素材。 func (h *Handler) UpsertRule(c *gin.Context) { track := normalizeTrack(c.Param("track")) level, ok := parseNonNegativeInt32(c.Param("level")) if !ok || !validTrack(track) { response.BadRequest(c, "等级参数不正确") return } var req ruleRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "等级规则参数不正确") return } config, err := h.loadConfig(c, track, "") if err != nil { response.ServerError(c, "读取等级配置失败") return } current, exists := findRule(config.GetRules(), track, level) trackName := defaultTrackName(track) if currentTrack, found := findTrack(config.GetTracks(), track); found { trackName = currentTrack.GetName() } requiredValue := current.GetRequiredValue() if req.RequiredValue != nil { requiredValue = *req.RequiredValue } if requiredValue < 0 { response.BadRequest(c, "升级所需数值不能小于 0") return } name := current.GetName() if req.Name != nil { name = strings.TrimSpace(*req.Name) } if name == "" { name = fmt.Sprintf("%s Lv.%d", trackName, level) } status := current.GetStatus() if req.Status != nil { status = normalizeStatus(*req.Status) } if status == "" { status = statusActive } if !validStatus(status) { response.BadRequest(c, "状态参数不正确") return } rewardResourceGroupID := current.GetRewardResourceGroupId() if req.RewardResourceGroupID != nil { rewardResourceGroupID = *req.RewardResourceGroupID } if rewardResourceGroupID < 0 { response.BadRequest(c, "奖励资源组 ID 不正确") return } sortOrder := current.GetSortOrder() if !exists { sortOrder = level } if req.SortOrder != nil { sortOrder = *req.SortOrder } displayConfig := current.GetDisplayConfigJson() if req.DisplayConfigJSON != nil { displayConfig = strings.TrimSpace(*req.DisplayConfigJSON) } displayConfig, err = mergeLongBadgeResourceID(displayConfig, req.LongBadgeResourceID) if err != nil { response.BadRequest(c, "展示配置 JSON 不正确") return } if badgeID := longBadgeResourceID(displayConfig); badgeID > 0 { if err := h.validateLevelLongBadgeResource(c, track, badgeID); err != nil { response.BadRequest(c, err.Error()) return } } resp, err := h.activity.UpsertLevelRule(c.Request.Context(), &activityv1.UpsertLevelRuleRequest{ Meta: h.meta(c), Track: track, Level: level, RequiredValue: requiredValue, Name: name, Status: status, RewardResourceGroupId: rewardResourceGroupID, SortOrder: sortOrder, DisplayConfigJson: displayConfig, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) 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)) response.OK(c, item) } func (h *Handler) CreateTier(c *gin.Context) { h.upsertTier(c, 0) } func (h *Handler) UpdateTier(c *gin.Context) { tierID, ok := parsePositiveInt64(c.Param("tier_id")) if !ok { response.BadRequest(c, "等级段 ID 不正确") return } h.upsertTier(c, tierID) } // upsertTier 复用已有等级段配置能力,保持 1-10、11-20 这类展示徽章仍由 activity-service 拥有。 func (h *Handler) upsertTier(c *gin.Context, tierID int64) { var req tierRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "等级段参数不正确") return } var current *activityv1.LevelTier if tierID > 0 { config, err := h.loadConfig(c, "", "") if err != nil { response.ServerError(c, "读取等级段配置失败") return } var found bool current, found = findTier(config.GetTiers(), tierID) if !found { response.NotFound(c, "等级段不存在") return } } track := "" if current != nil { track = current.GetTrack() } if req.Track != nil { track = normalizeTrack(*req.Track) } if !validTrack(track) { response.BadRequest(c, "等级类型不正确") return } minLevel := int32(0) if current != nil { minLevel = current.GetMinLevel() } if req.MinLevel != nil { minLevel = *req.MinLevel } maxLevel := int32(0) if current != nil { maxLevel = current.GetMaxLevel() } if req.MaxLevel != nil { maxLevel = *req.MaxLevel } if minLevel < 0 || maxLevel < minLevel { response.BadRequest(c, "等级段范围不正确") return } name := "" if current != nil { name = current.GetName() } if req.Name != nil { name = strings.TrimSpace(*req.Name) } if name == "" { name = fmt.Sprintf("%s Lv.%d-%d", defaultTrackName(track), minLevel, maxLevel) } status := statusActive if current != nil { status = current.GetStatus() } if req.Status != nil { status = normalizeStatus(*req.Status) } if !validStatus(status) { response.BadRequest(c, "状态参数不正确") return } avatarFrameResourceID := int64(0) if current != nil { avatarFrameResourceID = current.GetDisplayAvatarFrameResourceId() } if req.DisplayAvatarFrameResourceID != nil { avatarFrameResourceID = *req.DisplayAvatarFrameResourceID } badgeResourceID := int64(0) if current != nil { badgeResourceID = current.GetDisplayBadgeResourceId() } if req.DisplayBadgeResourceID != nil { badgeResourceID = *req.DisplayBadgeResourceID } rewardResourceGroupID := int64(0) if current != nil { rewardResourceGroupID = current.GetRewardResourceGroupId() } if req.RewardResourceGroupID != nil { rewardResourceGroupID = *req.RewardResourceGroupID } if avatarFrameResourceID < 0 || badgeResourceID < 0 || rewardResourceGroupID < 0 { response.BadRequest(c, "等级段资源 ID 不正确") return } displayConfig := "{}" if current != nil { displayConfig = current.GetDisplayConfigJson() } if req.DisplayConfigJSON != nil { displayConfig = strings.TrimSpace(*req.DisplayConfigJSON) } var err error displayConfig, err = normalizeJSONObject(displayConfig) if err != nil { response.BadRequest(c, "展示配置 JSON 不正确") return } resp, err := h.activity.UpsertLevelTier(c.Request.Context(), &activityv1.UpsertLevelTierRequest{ Meta: h.meta(c), TierId: tierID, Track: track, MinLevel: minLevel, MaxLevel: maxLevel, Name: name, DisplayAvatarFrameResourceId: avatarFrameResourceID, DisplayBadgeResourceId: badgeResourceID, RewardResourceGroupId: rewardResourceGroupID, Status: status, DisplayConfigJson: displayConfig, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { response.BadRequest(c, err.Error()) return } item := tierFromProto(resp.GetTier()) h.auditLog(c, "upsert-level-tier", "growth_level_tiers", strconv.FormatInt(item.TierID, 10), fmt.Sprintf("%s:%d-%d", item.Track, item.MinLevel, item.MaxLevel)) response.OK(c, item) } func (h *Handler) loadConfig(c *gin.Context, track string, status string) (*activityv1.ListLevelConfigResponse, error) { return h.activity.ListLevelConfig(c.Request.Context(), &activityv1.ListLevelConfigRequest{ Meta: h.meta(c), Track: track, Status: status, }) } func (h *Handler) validateLevelLongBadgeResource(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 != "strip" { 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 { return trackDTO{}, false, err } item, ok := findTrack(config.GetTracks(), track) if !ok { return trackDTO{}, false, nil } return trackFromProto(item, nil, nil), true, nil } func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", AppCode: appctx.FromContext(c.Request.Context()), SentAtMs: time.Now().UnixMilli(), } } func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, detail string) { shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, "success", detail) } func configFromProto(resp *activityv1.ListLevelConfigResponse) levelConfigDTO { if resp == nil { return levelConfigDTO{Tracks: []trackDTO{}} } rulesByTrack := make(map[string][]ruleDTO) for _, rule := range resp.GetRules() { item := ruleFromProto(rule) rulesByTrack[item.Track] = append(rulesByTrack[item.Track], item) } tiersByTrack := make(map[string][]tierDTO) for _, tier := range resp.GetTiers() { item := tierFromProto(tier) tiersByTrack[item.Track] = append(tiersByTrack[item.Track], item) } tracks := make([]trackDTO, 0, len(resp.GetTracks())) for _, track := range resp.GetTracks() { code := track.GetTrack() tracks = append(tracks, trackFromProto(track, rulesByTrack[code], tiersByTrack[code])) } return levelConfigDTO{Tracks: tracks, ServerTimeMS: resp.GetServerTimeMs()} } func trackFromProto(item *activityv1.LevelTrack, rules []ruleDTO, tiers []tierDTO) trackDTO { if item == nil { return trackDTO{Rules: []ruleDTO{}, Tiers: []tierDTO{}} } if rules == nil { rules = []ruleDTO{} } if tiers == nil { tiers = []tierDTO{} } return trackDTO{ Track: item.GetTrack(), Name: item.GetName(), Status: item.GetStatus(), SortOrder: item.GetSortOrder(), DisplayConfigJSON: item.GetDisplayConfigJson(), Rules: rules, Tiers: tiers, CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func ruleFromProto(item *activityv1.LevelRule) ruleDTO { if item == nil { return ruleDTO{} } return ruleDTO{ Track: item.GetTrack(), Level: item.GetLevel(), RequiredValue: item.GetRequiredValue(), Name: item.GetName(), Status: item.GetStatus(), RewardResourceGroupID: item.GetRewardResourceGroupId(), SortOrder: item.GetSortOrder(), LongBadgeResourceID: longBadgeResourceID(item.GetDisplayConfigJson()), DisplayConfigJSON: item.GetDisplayConfigJson(), CreatedByAdminID: item.GetCreatedByAdminId(), UpdatedByAdminID: item.GetUpdatedByAdminId(), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func tierFromProto(item *activityv1.LevelTier) tierDTO { if item == nil { return tierDTO{} } return tierDTO{ TierID: item.GetTierId(), Track: item.GetTrack(), MinLevel: item.GetMinLevel(), MaxLevel: item.GetMaxLevel(), Name: item.GetName(), DisplayAvatarFrameResourceID: item.GetDisplayAvatarFrameResourceId(), DisplayBadgeResourceID: item.GetDisplayBadgeResourceId(), RewardResourceGroupID: item.GetRewardResourceGroupId(), Status: item.GetStatus(), DisplayConfigJSON: item.GetDisplayConfigJson(), CreatedByAdminID: item.GetCreatedByAdminId(), UpdatedByAdminID: item.GetUpdatedByAdminId(), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func findTrack(items []*activityv1.LevelTrack, track string) (*activityv1.LevelTrack, bool) { for _, item := range items { if item.GetTrack() == track { return item, true } } return nil, false } func findRule(items []*activityv1.LevelRule, track string, level int32) (*activityv1.LevelRule, bool) { for _, item := range items { if item.GetTrack() == track && item.GetLevel() == level { return item, true } } return nil, false } func findTier(items []*activityv1.LevelTier, tierID int64) (*activityv1.LevelTier, bool) { for _, item := range items { if item.GetTierId() == tierID { return item, true } } return nil, false } func mergeLongBadgeResourceID(raw string, value *int64) (string, error) { payload, err := objectFromJSON(raw) if err != nil { return "", err } if value != nil { if *value < 0 { return "", fmt.Errorf("long badge resource id is invalid") } // 长徽章是每一级展示素材;沿用 display_config_json 可避免为展示扩展反复改表。 payload[longBadgeResourceIDKey] = *value } data, err := json.Marshal(payload) if err != nil { return "", err } return string(data), nil } func normalizeJSONObject(raw string) (string, error) { payload, err := objectFromJSON(raw) if err != nil { return "", err } data, err := json.Marshal(payload) if err != nil { return "", err } return string(data), nil } func objectFromJSON(raw string) (map[string]any, error) { raw = strings.TrimSpace(raw) if raw == "" { raw = "{}" } decoder := json.NewDecoder(bytes.NewBufferString(raw)) decoder.UseNumber() var payload map[string]any if err := decoder.Decode(&payload); err != nil { return nil, err } if payload == nil { payload = map[string]any{} } return payload, nil } func longBadgeResourceID(raw string) int64 { payload, err := objectFromJSON(raw) if err != nil { return 0 } value, ok := payload[longBadgeResourceIDKey] if !ok { return 0 } switch typed := value.(type) { case json.Number: parsed, err := typed.Int64() if err == nil && parsed > 0 { return parsed } case float64: if typed > 0 { return int64(typed) } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) if err == nil && parsed > 0 { return parsed } } return 0 } type levelBadgeMetadata struct { BadgeForm string `json:"badge_form"` BadgeKind string `json:"badge_kind"` LevelTrack string `json:"level_track"` } func levelBadgeMetadataFromJSON(raw string) levelBadgeMetadata { payload := levelBadgeMetadata{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { return levelBadgeMetadata{} } payload.BadgeForm = strings.ToLower(strings.TrimSpace(payload.BadgeForm)) payload.BadgeKind = strings.ToLower(strings.TrimSpace(payload.BadgeKind)) 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 { return 0, false } return int32(value), true } func parsePositiveInt64(raw string) (int64, bool) { value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || value <= 0 { return 0, false } return value, true } func normalizeTrack(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func normalizeStatus(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func normalizeStatusFilter(value string) string { value = normalizeStatus(value) if value == "all" { return "" } return value } func validTrack(value string) bool { switch value { case "wealth", "game", "charm": return true default: return false } } func validStatus(value string) bool { return value == statusActive || value == statusDisabled } func defaultTrackName(track string) string { switch track { case "wealth": return "财富等级" case "game": return "游戏等级" case "charm": return "魅力等级" default: return "等级" } } func defaultTrackSort(track string) int32 { switch track { case "wealth": return 10 case "game": return 20 case "charm": return 30 default: return 0 } }