471 lines
17 KiB
Go
471 lines
17 KiB
Go
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
|
||
}
|