2026-07-12 14:17:45 +08:00

681 lines
23 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 http
import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/services/gateway-service/internal/auth"
)
// authTokenData 是 gateway 对外 auth API 的扁平响应格式。
type authTokenData struct {
AppCode string `json:"app_code"`
UserID string `json:"user_id,omitempty"`
DisplayUserID string `json:"display_user_id,omitempty"`
DefaultDisplayUserID string `json:"default_display_user_id,omitempty"`
DisplayUserIDKind string `json:"display_user_id_kind,omitempty"`
DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"`
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInSec int64 `json:"expires_in_sec"`
TokenType string `json:"token_type"`
IsNewUser *bool `json:"is_new_user,omitempty"`
ProfileCompleted bool `json:"profile_completed"`
OnboardingStatus string `json:"onboarding_status"`
}
// quickCreateAccountData 直接把联调最关心的 uid/token 放到顶层token 内仍保留标准登录响应。
type quickCreateAccountData struct {
UID string `json:"uid"`
Account string `json:"account"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
PasswordSet bool `json:"password_set"`
Token authTokenData `json:"token"`
}
// loginPassword 处理账号密码登录;账号就是当前有效短号。
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {
Account string `json:"account"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
account := strings.TrimSpace(body.Account)
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationPassword,
deviceID: body.DeviceID,
displayUserID: account,
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: "account",
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
Meta: meta,
DisplayUserId: account,
Password: body.Password,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
}
// quickCreateAccount 为游戏厂商联调快速创建一个完整账号;返回 uid 即 App 登录账号和游戏 uid。
func (h *Handler) quickCreateAccount(writer http.ResponseWriter, request *http.Request) {
var body struct {
Password string `json:"password"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Gender string `json:"gender"`
Country string `json:"country"`
InviteCode string `json:"invite_code"`
DeviceID string `json:"device_id"`
Device string `json:"device"`
OSVersion string `json:"os_version"`
Birth string `json:"birth"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Source string `json:"source"`
InstallChannel string `json:"install_channel"`
Campaign string `json:"campaign"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
body.Password = strings.TrimSpace(body.Password)
if body.Password == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// 默认值全部保持可落库、可登录、可过 user-service 资料校验;调用方仍可按需覆盖。
if strings.TrimSpace(body.Username) == "" {
body.Username = "quick_" + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
if strings.TrimSpace(body.Avatar) == "" {
body.Avatar = "https://cdn.example.com/avatar/default.png"
}
if strings.TrimSpace(body.Gender) == "" {
body.Gender = "unknown"
}
if strings.TrimSpace(body.Country) == "" {
body.Country = "SG"
}
if strings.TrimSpace(body.DeviceID) == "" {
body.DeviceID = idgen.New("quick_device")
}
if strings.TrimSpace(body.Platform) == "" {
body.Platform = "android"
}
if strings.TrimSpace(body.Language) == "" {
body.Language = "zh-CN"
}
if strings.TrimSpace(body.Timezone) == "" {
body.Timezone = "Asia/Shanghai"
}
if strings.TrimSpace(body.Source) == "" {
body.Source = "quick_account"
}
if strings.TrimSpace(body.InstallChannel) == "" {
body.InstallChannel = "game_integration"
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
deviceID: body.DeviceID,
provider: "quick_account",
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: "quick_account",
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
// 标准客户端不再发 body device/os_version缺失时回退设备头让 users.register_device/os_version 不再为空。
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
resp, err := h.userClient.QuickCreateAccount(request.Context(), &userv1.QuickCreateAccountRequest{
Meta: meta,
Password: body.Password,
Username: body.Username,
Avatar: body.Avatar,
Gender: body.Gender,
Country: body.Country,
InviteCode: body.InviteCode,
DeviceId: body.DeviceID,
Device: body.Device,
OsVersion: body.OSVersion,
Birth: body.Birth,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
token := authData(resp.GetToken(), nil)
httpkit.WriteOK(writer, request, quickCreateAccountData{
UID: token.DisplayUserID,
Account: token.DisplayUserID,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
PasswordSet: resp.GetPasswordSet(),
Token: token,
})
}
// loginThirdParty 处理三方登录即注册。
func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Request) {
var body struct {
Provider string `json:"provider"`
Credential string `json:"credential"`
Username string `json:"username"`
Gender string `json:"gender"`
Country string `json:"country"`
InviteCode string `json:"invite_code"`
DeviceID string `json:"device_id"`
Device string `json:"device"`
OSVersion string `json:"os_version"`
Avatar string `json:"avatar"`
Birth string `json:"birth"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Source string `json:"source"`
InstallChannel string `json:"install_channel"`
Campaign string `json:"campaign"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
deviceID: body.DeviceID,
provider: body.Provider,
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: body.Provider,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
// 三方登录即注册路径同样回退设备头,注册快照与设备档案共用同一来源。
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
Meta: meta,
Provider: body.Provider,
Credential: body.Credential,
Username: body.Username,
Gender: body.Gender,
Country: body.Country,
InviteCode: body.InviteCode,
DeviceId: body.DeviceID,
Device: body.Device,
OsVersion: body.OSVersion,
Avatar: body.Avatar,
Birth: body.Birth,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
isNewUser := resp.GetIsNewUser()
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
}
// registerThirdParty 处理资料页完成时的三方注册,不返回 profile_required 中间态。
func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.Request) {
var body struct {
Provider string `json:"provider"`
Credential string `json:"credential"`
DeviceID string `json:"device_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Gender string `json:"gender"`
Country string `json:"country"`
InviteCode string `json:"invite_code"`
Device string `json:"device"`
OSVersion string `json:"os_version"`
Birth string `json:"birth"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Source string `json:"source"`
InstallChannel string `json:"install_channel"`
Campaign string `json:"campaign"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
deviceID: body.DeviceID,
provider: body.Provider,
}) {
return
}
if !h.allowLoginRisk(writer, request, loginRiskInput{
Channel: body.Provider,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
// 注册专用入口的 register_device/os_version 快照缺 body 时回退设备头。
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
resp, err := h.userClient.RegisterThirdParty(request.Context(), &userv1.RegisterThirdPartyRequest{
Meta: meta,
Provider: body.Provider,
Credential: body.Credential,
DeviceId: body.DeviceID,
Username: body.Username,
Avatar: body.Avatar,
Gender: body.Gender,
Country: body.Country,
InviteCode: body.InviteCode,
Device: body.Device,
OsVersion: body.OSVersion,
Birth: body.Birth,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
Platform: body.Platform,
Language: body.Language,
Timezone: body.Timezone,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
isNewUser := resp.GetIsNewUser()
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
}
// checkThirdPartyRegistered 只校验三方授权是否已绑定完整注册用户,不创建用户和 session。
func (h *Handler) checkThirdPartyRegistered(writer http.ResponseWriter, request *http.Request) {
var body struct {
Provider string `json:"provider"`
Credential string `json:"credential"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
provider: body.Provider,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.CheckThirdPartyRegistered(request.Context(), &userv1.CheckThirdPartyRegisteredRequest{
Meta: authRequestMeta(request, ""),
Provider: body.Provider,
Credential: body.Credential,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]bool{"registered": resp.GetRegistered()})
}
// setPassword 处理已登录用户首次设置密码。
func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {
Password string `json:"password"`
}
if !decode(writer, request, &body) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.SetPassword(request.Context(), &userv1.SetPasswordRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Password: body.Password,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]bool{"password_set": resp.GetPasswordSet()})
}
// refreshToken 处理 refresh token 轮换。
func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request) {
var body struct {
RefreshToken string `json:"refresh_token"`
DeviceID string `json:"device_id"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationRefresh,
deviceID: body.DeviceID,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.RefreshToken(request.Context(), &userv1.RefreshTokenRequest{
// refresh 是登录态自动恢复的一部分,必须携带当前安装包版本;否则升级后画像会一直停留在旧显式登录版本。
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, "", "", "", body.AppVersion, body.BuildNumber),
RefreshToken: body.RefreshToken,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, authData(resp.GetToken(), nil))
}
// logout 处理 refresh session 失效。
func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
var body struct {
RefreshToken string `json:"refresh_token"`
SessionID string `json:"session_id"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationLogout,
sessionID: body.SessionID,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.Logout(request.Context(), &userv1.LogoutRequest{
Meta: authRequestMeta(request, ""),
SessionId: body.SessionID,
RefreshToken: body.RefreshToken,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]bool{"revoked": resp.GetRevoked()})
}
// deviceFieldsWithHeaderFallback 在 body 未携带设备资料时回退到全局设备头。
// body 字段属于旧三方登录协议,标准 Flutter 客户端只发 X-Device-Model/X-OS-Version 头;
// 两个来源必须在 gateway 收敛一次注册快照users 表)与登录审计设备档案才不会互相矛盾。
func deviceFieldsWithHeaderFallback(bodyDevice string, bodyOSVersion string, meta *userv1.RequestMeta) (string, string) {
device := strings.TrimSpace(bodyDevice)
if device == "" {
device = meta.GetDeviceModel()
}
osVersion := strings.TrimSpace(bodyOSVersion)
if osVersion == "" {
osVersion = meta.GetOsVersion()
}
return device, osVersion
}
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
return authRequestMetaWithLoginContext(request, deviceID, "", "", "", "", "")
}
func authRequestMetaWithLoginContext(
request *http.Request,
deviceID string,
platform string,
language string,
timezone string,
bodyAppVersion string,
bodyBuildNumber string,
) *userv1.RequestMeta {
// 标准 App 客户端统一从 Header 上报版本body 只为仍按旧三方登录协议接入的客户端兜底。
// 两个值必须在 gateway 只解析一次并同时写入 Meta 和三方注册字段,避免审计版本与设备资料版本互相矛盾。
appVersion := httpkit.FirstHeader(request, "X-App-Version")
if appVersion == "" {
appVersion = strings.TrimSpace(bodyAppVersion)
}
buildNumber := httpkit.FirstHeader(request, "X-App-Build-Number")
if buildNumber == "" {
buildNumber = strings.TrimSpace(bodyBuildNumber)
}
return &userv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
DeviceId: deviceID,
ClientIp: clientIP(request),
UserAgent: request.UserAgent(),
CountryByIp: countryByIP(request),
SessionId: auth.SessionIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Language: strings.TrimSpace(language),
Timezone: strings.TrimSpace(timezone),
AppVersion: appVersion,
BuildNumber: buildNumber,
// 设备资料头由 App 全局注入;认证成功后 user-service 按 device_id 聚合进 user_devices。
DeviceModel: httpkit.FirstHeader(request, "X-Device-Model"),
DeviceManufacturer: httpkit.FirstHeader(request, "X-Device-Manufacturer"),
OsVersion: httpkit.FirstHeader(request, "X-OS-Version"),
Imei: httpkit.FirstHeader(request, "X-Device-IMEI"),
}
}
// authData 把 user-service 的 token response 转成外部 HTTP 扁平 data。
func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
if token == nil {
return authTokenData{IsNewUser: isNewUser}
}
return authTokenData{
UserID: httpkit.UserIDString(token.GetUserId()),
AppCode: token.GetAppCode(),
DisplayUserID: token.GetDisplayUserId(),
DefaultDisplayUserID: token.GetDefaultDisplayUserId(),
DisplayUserIDKind: token.GetDisplayUserIdKind(),
DisplayUserIDExpiresAtMs: token.GetDisplayUserIdExpiresAtMs(),
SessionID: token.GetSessionId(),
AccessToken: token.GetAccessToken(),
RefreshToken: token.GetRefreshToken(),
ExpiresInSec: token.GetExpiresInSec(),
TokenType: token.GetTokenType(),
IsNewUser: isNewUser,
ProfileCompleted: token.GetProfileCompleted(),
OnboardingStatus: token.GetOnboardingStatus(),
}
}
// clientIP 提取 gateway 看到的客户端地址X-Real-IP 在线上可能是入口代理地址,所以只在 XFF 没有公网段时兜底使用。
func clientIP(request *http.Request) string {
if ip := normalizeIP(request.Header.Get("X-Trusted-Client-IP")); ip != "" {
return ip
}
if ip := firstPublicForwardedIP(request.Header.Get("X-Forwarded-For")); ip != "" {
return ip
}
if ip := publicHeaderIP(request.Header.Get("X-Real-IP")); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return normalizeIP(request.RemoteAddr)
}
return host
}
func publicHeaderIP(value string) string {
ip := normalizeIP(value)
if ip == "" {
return ""
}
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
return ip
}
return ""
}
func firstPublicForwardedIP(forwarded string) string {
forwarded = strings.TrimSpace(forwarded)
if forwarded == "" {
return ""
}
for part := range strings.SplitSeq(forwarded, ",") {
ip := normalizeIP(part)
if ip == "" {
continue
}
if addr, err := netip.ParseAddr(ip); err == nil && isPublicClientAddr(addr) {
return ip
}
}
return ""
}
func normalizeIP(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
if addr, err := netip.ParseAddr(value); err == nil {
return addr.String()
}
return value
}
func isPublicClientAddr(addr netip.Addr) bool {
return addr.IsValid() &&
addr.IsGlobalUnicast() &&
!addr.IsPrivate() &&
!addr.IsLoopback() &&
!addr.IsLinkLocalUnicast() &&
!addr.IsLinkLocalMulticast() &&
!addr.IsMulticast() &&
!addr.IsUnspecified()
}
func countryByIP(request *http.Request) string {
// country_by_ip 不接受 JSON body只采信 gateway/边缘层写入的地域头。
for _, header := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Vercel-IP-Country", "X-AppEngine-Country", "X-Country-Code"} {
country := strings.ToUpper(strings.TrimSpace(request.Header.Get(header)))
if country == "" || country == "XX" || country == "UNKNOWN" {
continue
}
if len(country) >= 2 && len(country) <= 3 {
return country
}
}
return ""
}