494 lines
18 KiB
Go
494 lines
18 KiB
Go
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 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: 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(),
|
||
}
|
||
}
|