package app import ( "context" "errors" "fmt" "strings" "time" "golang.org/x/crypto/bcrypt" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) type AuthService struct { repo repository.AuthRepository plans repository.TenantPlanRepository kolProfiles repository.KolProfileRepository jwt *auth.Manager sessions *auth.SessionStore loginGuard *auth.LoginGuard } func NewAuthService( repo repository.AuthRepository, plans repository.TenantPlanRepository, kolProfiles repository.KolProfileRepository, jwt *auth.Manager, sessions *auth.SessionStore, loginGuards ...*auth.LoginGuard, ) *AuthService { var loginGuard *auth.LoginGuard if len(loginGuards) > 0 { loginGuard = loginGuards[0] } return &AuthService{ repo: repo, plans: plans, kolProfiles: kolProfiles, jwt: jwt, sessions: sessions, loginGuard: loginGuard, } } type LoginRequest struct { Identifier string `json:"identifier"` Email string `json:"email"` Password string `json:"password" binding:"required"` IP string `json:"-"` } type LoginResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` User UserInfo `json:"user"` } type UserInfo struct { ID int64 `json:"id"` Email *string `json:"email"` Phone string `json:"phone"` Name *string `json:"name"` AvatarURL *string `json:"avatar_url"` TenantID int64 `json:"tenant_id"` PrimaryWorkspaceID int64 `json:"primary_workspace_id"` TenantRole string `json:"tenant_role"` Permissions []string `json:"permissions"` Membership *MembershipInfo `json:"membership"` KolProfile *KolProfileBrief `json:"kol_profile"` } type MembershipInfo struct { PlanCode string `json:"plan_code"` PlanName string `json:"plan_name"` Status string `json:"status"` Blocked bool `json:"blocked"` BlockedReason *string `json:"blocked_reason"` StartAt *string `json:"start_at"` EndAt *string `json:"end_at"` ArticleGenerationLimit int `json:"article_generation_limit"` ArticleQuotaCycle string `json:"article_quota_cycle"` AIPointsMonthly int `json:"ai_points_monthly"` AIPointBaseChars int `json:"ai_point_base_chars"` CompanyLimit int `json:"company_limit"` ImageStorageBytes int64 `json:"image_storage_bytes"` ContactAdminOnExpiry bool `json:"contact_admin_on_expiry"` } type KolProfileBrief struct { ID int64 `json:"id"` DisplayName string `json:"display_name"` Status string `json:"status"` MarketEnabled bool `json:"market_enabled"` } const userBlockedReasonDisabled = "user_disabled" func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { identifier := strings.TrimSpace(req.Identifier) if identifier == "" { identifier = strings.TrimSpace(req.Email) } if identifier == "" { return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required") } permit, err := s.acquireLoginPermit(ctx, identifier, req.IP) if err != nil { return nil, err } credentialsVerified := false defer func() { if credentialsVerified { permit.RecordSuccess(ctx) return } permit.Release(ctx) }() user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier) if err != nil { permit.RecordFailure(ctx) return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { permit.RecordFailure(ctx) return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") } credentialsVerified = true membership, err := s.repo.GetTenantMembership(ctx, user.ID) if err != nil { return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership") } pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } tokenHash := auth.HashToken(pair.RefreshToken) if err := s.sessions.SaveRefresh(ctx, pair.RefreshJTI, tokenHash, s.jwt.RefreshTTL()); err != nil { return nil, fmt.Errorf("save refresh: %w", err) } kolProfile, err := s.loadKolProfileBrief(ctx, membership.TenantID, user.ID) if err != nil { return nil, err } memberInfo, err := s.loadMembershipInfo(ctx, membership.TenantID) if err != nil { return nil, err } memberInfo = applyUserDisabledMembership(memberInfo, user.Status) return &LoginResponse{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresAt: pair.ExpiresAt, User: UserInfo{ ID: user.ID, Email: user.Email, Phone: user.Phone, Name: user.Name, AvatarURL: user.AvatarURL, TenantID: membership.TenantID, PrimaryWorkspaceID: membership.PrimaryWorkspaceID, TenantRole: membership.TenantRole, Permissions: auth.PermissionsForRole(membership.TenantRole), Membership: memberInfo, KolProfile: kolProfile, }, }, nil } func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) { if s.loginGuard == nil { return nil, nil } permit, err := s.loginGuard.Acquire(ctx, auth.LoginGuardSubject{ Identifier: identifier, IP: ip, }) if err == nil { return permit, nil } var blocked *auth.LoginGuardBlockedError if errors.As(err, &blocked) { detail := fmt.Sprintf("请 %d 秒后重试", blocked.RetryAfterSeconds()) switch blocked.Reason { case auth.LoginGuardBlockedInProgress: return nil, response.ErrTooManyRequests(42912, "login_in_progress", detail) case auth.LoginGuardBlockedLocked: return nil, response.ErrTooManyRequests(42911, "login_locked", detail) default: return nil, response.ErrTooManyRequests(42910, "login_rate_limited", detail) } } if errors.Is(err, auth.ErrLoginGuardUnavailable) { return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "login protection is temporarily unavailable") } return nil, err } type RefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` } type RefreshResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` } func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*RefreshResponse, error) { claims, err := s.jwt.Parse(req.RefreshToken) if err != nil || claims.Subject != "refresh" { return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired") } membership, err := s.repo.GetTenantMembershipByTenantAndUser(ctx, claims.TenantID, claims.UserID) if err != nil { return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active") } pair, err := s.jwt.Issue(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } if err := s.sessions.RotateRefresh( ctx, claims.ID, auth.HashToken(req.RefreshToken), pair.RefreshJTI, auth.HashToken(pair.RefreshToken), s.jwt.RefreshTTL(), ); err != nil { switch err { case auth.ErrRefreshSessionNotFound: return nil, response.ErrUnauthorized(40121, "refresh_session_expired", "refresh session not found") case auth.ErrRefreshTokenMismatch: return nil, response.ErrUnauthorized(40122, "refresh_token_mismatch", "refresh token does not match") default: return nil, fmt.Errorf("rotate refresh: %w", err) } } return &RefreshResponse{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresAt: pair.ExpiresAt, }, nil } func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, error) { user, err := s.repo.GetUserByID(ctx, actor.UserID) if err != nil { return nil, response.ErrNotFound(40401, "user_not_found", "user not found") } kolProfile, err := s.loadKolProfileBrief(ctx, actor.TenantID, actor.UserID) if err != nil { return nil, err } memberInfo, err := s.loadMembershipInfo(ctx, actor.TenantID) if err != nil { return nil, err } memberInfo = applyUserDisabledMembership(memberInfo, user.Status) return &UserInfo{ ID: user.ID, Email: user.Email, Phone: user.Phone, Name: user.Name, AvatarURL: user.AvatarURL, TenantID: actor.TenantID, PrimaryWorkspaceID: actor.PrimaryWorkspaceID, TenantRole: actor.Role, Permissions: auth.PermissionsForRole(actor.Role), Membership: memberInfo, KolProfile: kolProfile, }, nil } func applyUserDisabledMembership(info *MembershipInfo, userStatus string) *MembershipInfo { if strings.EqualFold(strings.TrimSpace(userStatus), "active") { return info } reason := userBlockedReasonDisabled if info == nil { return &MembershipInfo{ Status: "disabled", Blocked: true, BlockedReason: &reason, } } next := *info next.Status = "disabled" next.Blocked = true next.BlockedReason = &reason next.ContactAdminOnExpiry = true return &next } func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID int64) (*KolProfileBrief, error) { if s.kolProfiles == nil { return nil, nil } profile, err := s.kolProfiles.GetByUser(ctx, tenantID, userID) if err != nil { return nil, fmt.Errorf("get kol profile: %w", err) } if profile == nil || profile.Status != "active" { return nil, nil } return &KolProfileBrief{ ID: profile.ID, DisplayName: profile.DisplayName, Status: profile.Status, MarketEnabled: profile.MarketEnabled, }, nil } func (s *AuthService) loadMembershipInfo(ctx context.Context, tenantID int64) (*MembershipInfo, error) { if s.plans == nil { return nil, nil } now := time.Now().UTC() access, err := s.plans.GetTenantPlanAccess(ctx, tenantID, now) if err != nil { return nil, err } if access == nil { reason := "subscription_required" return &MembershipInfo{ Status: "inactive", Blocked: true, BlockedReason: &reason, }, nil } status := "active" if !access.HasActiveAccess(now) { status = "expired" } blockedReason := access.BlockedReason(now) var blockedReasonPtr *string if blockedReason != "" { blockedReasonPtr = &blockedReason } return &MembershipInfo{ PlanCode: access.PlanCode, PlanName: access.PlanName, Status: status, Blocked: !access.HasActiveAccess(now), BlockedReason: blockedReasonPtr, StartAt: formatTimeValuePtr(access.StartAt), EndAt: formatTimeValuePtr(access.EndAt), ArticleGenerationLimit: access.ArticleGenerationLimit(), ArticleQuotaCycle: access.ArticleQuotaCycle(), AIPointsMonthly: access.AIPointsMonthlyLimit(), AIPointBaseChars: access.AIPointBaseChars(), CompanyLimit: access.BrandLimit(), ImageStorageBytes: access.ImageStorageBytes(), ContactAdminOnExpiry: access.Policy.ContactAdminOnExpiry, }, nil } func formatTimeValuePtr(value time.Time) *string { if value.IsZero() { return nil } formatted := value.UTC().Format(time.RFC3339) return &formatted } func (s *AuthService) Logout(ctx context.Context, accessToken string) error { claims, err := s.jwt.Parse(accessToken) if err != nil { return nil } if claims.Subject == "access" && claims.ExpiresAt != nil { ttl := time.Until(claims.ExpiresAt.Time) if ttl > 0 { _ = s.sessions.Blacklist(ctx, claims.ID, ttl) } } return nil }