2026-07-05 01:55:03 +08:00

483 lines
19 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 (
"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 {
// 未佩戴头像框时保持 nilomitempty 会让 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(),
}
}