package userapi import ( "errors" "net/http" "strconv" 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"` 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{}, 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 data.ServerTimeMS = badgeProfile.ServerTimeMS return data, nil } 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{} } return 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, } } 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 }