package http import ( "hyapp/services/gateway-service/internal/transport/http/httpkit" "log/slog" "net/http" "strconv" "strings" activityv1 "hyapp.local/api/proto/activity/v1" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/logx" "hyapp/services/gateway-service/internal/auth" ) // userIdentityData 是 gateway 对外返回的当前短号投影。 // `display_user_id` 永远表示当前有效展示号,临时靓号过期后由 user-service 恢复为默认短号。 type userIdentityData struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` Status string `json:"status"` DefaultDisplayUserID string `json:"default_display_user_id"` DisplayUserIDKind string `json:"display_user_id_kind"` DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"` LeaseID string `json:"lease_id,omitempty"` } // userProfileData 是 gateway 对外返回的用户基础资料投影。 // 它不包含注册来源、三方身份或 refresh session 等服务端内部字段。 type userProfileData struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` DefaultDisplayUserID string `json:"default_display_user_id"` DisplayUserIDKind string `json:"display_user_id_kind"` DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"` Username string `json:"username"` Gender string `json:"gender,omitempty"` Country string `json:"country,omitempty"` CountryID int64 `json:"country_id,omitempty"` CountryName string `json:"country_name,omitempty"` CountryDisplayName string `json:"country_display_name,omitempty"` ISONumeric string `json:"iso_numeric,omitempty"` PhoneCountryCode string `json:"phone_country_code,omitempty"` CountryEnabled bool `json:"country_enabled"` RegionID int64 `json:"region_id,omitempty"` RegionCode string `json:"region_code,omitempty"` RegionName string `json:"region_name,omitempty"` Avatar string `json:"avatar,omitempty"` Birth string `json:"birth,omitempty"` Language string `json:"language,omitempty"` ProfileCompleted bool `json:"profile_completed"` ProfileCompletedAtMs int64 `json:"profile_completed_at_ms"` OnboardingStatus string `json:"onboarding_status"` UpdatedAtMs int64 `json:"updated_at_ms"` NextCountryChangeAtMs int64 `json:"next_country_change_allowed_at_ms,omitempty"` } // completeOnboardingData 在保留资料字段顶层兼容性的同时返回新的 access token。 // token.refresh_token 首版为空,客户端继续持有登录时的 refresh token,仅替换 access_token。 type completeOnboardingData struct { userProfileData Invite *inviteBindingData `json:"invite,omitempty"` Token authTokenData `json:"token"` } type inviteBindingData struct { Bound bool `json:"bound"` InviteCode string `json:"invite_code,omitempty"` InviterUserID string `json:"inviter_user_id,omitempty"` } // countryData 是注册页国家列表的公开响应投影。 // 只包含客户端选择和展示所需字段,不暴露管理端审计字段。 type countryData struct { CountryID int64 `json:"country_id"` CountryName string `json:"country_name"` CountryCode string `json:"country_code"` ISOAlpha3 string `json:"iso_alpha3,omitempty"` ISONumeric string `json:"iso_numeric,omitempty"` CountryDisplayName string `json:"country_display_name"` PhoneCountryCode string `json:"phone_country_code,omitempty"` Enabled bool `json:"enabled"` Flag string `json:"flag,omitempty"` SortOrder int32 `json:"sort_order"` } // listRegistrationCountries 返回 App 注册页可选国家。 // 该接口公开读,不要求 access token;user-service 负责只返回 active 且 enabled 的国家。 func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request *http.Request) { if h.userCountryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{ Meta: authRequestMeta(request, ""), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } countries := make([]countryData, 0, len(resp.GetCountries())) for _, country := range resp.GetCountries() { countries = append(countries, registrationCountryData(country)) } httpkit.WriteOK(writer, request, map[string]any{"countries": countries}) } // getMyIdentity 返回当前登录用户的短号状态。 func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Request) { if h.userIdentityClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userIdentityClient.GetUserIdentity(request.Context(), &userv1.GetUserIdentityRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), "")) } // resolveDisplayUserID 通过当前有效展示号解析用户。 func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http.Request) { if h.userIdentityClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } displayUserID := strings.TrimSpace(request.PathValue("display_user_id")) if displayUserID == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.userIdentityClient.ResolveDisplayUserID(request.Context(), &userv1.ResolveDisplayUserIDRequest{ Meta: authRequestMeta(request, ""), DisplayUserId: displayUserID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), "")) } // completeMyOnboarding 是注册页唯一资料提交入口。 // gateway 只负责从 access token 取 user_id,资料校验和国家/区域解析必须由 user-service 同事务完成。 func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http.Request) { var body struct { Username string `json:"username"` Avatar string `json:"avatar"` Gender string `json:"gender"` Country string `json:"country"` InviteCode string `json:"invite_code"` } if !decode(writer, request, &body) { return } if h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userProfileClient.CompleteOnboarding(request.Context(), &userv1.CompleteOnboardingRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), Username: body.Username, Avatar: body.Avatar, Gender: body.Gender, Country: body.Country, InviteCode: body.InviteCode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, completeOnboardingData{ userProfileData: profileData(resp.GetUser(), 0), Invite: inviteBindingDataFromProto(resp.GetInvite()), Token: authData(resp.GetToken(), nil), }) } // getMyProfile 返回当前登录用户的基础资料。 func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0)) } // updateMyProfile 修改当前用户的用户名、头像、性别和生日。 func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Request) { var body struct { Username *string `json:"username"` Avatar *string `json:"avatar"` Gender *string `json:"gender"` Birth *string `json:"birth"` } if !decode(writer, request, &body) { return } if h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userProfileClient.UpdateUserProfile(request.Context(), &userv1.UpdateUserProfileRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), Username: body.Username, Avatar: body.Avatar, Gender: body.Gender, Birth: body.Birth, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0)) } // changeMyCountry 单独修改当前用户国家;冷却期和审计由 user-service 保证。 func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Request) { var body struct { Country string `json:"country"` } if !decode(writer, request, &body) { return } if h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userProfileClient.ChangeUserCountry(request.Context(), &userv1.ChangeUserCountryRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), Country: body.Country, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } h.removeOldRegionBroadcastMemberBestEffort(request, auth.UserIDFromContext(request.Context()), resp.GetOldRegionId(), resp.GetNewRegionId(), "user_country_changed") httpkit.WriteOK(writer, request, profileData(resp.GetUser(), resp.GetNextChangeAllowedAtMs())) } func (h *Handler) removeOldRegionBroadcastMemberBestEffort(request *http.Request, userID int64, oldRegionID int64, newRegionID int64, reason string) { if h == nil || h.broadcastClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID { return } resp, err := h.broadcastClient.RemoveRegionBroadcastMember(request.Context(), &activityv1.RemoveRegionBroadcastMemberRequest{ Meta: activityMeta(request), UserId: userID, RegionId: oldRegionID, Reason: reason, }) if err != nil { // 国家修改已经由 user-service 提交,IM 旧群成员移除是外部副作用;失败记录日志,后续 callback 仍会阻止用户重新加入旧区域群。 logx.Warn(request.Context(), "im_region_group_member_remove_failed", slog.Int64("user_id", userID), slog.Int64("old_region_id", oldRegionID), slog.Int64("new_region_id", newRegionID), slog.String("reason", reason), slog.String("error", err.Error()), ) return } logx.Info(request.Context(), "im_region_group_member_removed", slog.Int64("user_id", userID), slog.Int64("old_region_id", oldRegionID), slog.Int64("new_region_id", newRegionID), slog.String("group_id", resp.GetGroupId()), slog.Bool("removed", resp.GetRemoved()), ) } // changeMyDisplayUserID 修改当前登录用户的默认短号。 func (h *Handler) changeMyDisplayUserID(writer http.ResponseWriter, request *http.Request) { var body struct { NewDisplayUserID string `json:"new_display_user_id"` Reason string `json:"reason"` } if !decode(writer, request, &body) { return } if h.userIdentityClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userIdentityClient.ChangeDisplayUserID(request.Context(), &userv1.ChangeDisplayUserIDRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), NewDisplayUserId: body.NewDisplayUserID, Reason: body.Reason, OperatorUserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), "")) } // applyMyPrettyDisplayUserID 为当前登录用户申请临时靓号。 func (h *Handler) applyMyPrettyDisplayUserID(writer http.ResponseWriter, request *http.Request) { var body struct { PrettyDisplayUserID string `json:"pretty_display_user_id"` LeaseDurationMs int64 `json:"lease_duration_ms"` PaymentReceiptID string `json:"payment_receipt_id"` } if !decode(writer, request, &body) { return } if h.userIdentityClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.userIdentityClient.ApplyPrettyDisplayUserID(request.Context(), &userv1.ApplyPrettyDisplayUserIDRequest{ Meta: authRequestMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), PrettyDisplayUserId: body.PrettyDisplayUserID, LeaseDurationMs: body.LeaseDurationMs, PaymentReceiptId: body.PaymentReceiptID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, identityData(resp.GetIdentity(), resp.GetLeaseId())) } func identityData(identity *userv1.UserIdentity, leaseID string) userIdentityData { if identity == nil { return userIdentityData{LeaseID: leaseID} } return userIdentityData{ UserID: userIDStringFromIdentity(identity), DisplayUserID: identity.GetDisplayUserId(), Status: identity.GetStatus(), DefaultDisplayUserID: identity.GetDefaultDisplayUserId(), DisplayUserIDKind: identity.GetDisplayUserIdKind(), DisplayUserIDExpiresAtMs: identity.GetDisplayUserIdExpiresAtMs(), LeaseID: leaseID, } } func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData { if user == nil { return userProfileData{NextCountryChangeAtMs: nextCountryChangeAtMs} } return userProfileData{ UserID: userIDStringFromUser(user), DisplayUserID: user.GetDisplayUserId(), DefaultDisplayUserID: user.GetDefaultDisplayUserId(), DisplayUserIDKind: user.GetDisplayUserIdKind(), DisplayUserIDExpiresAtMs: user.GetDisplayUserIdExpiresAtMs(), Username: user.GetUsername(), Gender: user.GetGender(), Country: user.GetCountry(), CountryID: user.GetCountryId(), CountryName: user.GetCountryName(), CountryDisplayName: user.GetCountryDisplayName(), ISONumeric: user.GetIsoNumeric(), PhoneCountryCode: user.GetPhoneCountryCode(), CountryEnabled: user.GetCountryEnabled(), RegionID: user.GetRegionId(), RegionCode: user.GetRegionCode(), RegionName: user.GetRegionName(), Avatar: user.GetAvatar(), Birth: user.GetBirth(), Language: user.GetLanguage(), ProfileCompleted: user.GetProfileCompleted(), ProfileCompletedAtMs: user.GetProfileCompletedAtMs(), OnboardingStatus: user.GetOnboardingStatus(), UpdatedAtMs: user.GetUpdatedAtMs(), NextCountryChangeAtMs: nextCountryChangeAtMs, } } func inviteBindingDataFromProto(binding *userv1.InviteBinding) *inviteBindingData { if binding == nil { return nil } return &inviteBindingData{ Bound: binding.GetBound(), InviteCode: binding.GetInviteCode(), InviterUserID: userIDString(binding.GetInviterUserId()), } } func userIDString(userID int64) string { if userID > 0 { return strconv.FormatInt(userID, 10) } return "" } func userIDStringFromUser(user *userv1.User) string { if user == nil { return "" } return userIDString(user.GetUserId()) } func userIDStringFromIdentity(identity *userv1.UserIdentity) string { if identity == nil { return "" } return userIDString(identity.GetUserId()) } func registrationCountryData(country *userv1.Country) countryData { if country == nil { return countryData{} } return countryData{ CountryID: country.GetCountryId(), CountryName: country.GetCountryName(), CountryCode: country.GetCountryCode(), ISOAlpha3: country.GetIsoAlpha3(), ISONumeric: country.GetIsoNumeric(), CountryDisplayName: country.GetCountryDisplayName(), PhoneCountryCode: country.GetPhoneCountryCode(), Enabled: country.GetEnabled(), Flag: country.GetFlag(), SortOrder: country.GetSortOrder(), } }