package userapi import ( "net/http" "strconv" "strings" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) type cpUserProfileData struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` Username string `json:"username"` Avatar string `json:"avatar"` } type cpAvatarFrameData struct { ResourceID int64 `json:"resource_id"` ResourceCode string `json:"resource_code"` Name string `json:"name"` AssetURL string `json:"asset_url"` PreviewURL string `json:"preview_url"` AnimationURL string `json:"animation_url"` MetadataJSON string `json:"metadata_json"` } type cpIntimacyLeaderboardUserData struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` Username string `json:"username"` Avatar string `json:"avatar"` AvatarFrame *cpAvatarFrameData `json:"avatar_frame,omitempty"` } type cpIntimacyLeaderboardItemData struct { Rank int64 `json:"rank"` RelationshipID string `json:"relationship_id"` RelationType string `json:"relation_type"` IntimacyValue int64 `json:"intimacy_value"` Level int32 `json:"level"` UserA *cpIntimacyLeaderboardUserData `json:"user_a,omitempty"` UserB *cpIntimacyLeaderboardUserData `json:"user_b,omitempty"` FormedAtMS int64 `json:"formed_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type cpGiftSnapshotData struct { GiftID string `json:"gift_id"` GiftName string `json:"gift_name"` GiftIconURL string `json:"gift_icon_url"` GiftAnimationURL string `json:"gift_animation_url"` GiftCount int32 `json:"gift_count"` GiftValue int64 `json:"gift_value"` BillingReceiptID string `json:"billing_receipt_id"` } type cpApplicationData struct { ApplicationID string `json:"application_id"` RelationType string `json:"relation_type"` Status string `json:"status"` Requester *cpUserProfileData `json:"requester,omitempty"` Target *cpUserProfileData `json:"target,omitempty"` RoomID string `json:"room_id"` RoomRegionID int64 `json:"room_region_id"` Gift *cpGiftSnapshotData `json:"gift,omitempty"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` ExpiresAtMS int64 `json:"expires_at_ms"` DecidedAtMS int64 `json:"decided_at_ms"` } type cpRelationshipData struct { RelationshipID string `json:"relationship_id"` RelationType string `json:"relation_type"` Status string `json:"status"` Me *cpUserProfileData `json:"me,omitempty"` Partner *cpUserProfileData `json:"partner,omitempty"` IntimacyValue int64 `json:"intimacy_value"` Level int32 `json:"level"` CurrentLevelThreshold int64 `json:"current_level_threshold"` NextLevelThreshold int64 `json:"next_level_threshold"` NeededForNextLevel int64 `json:"needed_for_next_level"` LevelProgressPercent int32 `json:"level_progress_percent"` MaxLevel bool `json:"max_level"` BreakupCostCoins int64 `json:"breakup_cost_coins"` FormedAtMS int64 `json:"formed_at_ms"` EndedAtMS int64 `json:"ended_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type cpRejectRequest struct { Reason string `json:"reason"` } type cpBreakRelationshipRequest struct { CommandID string `json:"command_id"` } // listCPApplications 返回当前用户收到或发出的 CP/兄弟/姐妹申请。 func (h *Handler) listCPApplications(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } page, pageSize, ok := socialPage(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.userCPClient.ListCPApplications(request.Context(), &userv1.ListCPApplicationsRequest{ Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), Direction: strings.TrimSpace(request.URL.Query().Get("direction")), Status: strings.TrimSpace(request.URL.Query().Get("status")), Page: page, PageSize: pageSize, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } applications := make([]cpApplicationData, 0, len(resp.GetApplications())) for _, item := range resp.GetApplications() { applications = append(applications, cpApplicationFromProto(item)) } httpkit.WriteOK(writer, request, map[string]any{ "applications": applications, "total": resp.GetTotal(), "server_time_ms": resp.GetServerTimeMs(), }) } // acceptCPApplication 同意一条申请;同意后同一对用户其他 pending 类型由 user-service 阻断。 func (h *Handler) acceptCPApplication(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } applicationID := strings.TrimSpace(request.PathValue("application_id")) if applicationID == "" { httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found") return } resp, err := h.userCPClient.AcceptCPApplication(request.Context(), &userv1.AcceptCPApplicationRequest{ Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), ApplicationId: applicationID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, map[string]any{ "application": cpApplicationFromProto(resp.GetApplication()), "relationship": cpRelationshipFromProto(resp.GetRelationship()), }) } // rejectCPApplication 拒绝一条申请;拒绝不会删除历史申请,客户端仍可看到 decided 状态。 func (h *Handler) rejectCPApplication(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } applicationID := strings.TrimSpace(request.PathValue("application_id")) if applicationID == "" { httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found") return } var body cpRejectRequest if request.Body != nil && request.ContentLength != 0 && !httpkit.Decode(writer, request, &body) { return } resp, err := h.userCPClient.RejectCPApplication(request.Context(), &userv1.RejectCPApplicationRequest{ Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), ApplicationId: applicationID, Reason: body.Reason, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, map[string]any{"application": cpApplicationFromProto(resp.GetApplication())}) } // listCPRelationships 默认返回当前用户关系;资料卡传 user_id 时返回被查看用户关系。 func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } page, pageSize, ok := socialPage(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } targetUserID := cpRelationshipListUserID(request) resp, err := h.userCPClient.ListCPRelationships(request.Context(), &userv1.ListCPRelationshipsRequest{ Meta: httpkit.UserMeta(request, ""), UserId: targetUserID, RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")), Page: page, PageSize: pageSize, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } relationships := make([]cpRelationshipData, 0, len(resp.GetRelationships())) for _, item := range resp.GetRelationships() { relationships = append(relationships, cpRelationshipFromProto(item)) } httpkit.WriteOK(writer, request, map[string]any{ "relationships": relationships, "total": resp.GetTotal(), }) } func cpRelationshipListUserID(request *http.Request) int64 { currentUserID := auth.UserIDFromContext(request.Context()) raw := strings.TrimSpace(request.URL.Query().Get("user_id")) if raw == "" { return currentUserID } // Flutter 已经会把被查看用户放进 user_id;非法值按历史语义回落到登录用户,避免破坏旧客户端的“我的关系”入口。 targetUserID, err := strconv.ParseInt(raw, 10, 64) if err != nil || targetUserID <= 0 { return currentUserID } return targetUserID } // listCPIntimacyLeaderboard 返回 cron 聚合后的 CP 亲密值排行榜,不在 gateway 实时扫描关系表。 func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } // 榜单接口沿用社交列表分页参数,非法 page/page_size 在 gateway 直接拒绝,避免把异常分页传给内部服务。 page, pageSize, ok := socialPage(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } // gateway 只透传 relation_type 筛选和请求 meta;榜单数据来自 user-service 的 Redis 读模型,不在 HTTP 层拼装。 resp, err := h.userCPClient.ListCPIntimacyLeaderboard(request.Context(), &userv1.ListCPIntimacyLeaderboardRequest{ Meta: httpkit.UserMeta(request, ""), RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")), Page: page, PageSize: pageSize, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } items := make([]cpIntimacyLeaderboardItemData, 0, len(resp.GetItems())) for _, item := range resp.GetItems() { // 双方资料、头像框和 rank 都已经在内部 RPC 返回;这里仅转换为 App JSON 字段名。 items = append(items, cpIntimacyLeaderboardItemFromProto(item)) } httpkit.WriteOK(writer, request, map[string]any{ "items": items, "total": resp.GetTotal(), "page": resp.GetPage(), "page_size": resp.GetPageSize(), "server_time_ms": resp.GetServerTimeMs(), }) } // breakCPRelationship 先在 user-service 创建解除占位,再扣钱包,最后确认关系 ended,避免并发请求重复扣费。 func (h *Handler) breakCPRelationship(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil || h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } relationshipID := strings.TrimSpace(request.PathValue("relationship_id")) if relationshipID == "" { httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found") return } var body cpBreakRelationshipRequest if !httpkit.Decode(writer, request, &body) { return } commandID := strings.TrimSpace(body.CommandID) if commandID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is required") return } userID := auth.UserIDFromContext(request.Context()) meta := httpkit.UserMeta(request, "") prepare, err := h.userCPClient.PrepareBreakCPRelationship(request.Context(), &userv1.PrepareBreakCPRelationshipRequest{ Meta: meta, UserId: userID, RelationshipId: relationshipID, CommandId: commandID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } if prepare.GetStatus() == "confirmed" { httpkit.WriteOK(writer, request, map[string]any{ "relationship": cpRelationshipFromProto(prepare.GetRelationship()), "breakup_cost_coins": prepare.GetBreakupCostCoins(), "wallet_transaction_id": prepare.GetWalletTransactionId(), "coin_balance_after": prepare.GetCoinBalanceAfter(), }) return } walletTransactionID := "" coinBalanceAfter := int64(0) cost := prepare.GetBreakupCostCoins() if cost > 0 { walletResp, walletErr := h.walletClient.DebitCPBreakupFee(request.Context(), &walletv1.DebitCPBreakupFeeRequest{ CommandId: commandID, AppCode: meta.GetAppCode(), UserId: userID, RelationshipId: relationshipID, RelationType: prepare.GetRelationship().GetRelationType(), Amount: cost, }) if walletErr != nil { // 钱包失败表示关系不应解除;取消 pending 占位后把真实钱包错误返回给客户端。 _, _ = h.userCPClient.CancelBreakCPRelationship(request.Context(), &userv1.CancelBreakCPRelationshipRequest{ Meta: meta, UserId: userID, RelationshipId: relationshipID, CommandId: commandID, Reason: "wallet_debit_failed", }) httpkit.WriteRPCError(writer, request, walletErr) return } walletTransactionID = walletResp.GetTransactionId() coinBalanceAfter = walletResp.GetCoinBalanceAfter() } confirmed, err := h.userCPClient.ConfirmBreakCPRelationship(request.Context(), &userv1.ConfirmBreakCPRelationshipRequest{ Meta: meta, UserId: userID, RelationshipId: relationshipID, CommandId: commandID, WalletTransactionId: walletTransactionID, PaidCoinAmount: cost, CoinBalanceAfter: coinBalanceAfter, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, map[string]any{ "relationship": cpRelationshipFromProto(confirmed.GetRelationship()), "breakup_cost_coins": confirmed.GetBreakupCostCoins(), "wallet_transaction_id": confirmed.GetWalletTransactionId(), "coin_balance_after": confirmed.GetCoinBalanceAfter(), }) } func cpApplicationFromProto(item *userv1.CPApplication) cpApplicationData { if item == nil { return cpApplicationData{} } return cpApplicationData{ ApplicationID: item.GetApplicationId(), RelationType: item.GetRelationType(), Status: item.GetStatus(), Requester: cpUserProfileFromProto(item.GetRequester()), Target: cpUserProfileFromProto(item.GetTarget()), RoomID: item.GetRoomId(), RoomRegionID: item.GetRoomRegionId(), Gift: cpGiftSnapshotFromProto(item.GetGift()), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), ExpiresAtMS: item.GetExpiresAtMs(), DecidedAtMS: item.GetDecidedAtMs(), } } func cpRelationshipFromProto(item *userv1.CPRelationship) cpRelationshipData { if item == nil { return cpRelationshipData{} } return cpRelationshipData{ RelationshipID: item.GetRelationshipId(), RelationType: item.GetRelationType(), Status: item.GetStatus(), Me: cpUserProfileFromProto(item.GetMe()), Partner: cpUserProfileFromProto(item.GetPartner()), IntimacyValue: item.GetIntimacyValue(), Level: item.GetLevel(), CurrentLevelThreshold: item.GetCurrentLevelThreshold(), NextLevelThreshold: item.GetNextLevelThreshold(), NeededForNextLevel: item.GetNeededForNextLevel(), LevelProgressPercent: item.GetLevelProgressPercent(), MaxLevel: item.GetMaxLevel(), BreakupCostCoins: item.GetBreakupCostCoins(), FormedAtMS: item.GetFormedAtMs(), EndedAtMS: item.GetEndedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func cpUserProfileFromProto(item *userv1.CPUserProfile) *cpUserProfileData { if item == nil || item.GetUserId() <= 0 { return nil } return &cpUserProfileData{ UserID: userIDString(item.GetUserId()), DisplayUserID: item.GetDisplayUserId(), Username: item.GetUsername(), Avatar: item.GetAvatar(), } } func cpIntimacyLeaderboardItemFromProto(item *userv1.CPIntimacyLeaderboardItem) cpIntimacyLeaderboardItemData { if item == nil { return cpIntimacyLeaderboardItemData{} } // user_a/user_b 是关系无序 pair 的固定两端,不按当前登录用户换位;排行榜展示需要稳定的双方快照。 return cpIntimacyLeaderboardItemData{ Rank: item.GetRank(), RelationshipID: item.GetRelationshipId(), RelationType: item.GetRelationType(), IntimacyValue: item.GetIntimacyValue(), Level: item.GetLevel(), UserA: cpIntimacyLeaderboardUserFromProto(item.GetUserA()), UserB: cpIntimacyLeaderboardUserFromProto(item.GetUserB()), FormedAtMS: item.GetFormedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } } func cpIntimacyLeaderboardUserFromProto(item *userv1.CPIntimacyLeaderboardUser) *cpIntimacyLeaderboardUserData { if item == nil || item.GetUserId() <= 0 { // 内部服务如果返回空用户,App JSON 直接省略该侧,避免输出 user_id=0 的伪用户。 return nil } return &cpIntimacyLeaderboardUserData{ UserID: userIDString(item.GetUserId()), DisplayUserID: item.GetDisplayUserId(), Username: item.GetUsername(), Avatar: item.GetAvatar(), AvatarFrame: cpAvatarFrameFromProto(item.GetAvatarFrame()), } } func cpAvatarFrameFromProto(item *userv1.CPAvatarFrameSnapshot) *cpAvatarFrameData { if item == nil || item.GetResourceId() <= 0 { // 未佩戴头像框时保持 nil,omitempty 会让 App 端按无头像框处理。 return nil } return &cpAvatarFrameData{ ResourceID: item.GetResourceId(), ResourceCode: item.GetResourceCode(), Name: item.GetName(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), AnimationURL: item.GetAnimationUrl(), MetadataJSON: item.GetMetadataJson(), } } func cpGiftSnapshotFromProto(item *userv1.CPGiftSnapshot) *cpGiftSnapshotData { if item == nil { return nil } return &cpGiftSnapshotData{ GiftID: item.GetGiftId(), GiftName: item.GetGiftName(), GiftIconURL: item.GetGiftIconUrl(), GiftAnimationURL: item.GetGiftAnimationUrl(), GiftCount: item.GetGiftCount(), GiftValue: item.GetGiftValue(), BillingReceiptID: item.GetBillingReceiptId(), } }