package userapi import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) const ( // /appearance 只暴露客户端能直接佩戴或展示的装扮维度;类型值保持和 wallet resource_type 一致。 appearanceResourceAvatarFrame = "avatar_frame" appearanceResourceProfileCard = "profile_card" appearanceResourceVehicle = "vehicle" appearanceResourceBadge = "badge" ) var errAppearanceUpstream = errors.New("upstream service error") type appearanceData struct { UserID string `json:"user_id"` AvatarFrame map[string]any `json:"avatar_frame"` ProfileCard map[string]any `json:"profile_card"` Vehicle map[string]any `json:"vehicle"` Level map[string]any `json:"level"` LevelBadges map[string]any `json:"level_badges"` EquippedBadges []map[string]any `json:"equipped_badges"` Badges []map[string]any `json:"badges"` BadgeProfile appearanceBadgeData `json:"badge_profile"` Resources []map[string]any `json:"resources"` ServerTimeMS int64 `json:"server_time_ms"` } type appearanceBadgeData struct { StripBadges []map[string]any `json:"strip_badges"` ProfileTileBadges []map[string]any `json:"profile_tile_badges"` HonorBadges []map[string]any `json:"honor_badges"` ServerTimeMS int64 `json:"server_time_ms"` } func (h *Handler) getMyAppearance(writer http.ResponseWriter, request *http.Request) { h.writeAppearance(writer, request, auth.UserIDFromContext(request.Context())) } func (h *Handler) getUserAppearance(writer http.ResponseWriter, request *http.Request) { userID, ok := httpkit.PositiveInt64PathValue(request, "user_id") if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } h.writeAppearance(writer, request, userID) } func (h *Handler) writeAppearance(writer http.ResponseWriter, request *http.Request, userID int64) { if userID <= 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // wallet 是装扮权益和当前佩戴的事实源;缺 wallet 时 appearance 不能返回可信数据。 if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } data, err := h.appearanceForUser(request, userID) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, data) } func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appearanceData, error) { // 先初始化为空对象/数组,保证客户端不需要处理 null 和字段缺失两套形态。 data := appearanceData{ UserID: strconv.FormatInt(userID, 10), AvatarFrame: map[string]any{}, ProfileCard: map[string]any{}, Vehicle: map[string]any{}, Level: defaultAppearanceLevelDisplay(), LevelBadges: map[string]any{}, EquippedBadges: []map[string]any{}, Badges: []map[string]any{}, Resources: []map[string]any{}, } // 当前佩戴只走 BatchGetUserEquippedResources: // 1. wallet 内部会过滤过期佩戴; // 2. 后续扩展批量 appearance 时可以复用同一 RPC,不引入 N+1。 equipped, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: []int64{userID}, ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge}, }) if err != nil { return data, err } for _, user := range equipped.GetUsers() { if user.GetUserId() != userID { // batch RPC 按用户分组返回;这里防御上游误返回其他用户数据。 continue } for _, item := range user.GetResources() { // Resources 保留完整佩戴列表,便于客户端以后按 resource_type 自行扩展展示位。 resource := appearanceResourceFromEntitlement(item) data.Resources = append(data.Resources, resource) switch item.GetResource().GetResourceType() { case appearanceResourceAvatarFrame: data.AvatarFrame = resource case appearanceResourceProfileCard: data.ProfileCard = resource case appearanceResourceVehicle: data.Vehicle = resource case appearanceResourceBadge: data.EquippedBadges = append(data.EquippedBadges, resource) } } } // badge_display 是 activity-service 的投影;wallet 只提供 badge 资源素材和权益事实。 badgeProfile, err := h.badgeProfileForUser(request, userID) if err != nil { return data, err } data.BadgeProfile = badgeProfile // 顶层 badges 给高频展示位直接消费;完整 profile 仍保留在 badge_profile 中。 data.Badges = badgeProfile.StripBadges level, levelBadges := h.levelDisplayForUser(request, userID) data.Level = level data.LevelBadges = levelBadges h.fillAppearanceLevelBadgeEntitlementFallback(request, userID, data.Level, data.LevelBadges) data.ServerTimeMS = badgeProfile.ServerTimeMS return data, nil } func (h *Handler) fillAppearanceLevelBadgeEntitlementFallback(request *http.Request, userID int64, levels map[string]any, levelBadges map[string]any) { if h.walletClient == nil || userID <= 0 || !appearanceLevelBadgesNeedFallback(levelBadges) { return } // activity 的等级投影仍是首选来源;这里只在投影缺轨道时读取 wallet active 权益。 // 注册补发的一等级徽章当前已经落在权益表里,这层兜底让老用户不等等级事件也能展示素材。 resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: userID, ResourceType: appearanceResourceBadge, ActiveOnly: true, }) if err != nil { return } for _, item := range resp.GetResources() { appearanceApplyLevelBadgeEntitlement(levels, levelBadges, item) if !appearanceLevelBadgesNeedFallback(levelBadges) { return } } } func appearanceLevelBadgesNeedFallback(levelBadges map[string]any) bool { if levelBadges == nil { return true } for _, track := range []string{"wealth", "game", "charm"} { if _, ok := levelBadges[track]; !ok { return true } } return false } func appearanceApplyLevelBadgeEntitlement(levels map[string]any, levelBadges map[string]any, item *walletv1.UserResourceEntitlement) { if item == nil || levelBadges == nil { return } resource := appearanceResourceFromEntitlement(item) track := strings.TrimSpace(fmt.Sprint(resource["level_track"])) if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" { return } if _, exists := levelBadges[track]; exists { return } resourceID, _ := resource["resource_id"].(int64) if resourceID <= 0 { return } levelBadges[track] = map[string]any{ "track": track, "source_type": "wallet_entitlement", "source_level": appearanceLevelValue(levels, track), "badge_kind": "level", "resource_id": resourceID, "entitlement_id": item.GetEntitlementId(), "updated_at_ms": item.GetUpdatedAtMs(), "resource": resource, } } func appearanceLevelValue(levels map[string]any, track string) int64 { trackLevel, ok := levels[track].(map[string]any) if !ok { return 0 } switch value := trackLevel["level"].(type) { case int64: return value case int32: return int64(value) case int: return int64(value) case float64: return int64(value) default: return 0 } } func (h *Handler) levelDisplayForUser(request *http.Request, userID int64) (map[string]any, map[string]any) { levels := defaultAppearanceLevelDisplay() levelBadges := map[string]any{} if h.growthLevelClient == nil { return levels, levelBadges } resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{ Meta: httpkit.ActivityMeta(request), UserIds: []int64{userID}, }) if err != nil { // 等级徽章只是展示增强,activity 短暂失败时保留装扮和普通徽章结果。 return levels, levelBadges } resourceIDs := make([]int64, 0, 3) var profile *activityv1.UserLevelDisplayProfile for _, item := range resp.GetProfiles() { if item.GetUserId() == userID { profile = item break } } if profile == nil { return levels, levelBadges } resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId(), profile.GetGame().GetBadgeResourceId(), profile.GetCharm().GetBadgeResourceId()) resources := map[int64]map[string]any{} if resolved, err := h.appearanceResourcesByID(request, resourceIDs); err == nil { resources = resolved } applyAppearanceLevelTrack(levels, levelBadges, profile.GetWealth(), resources) applyAppearanceLevelTrack(levels, levelBadges, profile.GetGame(), resources) applyAppearanceLevelTrack(levels, levelBadges, profile.GetCharm(), resources) return levels, levelBadges } func applyAppearanceLevelTrack(levels map[string]any, levelBadges map[string]any, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) { if item == nil || strings.TrimSpace(item.GetTrack()) == "" { return } track := strings.TrimSpace(item.GetTrack()) levels[track] = map[string]any{ "level": item.GetLevel(), "tier_id": item.GetTierId(), "badge_resource_id": item.GetBadgeResourceId(), "badge_source_level": item.GetBadgeSourceLevel(), "avatar_frame_id": item.GetAvatarFrameResourceId(), "updated_at_ms": item.GetUpdatedAtMs(), "display_badge_source": "level_rule", } if item.GetBadgeResourceId() <= 0 { return } badge := map[string]any{ "track": track, "source_type": "level_rule", "source_level": item.GetBadgeSourceLevel(), "badge_kind": "level", "resource_id": item.GetBadgeResourceId(), "updated_at_ms": item.GetUpdatedAtMs(), } if resource, ok := resources[item.GetBadgeResourceId()]; ok { badge["resource"] = resource } levelBadges[track] = badge } func defaultAppearanceLevelDisplay() map[string]any { return map[string]any{ "wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, "game": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, "charm": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, } } func (h *Handler) badgeProfileForUser(request *http.Request, userID int64) (appearanceBadgeData, error) { result := appearanceBadgeData{ StripBadges: []map[string]any{}, ProfileTileBadges: []map[string]any{}, HonorBadges: []map[string]any{}, } if h.achievementClient == nil { // 未接 activity 时保留装扮数据返回,badge 投影为空;这不会影响头像框/座驾展示。 return result, nil } resp, err := h.achievementClient.ListMyBadges(request.Context(), &activityv1.ListMyBadgesRequest{ Meta: httpkit.ActivityMeta(request), UserId: userID, }) if err != nil { return result, err } // activity 的 badge item 只保存 resource_id;资源图标/动画素材仍从 wallet resource 表解析。 resources, err := h.appearanceResourcesByID(request, badgeResourceIDs(resp)) if err != nil { return result, err } result.StripBadges = badgeItemsFromProto(resp.GetStripBadges(), resources) result.ProfileTileBadges = badgeItemsFromProto(resp.GetProfileTileBadges(), resources) result.HonorBadges = badgeItemsFromProto(resp.GetHonorBadges(), resources) result.ServerTimeMS = resp.GetServerTimeMs() return result, nil } func (h *Handler) appearanceResourcesByID(request *http.Request, ids []int64) (map[int64]map[string]any, error) { ids = uniqueAppearanceResourceIDs(ids) resources := make(map[int64]map[string]any, len(ids)) if len(ids) == 0 { return resources, nil } if h.walletClient == nil { // 只有 badge 素材解析会走到这里;缺 wallet 表示无法把 badge_id 转成可展示资源。 return nil, errAppearanceUpstream } for _, id := range ids { resp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), ResourceId: id, }) if err != nil { return nil, err } resource := appearanceResourceFromProto(resp.GetResource(), "", 0) if resourceID, ok := resource["resource_id"].(int64); ok && resourceID > 0 { resources[resourceID] = resource } } return resources, nil } func appearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlement) map[string]any { if item == nil { return map[string]any{} } // entitlement_id/expires_at_ms 是佩戴态字段,resource 本身不带这两个用户维度。 return appearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) } func appearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any { if item == nil || item.GetResourceId() <= 0 { return map[string]any{} } resource := map[string]any{ "resource_id": item.GetResourceId(), "resource_code": item.GetResourceCode(), "resource_type": item.GetResourceType(), "name": item.GetName(), "asset_url": item.GetAssetUrl(), "preview_url": item.GetPreviewUrl(), "animation_url": item.GetAnimationUrl(), "metadata_json": item.GetMetadataJson(), "entitlement_id": entitlementID, "expires_at_ms": expiresAtMS, } if item.GetResourceType() == appearanceResourceBadge { badgeForm, badgeKind, levelTrack := appearanceBadgeMetadataFields(item.GetMetadataJson()) resource["badge_form"] = badgeForm resource["badge_kind"] = badgeKind resource["level_track"] = levelTrack } return resource } func appearanceBadgeMetadataFields(raw string) (string, string, string) { payload := map[string]any{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { return "", "normal", "" } badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"]))) if badgeForm != "strip" && badgeForm != "tile" { badgeForm = "" } badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"]))) if badgeKind != "level" { badgeKind = "normal" } levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"]))) switch levelTrack { case "wealth", "game", "charm": default: levelTrack = "" } return badgeForm, badgeKind, levelTrack } func badgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any { result := make([]map[string]any, 0, len(items)) for _, item := range items { if item == nil { continue } // badge item 保留 activity 的展示位字段,同时在 resource 内嵌 wallet 资源素材。 data := map[string]any{ "slot": item.GetSlot(), "position": item.GetPosition(), "badge_form": item.GetBadgeForm(), "resource_id": item.GetResourceId(), "entitlement_id": item.GetEntitlementId(), "source_type": item.GetSourceType(), "source_id": item.GetSourceId(), "pin_mode": item.GetPinMode(), "updated_at_ms": item.GetUpdatedAtMs(), } if resource, ok := resources[item.GetResourceId()]; ok { data["resource"] = resource } result = append(result, data) } return result } func badgeResourceIDs(resp *activityv1.ListMyBadgesResponse) []int64 { if resp == nil { return nil } // 三个展示区都可能引用 badge 资源;统一收集后去重解析素材。 ids := make([]int64, 0, len(resp.GetStripBadges())+len(resp.GetProfileTileBadges())+len(resp.GetHonorBadges())) for _, item := range resp.GetStripBadges() { ids = append(ids, item.GetResourceId()) } for _, item := range resp.GetProfileTileBadges() { ids = append(ids, item.GetResourceId()) } for _, item := range resp.GetHonorBadges() { ids = append(ids, item.GetResourceId()) } return ids } func uniqueAppearanceResourceIDs(ids []int64) []int64 { seen := make(map[int64]struct{}, len(ids)) result := make([]int64, 0, len(ids)) for _, id := range ids { if id <= 0 { continue } if _, exists := seen[id]; exists { continue } seen[id] = struct{}{} result = append(result, id) } return result }