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

471 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}