2026-06-26 10:51:43 +08:00

625 lines
20 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"`
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
}
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
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
}
resp, err := h.userClient.QuickCreateAccount(request.Context(), &userv1.QuickCreateAccountRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
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: body.AppVersion,
BuildNumber: body.BuildNumber,
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
}
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
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: body.AppVersion,
BuildNumber: body.BuildNumber,
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
}
resp, err := h.userClient.RegisterThirdParty(request.Context(), &userv1.RegisterThirdPartyRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
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: body.AppVersion,
BuildNumber: body.BuildNumber,
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"`
}
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{
Meta: authRequestMeta(request, body.DeviceID),
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) *userv1.RequestMeta {
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),
}
}
// 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 ""
}