2026-05-16 08:12:42 +08:00

494 lines
18 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 (
"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"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// 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"`
}
// loginRiskBlockedCountryData 是 App 登录后异步地区风控只读词表。
// keyword 保留后台录入词country_code 为空时表示该词只供客户端本地匹配。
type loginRiskBlockedCountryData struct {
CountryCode string `json:"country_code,omitempty"`
Keyword string `json:"keyword"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// listRegistrationCountries 返回 App 注册页可选国家。
// 该接口公开读,不要求 access tokenuser-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: httpkit.UserMeta(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})
}
// listLoginRiskBlockedCountries 返回 App 可异步拉取的地区屏蔽词表。
// 登录最终处置仍由 user-service 风控 worker 决定,客户端只做补充风险探测和上报。
func (h *Handler) listLoginRiskBlockedCountries(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.ListLoginRiskBlockedCountries(request.Context(), &userv1.ListLoginRiskBlockedCountriesRequest{
Meta: httpkit.UserMeta(request, ""),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
countries := make([]loginRiskBlockedCountryData, 0, len(resp.GetCountries()))
for _, country := range resp.GetCountries() {
countries = append(countries, loginRiskBlockedCountryData{
CountryCode: country.GetCountryCode(),
Keyword: country.GetKeyword(),
UpdatedAtMs: country.GetUpdatedAtMs(),
})
}
httpkit.WriteOK(writer, request, map[string]any{"countries": countries, "updated_at_ms": resp.GetUpdatedAtMs()})
}
// 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: httpkit.UserMeta(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: httpkit.UserMeta(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 !httpkit.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: httpkit.UserMeta(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: httpkit.UserMeta(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 !httpkit.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: httpkit.UserMeta(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 !httpkit.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: httpkit.UserMeta(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: httpkit.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 !httpkit.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: httpkit.UserMeta(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 !httpkit.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: httpkit.UserMeta(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(),
}
}