483 lines
19 KiB
Go
483 lines
19 KiB
Go
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(),
|
||
}
|
||
}
|