package service import ( "context" "encoding/json" "net/http" "strings" "time" "cmr-backend/internal/apperr" "cmr-backend/internal/platform/jwtx" "cmr-backend/internal/platform/security" "cmr-backend/internal/platform/wechatmini" "cmr-backend/internal/store/postgres" ) type AuthSettings struct { AppEnv string RefreshTTL time.Duration SMSCodeTTL time.Duration SMSCodeCooldown time.Duration SMSProvider string DevSMSCode string WechatMini *wechatmini.Client } type AuthService struct { cfg AuthSettings store *postgres.Store jwtManager *jwtx.Manager } type SendSMSCodeInput struct { CountryCode string `json:"countryCode"` Mobile string `json:"mobile"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` Scene string `json:"scene"` } type SendSMSCodeResult struct { TTLSeconds int64 `json:"ttlSeconds"` CooldownSeconds int64 `json:"cooldownSeconds"` DevCode *string `json:"devCode,omitempty"` } type LoginSMSInput struct { CountryCode string `json:"countryCode"` Mobile string `json:"mobile"` Code string `json:"code"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } type LoginWechatMiniInput struct { Code string `json:"code"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } type BindMobileInput struct { UserID string `json:"-"` CountryCode string `json:"countryCode"` Mobile string `json:"mobile"` Code string `json:"code"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } type RefreshTokenInput struct { RefreshToken string `json:"refreshToken"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } type LogoutInput struct { RefreshToken string `json:"refreshToken"` UserID string `json:"-"` } type AuthUser struct { ID string `json:"id"` PublicID string `json:"publicId"` Status string `json:"status"` Nickname *string `json:"nickname,omitempty"` AvatarURL *string `json:"avatarUrl,omitempty"` } type AuthTokens struct { AccessToken string `json:"accessToken"` AccessTokenExpiresAt string `json:"accessTokenExpiresAt"` RefreshToken string `json:"refreshToken"` RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"` } type AuthResult struct { User AuthUser `json:"user"` Tokens AuthTokens `json:"tokens"` NewUser bool `json:"newUser"` } func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService { return &AuthService{ cfg: cfg, store: store, jwtManager: jwtManager, } } func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) { input.CountryCode = normalizeCountryCode(input.CountryCode) input.Mobile = normalizeMobile(input.Mobile) input.Scene = normalizeScene(input.Scene) if err := validateClientType(input.ClientType); err != nil { return nil, err } if input.Mobile == "" || input.DeviceKey == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required") } latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene) if err != nil { return nil, err } now := time.Now().UTC() if latest != nil && latest.CooldownUntil.After(now) { return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently") } code := s.cfg.DevSMSCode if code == "" { code, err = security.GenerateNumericCode(6) if err != nil { return nil, err } } expiresAt := now.Add(s.cfg.SMSCodeTTL) cooldownUntil := now.Add(s.cfg.SMSCodeCooldown) if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{ Scene: input.Scene, CountryCode: input.CountryCode, Mobile: input.Mobile, ClientType: input.ClientType, DeviceKey: input.DeviceKey, CodeHash: security.HashText(code), ProviderName: s.cfg.SMSProvider, ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider}, ExpiresAt: expiresAt, CooldownUntil: cooldownUntil, }); err != nil { return nil, err } result := &SendSMSCodeResult{ TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()), CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()), } if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") { result.DevCode = &code } return result, nil } func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) { input.CountryCode = normalizeCountryCode(input.CountryCode) input.Mobile = normalizeMobile(input.Mobile) input.Code = strings.TrimSpace(input.Code) if err := validateClientType(input.ClientType); err != nil { return nil, err } if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required") } codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login") if err != nil { return nil, err } if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) { return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code") } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID) if err != nil { return nil, err } if !consumed { return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used") } user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile) if err != nil { return nil, err } newUser := false if user == nil { userPublicID, err := security.GeneratePublicID("usr") if err != nil { return nil, err } user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{ PublicID: userPublicID, Status: "active", }) if err != nil { return nil, err } if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{ UserID: user.ID, CountryCode: input.CountryCode, Mobile: input.Mobile, Provider: "mobile", ProviderSubj: input.CountryCode + ":" + input.Mobile, IdentityType: "mobile", }); err != nil { return nil, err } newUser = true } if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil { return nil, err } result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return result, nil } func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) { input.RefreshToken = strings.TrimSpace(input.RefreshToken) if err := validateClientType(input.ClientType); err != nil { return nil, err } if input.RefreshToken == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required") } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken)) if err != nil { return nil, err } if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) { return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired") } if input.ClientType != "" && input.ClientType != record.ClientType { return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch") } if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey { return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch") } user, err := s.store.GetUserByID(ctx, tx, record.UserID) if err != nil { return nil, err } if user == nil { return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found") } result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false) if err != nil { return nil, err } if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return result, nil } func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) { input.Code = strings.TrimSpace(input.Code) if err := validateClientType(input.ClientType); err != nil { return nil, err } if input.ClientType != "wechat" { return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat") } if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required") } if s.cfg.WechatMini == nil { return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured") } session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code) if err != nil { return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error()) } openIDSubject := session.AppID + ":" + session.OpenID unionIDSubject := strings.TrimSpace(session.UnionID) tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject) if err != nil { return nil, err } if user == nil && unionIDSubject != "" { user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject) if err != nil { return nil, err } } newUser := false if user == nil { userPublicID, err := security.GeneratePublicID("usr") if err != nil { return nil, err } user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{ PublicID: userPublicID, Status: "active", }) if err != nil { return nil, err } newUser = true } profileJSON, err := json.Marshal(map[string]any{ "appId": session.AppID, }) if err != nil { return nil, err } if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{ UserID: user.ID, IdentityType: "wechat_mini_openid", Provider: "wechat_mini", ProviderSubj: openIDSubject, ProfileJSON: string(profileJSON), }); err != nil { return nil, err } if unionIDSubject != "" { if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{ UserID: user.ID, IdentityType: "wechat_unionid", Provider: "wechat_unionid", ProviderSubj: unionIDSubject, ProfileJSON: "{}", }); err != nil { return nil, err } } if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil { return nil, err } result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return result, nil } func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) { input.CountryCode = normalizeCountryCode(input.CountryCode) input.Mobile = normalizeMobile(input.Mobile) input.Code = strings.TrimSpace(input.Code) if err := validateClientType(input.ClientType); err != nil { return nil, err } if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" { return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required") } codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile") if err != nil { return nil, err } if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) { return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code") } tx, err := s.store.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID) if err != nil { return nil, err } if !consumed { return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used") } currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID) if err != nil { return nil, err } if currentUser == nil { return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found") } mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile) if err != nil { return nil, err } finalUser := currentUser newlyBound := false if mobileUser == nil { if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{ UserID: currentUser.ID, CountryCode: input.CountryCode, Mobile: input.Mobile, Provider: "mobile", ProviderSubj: input.CountryCode + ":" + input.Mobile, IdentityType: "mobile", }); err != nil { return nil, err } newlyBound = true } else if mobileUser.ID != currentUser.ID { if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil { return nil, err } if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil { return nil, err } if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil { return nil, err } finalUser = mobileUser } if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil { return nil, err } result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return result, nil } func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error { if strings.TrimSpace(input.RefreshToken) == "" { return nil } return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken))) } func (s *AuthService) issueAuthResult( ctx context.Context, tx postgres.Tx, user postgres.User, clientType string, deviceKey string, newUser bool, ) (*AuthResult, error) { result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser) return result, err } func (s *AuthService) issueAuthResultWithRefreshID( ctx context.Context, tx postgres.Tx, user postgres.User, clientType string, deviceKey string, newUser bool, ) (*AuthResult, string, error) { accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID) if err != nil { return nil, "", err } refreshToken, err := security.GenerateToken(32) if err != nil { return nil, "", err } refreshTokenHash := security.HashText(refreshToken) refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL) refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{ UserID: user.ID, ClientType: clientType, DeviceKey: deviceKey, TokenHash: refreshTokenHash, ExpiresAt: refreshExpiresAt, }) if err != nil { return nil, "", err } return &AuthResult{ User: AuthUser{ ID: user.ID, PublicID: user.PublicID, Status: user.Status, Nickname: user.Nickname, AvatarURL: user.AvatarURL, }, Tokens: AuthTokens{ AccessToken: accessToken, AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339), RefreshToken: refreshToken, RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339), }, NewUser: newUser, }, refreshID, nil } func validateClientType(clientType string) error { switch clientType { case "app", "wechat": return nil default: return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat") } } func normalizeCountryCode(value string) string { value = strings.TrimSpace(value) if value == "" { return "86" } return strings.TrimPrefix(value, "+") } func normalizeMobile(value string) string { value = strings.TrimSpace(value) value = strings.ReplaceAll(value, " ", "") value = strings.ReplaceAll(value, "-", "") return value } func normalizeScene(value string) string { value = strings.TrimSpace(value) if value == "" { return "login" } return value } func nullableStringValue(value *string) string { if value == nil { return "" } return *value }