feat(server/auth): switch user identity to phone with email optional

Phone becomes the required, unique login identifier; email and name turn
optional. Login now accepts either via a single identifier field, refresh
re-checks tenant membership, and the dev seed provides phone numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:01:36 +08:00
parent a6b17bae62
commit ee21f512a9
11 changed files with 225 additions and 40 deletions
+28 -6
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -37,8 +38,9 @@ func NewAuthService(
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Identifier string `json:"identifier"`
Email string `json:"email"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
@@ -50,8 +52,9 @@ type LoginResponse struct {
type UserInfo struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
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"`
@@ -86,11 +89,23 @@ type KolProfileBrief struct {
}
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
user, err := s.repo.GetUserByEmail(ctx, req.Email)
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")
}
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
if err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
if user.Status != "active" {
return nil, response.ErrForbidden(40311, "user_disabled", "user account is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
}
@@ -126,6 +141,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
User: UserInfo{
ID: user.ID,
Email: user.Email,
Phone: user.Phone,
Name: user.Name,
AvatarURL: user.AvatarURL,
TenantID: membership.TenantID,
@@ -154,7 +170,12 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired")
}
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role)
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)
}
@@ -202,6 +223,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
return &UserInfo{
ID: user.ID,
Email: user.Email,
Phone: user.Phone,
Name: user.Name,
AvatarURL: user.AvatarURL,
TenantID: actor.TenantID,