2026-07-12 00:47:20 +08:00

655 lines
21 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)
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)
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)
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()})
}
// 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,
}
}
// 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 ""
}