diff --git a/server/cmd/dev-seed/main.go b/server/cmd/dev-seed/main.go index fa3b505..5401191 100644 --- a/server/cmd/dev-seed/main.go +++ b/server/cmd/dev-seed/main.go @@ -326,15 +326,15 @@ func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, erro } func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) { - return ensureNamedUser(ctx, tx, "admin@geo.local", "Admin", passwordHash) + return ensureNamedUser(ctx, tx, "admin@geo.local", "13800138000", "Admin", passwordHash) } -func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) { +func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, phone, name, passwordHash string) (int64, error) { var userID int64 err := tx.QueryRow(ctx, ` WITH ensured AS ( - INSERT INTO users (email, password_hash, name, status) - VALUES ($1, $2, $3, 'active') + INSERT INTO users (email, phone, password_hash, name, status) + VALUES ($1, $2, $3, $4, 'active') ON CONFLICT DO NOTHING RETURNING id ) @@ -342,7 +342,7 @@ func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash s UNION ALL SELECT id FROM users WHERE email = $1 LIMIT 1 - `, email, passwordHash, name).Scan(&userID) + `, email, phone, passwordHash, name).Scan(&userID) return userID, wrapSeedErr("insert user", err) } @@ -412,7 +412,7 @@ func ensureSecondaryTenantUser( return 0, 0, err } - userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "test", string(hash)) + userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "13800138001", "test", string(hash)) if err != nil { return 0, 0, err } diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index 3e888e4..66bd04f 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -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, diff --git a/server/internal/tenant/domain/user.go b/server/internal/tenant/domain/user.go index 33e0652..c8674d6 100644 --- a/server/internal/tenant/domain/user.go +++ b/server/internal/tenant/domain/user.go @@ -4,10 +4,10 @@ import "time" type User struct { ID int64 - Email string - Phone *string + Email *string + Phone string PasswordHash string - Name string + Name *string AvatarURL *string Status string CreatedAt time.Time diff --git a/server/internal/tenant/repository/auth_repo.go b/server/internal/tenant/repository/auth_repo.go index 18c6366..fd276c2 100644 --- a/server/internal/tenant/repository/auth_repo.go +++ b/server/internal/tenant/repository/auth_repo.go @@ -4,15 +4,17 @@ import ( "context" "time" + "github.com/jackc/pgx/v5/pgtype" + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" ) type UserRecord struct { ID int64 - Email string - Phone *string + Email *string + Phone string PasswordHash string - Name string + Name *string AvatarURL *string Status string CreatedAt time.Time @@ -30,6 +32,7 @@ type MembershipRecord struct { type AuthRepository interface { GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) + GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error) GetUserByID(ctx context.Context, id int64) (*UserRecord, error) GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error) GetTenantMembershipByTenantAndUser(ctx context.Context, tenantID, userID int64) (*MembershipRecord, error) @@ -44,17 +47,36 @@ func NewAuthRepository(db generated.DBTX) AuthRepository { } func (r *authRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) { - row, err := r.q.GetUserByEmail(ctx, email) + row, err := r.q.GetUserByEmail(ctx, pgtype.Text{String: email, Valid: true}) if err != nil { return nil, err } return &UserRecord{ ID: row.ID, - Email: row.Email, - Phone: nullableText(row.Phone), + Email: nullableText(row.Email), + Phone: row.Phone, PasswordHash: row.PasswordHash, - Name: row.Name, + Name: nullableText(row.Name), + AvatarURL: nullableText(row.AvatarUrl), + Status: row.Status, + CreatedAt: timeFromTimestamp(row.CreatedAt), + UpdatedAt: timeFromTimestamp(row.UpdatedAt), + }, nil +} + +func (r *authRepository) GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error) { + row, err := r.q.GetUserByLoginIdentifier(ctx, pgtype.Text{String: identifier, Valid: true}) + if err != nil { + return nil, err + } + + return &UserRecord{ + ID: row.ID, + Email: nullableText(row.Email), + Phone: row.Phone, + PasswordHash: row.PasswordHash, + Name: nullableText(row.Name), AvatarURL: nullableText(row.AvatarUrl), Status: row.Status, CreatedAt: timeFromTimestamp(row.CreatedAt), @@ -70,10 +92,10 @@ func (r *authRepository) GetUserByID(ctx context.Context, id int64) (*UserRecord return &UserRecord{ ID: row.ID, - Email: row.Email, - Phone: nullableText(row.Phone), + Email: nullableText(row.Email), + Phone: row.Phone, PasswordHash: row.PasswordHash, - Name: row.Name, + Name: nullableText(row.Name), AvatarURL: nullableText(row.AvatarUrl), Status: row.Status, CreatedAt: timeFromTimestamp(row.CreatedAt), diff --git a/server/internal/tenant/repository/generated/auth.sql.go b/server/internal/tenant/repository/generated/auth.sql.go index 5db9eaa..64b2017 100644 --- a/server/internal/tenant/repository/generated/auth.sql.go +++ b/server/internal/tenant/repository/generated/auth.sql.go @@ -101,17 +101,17 @@ WHERE email = $1 AND deleted_at IS NULL type GetUserByEmailRow struct { ID int64 `json:"id"` - Email string `json:"email"` - Phone pgtype.Text `json:"phone"` + Email pgtype.Text `json:"email"` + Phone string `json:"phone"` PasswordHash string `json:"password_hash"` - Name string `json:"name"` + Name pgtype.Text `json:"name"` AvatarUrl pgtype.Text `json:"avatar_url"` Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) { +func (q *Queries) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) { row := q.db.QueryRow(ctx, getUserByEmail, email) var i GetUserByEmailRow err := row.Scan( @@ -136,10 +136,10 @@ WHERE id = $1 AND deleted_at IS NULL type GetUserByIDRow struct { ID int64 `json:"id"` - Email string `json:"email"` - Phone pgtype.Text `json:"phone"` + Email pgtype.Text `json:"email"` + Phone string `json:"phone"` PasswordHash string `json:"password_hash"` - Name string `json:"name"` + Name pgtype.Text `json:"name"` AvatarUrl pgtype.Text `json:"avatar_url"` Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -162,3 +162,39 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er ) return i, err } + +const getUserByLoginIdentifier = `-- name: GetUserByLoginIdentifier :one +SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at +FROM users +WHERE (email = $1 OR phone = $1) + AND deleted_at IS NULL +` + +type GetUserByLoginIdentifierRow struct { + ID int64 `json:"id"` + Email pgtype.Text `json:"email"` + Phone string `json:"phone"` + PasswordHash string `json:"password_hash"` + Name pgtype.Text `json:"name"` + AvatarUrl pgtype.Text `json:"avatar_url"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error) { + row := q.db.QueryRow(ctx, getUserByLoginIdentifier, loginIdentifier) + var i GetUserByLoginIdentifierRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Phone, + &i.PasswordHash, + &i.Name, + &i.AvatarUrl, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/internal/tenant/repository/generated/models.go b/server/internal/tenant/repository/generated/models.go index 1943922..f55237d 100644 --- a/server/internal/tenant/repository/generated/models.go +++ b/server/internal/tenant/repository/generated/models.go @@ -21,6 +21,27 @@ type AiPlatform struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type AiPointUsageLog struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + OperatorID pgtype.Int8 `json:"operator_id"` + QuotaReservationID pgtype.Int8 `json:"quota_reservation_id"` + UsageType string `json:"usage_type"` + ResourceType pgtype.Text `json:"resource_type"` + ResourceID pgtype.Int8 `json:"resource_id"` + ResourceUid pgtype.Text `json:"resource_uid"` + RequestChars int32 `json:"request_chars"` + BaseChars int32 `json:"base_chars"` + Points int32 `json:"points"` + Status string `json:"status"` + Model pgtype.Text `json:"model"` + MetadataJson []byte `json:"metadata_json"` + ErrorMessage pgtype.Text `json:"error_message"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Article struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -149,6 +170,15 @@ type DesktopClient struct { RevokedAt pgtype.Timestamptz `json:"revoked_at"` } +type DesktopClientPrimaryLease struct { + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + PrimaryClientID pgtype.UUID `json:"primary_client_id"` + LeaseExpiresAt pgtype.Timestamptz `json:"lease_expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type DesktopPublishJob struct { ID int64 `json:"id"` DesktopID pgtype.UUID `json:"desktop_id"` @@ -708,10 +738,10 @@ type TenantQuotaLedger struct { type User struct { ID int64 `json:"id"` - Email string `json:"email"` - Phone pgtype.Text `json:"phone"` + Email pgtype.Text `json:"email"` + Phone string `json:"phone"` PasswordHash string `json:"password_hash"` - Name string `json:"name"` + Name pgtype.Text `json:"name"` AvatarUrl pgtype.Text `json:"avatar_url"` Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 6ad50c2..42b3980 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -6,6 +6,8 @@ package generated import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) type Querier interface { @@ -90,8 +92,9 @@ type Querier interface { GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error) GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error) GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) - GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) + GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) + GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error) HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error) diff --git a/server/internal/tenant/repository/queries/auth.sql b/server/internal/tenant/repository/queries/auth.sql index 5c5ec7e..634af14 100644 --- a/server/internal/tenant/repository/queries/auth.sql +++ b/server/internal/tenant/repository/queries/auth.sql @@ -3,6 +3,12 @@ SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, up FROM users WHERE email = sqlc.arg(email) AND deleted_at IS NULL; +-- name: GetUserByLoginIdentifier :one +SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at +FROM users +WHERE (email = sqlc.arg(login_identifier) OR phone = sqlc.arg(login_identifier)) + AND deleted_at IS NULL; + -- name: GetUserByID :one SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at FROM users diff --git a/server/migrations/20260331100001_create_users.up.sql b/server/migrations/20260331100001_create_users.up.sql index ee5a57d..bbde355 100644 --- a/server/migrations/20260331100001_create_users.up.sql +++ b/server/migrations/20260331100001_create_users.up.sql @@ -1,13 +1,19 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL, - phone VARCHAR(20), + email VARCHAR(255), + phone VARCHAR(20) NOT NULL, password_hash VARCHAR(255) NOT NULL, - name VARCHAR(100) NOT NULL, + name VARCHAR(100), avatar_url TEXT, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); -CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL; +CREATE UNIQUE INDEX uk_users_phone_active ON users(phone) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_email_trgm_active ON users USING GIN (email gin_trgm_ops) WHERE email IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX idx_users_phone_trgm_active ON users USING GIN (phone gin_trgm_ops) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_name_trgm_active ON users USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL AND deleted_at IS NULL; diff --git a/server/migrations/20260428120000_prepare_user_phone_identity.down.sql b/server/migrations/20260428120000_prepare_user_phone_identity.down.sql new file mode 100644 index 0000000..d09a430 --- /dev/null +++ b/server/migrations/20260428120000_prepare_user_phone_identity.down.sql @@ -0,0 +1,24 @@ +DROP INDEX IF EXISTS idx_users_name_trgm_active; +DROP INDEX IF EXISTS idx_users_phone_trgm_active; +DROP INDEX IF EXISTS idx_users_email_trgm_active; +DROP INDEX IF EXISTS idx_users_status_created_at; +DROP INDEX IF EXISTS uk_users_phone_active; + +UPDATE users +SET email = 'user-' || id || '@rollback.local' +WHERE email IS NULL; + +UPDATE users +SET name = COALESCE(NULLIF(phone, ''), email, 'user-' || id) +WHERE name IS NULL; + +ALTER TABLE users + ALTER COLUMN email SET NOT NULL, + ALTER COLUMN phone DROP NOT NULL, + ALTER COLUMN name SET NOT NULL; + +DROP INDEX IF EXISTS uk_users_email_active; + +CREATE UNIQUE INDEX uk_users_email_active + ON users(email) + WHERE deleted_at IS NULL; diff --git a/server/migrations/20260428120000_prepare_user_phone_identity.up.sql b/server/migrations/20260428120000_prepare_user_phone_identity.up.sql new file mode 100644 index 0000000..4b549da --- /dev/null +++ b/server/migrations/20260428120000_prepare_user_phone_identity.up.sql @@ -0,0 +1,36 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +UPDATE users +SET phone = '0' || LPAD(id::text, 19, '0') +WHERE phone IS NULL; + +ALTER TABLE users + ALTER COLUMN email DROP NOT NULL, + ALTER COLUMN phone SET NOT NULL, + ALTER COLUMN name DROP NOT NULL; + +DROP INDEX IF EXISTS uk_users_email_active; + +CREATE UNIQUE INDEX uk_users_email_active + ON users(email) + WHERE email IS NOT NULL AND deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_phone_active + ON users(phone) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_users_status_created_at + ON users(status, created_at DESC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_users_email_trgm_active + ON users USING GIN (email gin_trgm_ops) + WHERE email IS NOT NULL AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_users_phone_trgm_active + ON users USING GIN (phone gin_trgm_ops) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_users_name_trgm_active + ON users USING GIN (name gin_trgm_ops) + WHERE name IS NOT NULL AND deleted_at IS NULL;