2026-06-04 10:06:49 +08:00

800 lines
23 KiB
Go

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
}
}