feat: 切换邮箱认证并重构前后端启动与门禁

This commit is contained in:
qzl
2026-04-02 18:39:35 +08:00
parent 92cdfd9fca
commit 31594558eb
116 changed files with 5608 additions and 628 deletions
+58 -96
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import re
import time
from typing import Any, cast
@@ -8,17 +9,19 @@ from pydantic import ValidationError
from supabase import AuthError
from core.config.settings import config
from core.http.errors import ApiProblemError
from core.logging import get_logger
from services.base.supabase import supabase_service
from v1.auth.dev_email_session import create_dev_email_session
from v1.auth.schemas import (
AuthUser,
EmailSessionCreateRequest,
OtpSendRequest,
PhoneSessionCreateRequest,
SessionRefreshRequest,
SessionResponse,
UserByIdResponse,
UserByPhoneResponse,
UserByEmailResponse,
)
from v1.auth.service import AuthServiceGateway
@@ -40,7 +43,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
def __init__(self) -> None:
self._user_lookup_cache_ttl_seconds: int = 60
self._user_lookup_cache_expires_at: float = 0.0
self._users_by_phone: dict[str, Any] = {}
self._users_by_email: dict[str, Any] = {}
self._users_by_id: dict[str, Any] = {}
def _get_client(self) -> Any:
@@ -52,7 +55,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def send_otp(self, request: OtpSendRequest) -> None:
client = self._get_client()
payload: dict[str, Any] = {
"phone": request.phone,
"email": request.email,
"options": {"should_create_user": True},
}
try:
@@ -72,13 +75,23 @@ class SupabaseAuthGateway(AuthServiceGateway):
detail="Too many requests",
) from exc
async def create_phone_session(
self, request: PhoneSessionCreateRequest
async def create_email_session(
self, request: EmailSessionCreateRequest
) -> SessionResponse:
if config.runtime.environment == "dev":
return await create_dev_email_session(
request=request,
client=self._get_client(),
admin_client=self._get_admin_client(),
auth_unavailable_detail=AUTH_UNAVAILABLE_DETAIL,
is_auth_upstream_unavailable=_is_auth_upstream_unavailable,
map_auth_response=_map_auth_response,
)
client = self._get_client()
payload: dict[str, Any] = {
"type": "sms",
"phone": request.phone,
"type": "email",
"email": request.email,
"token": request.token,
}
try:
@@ -90,7 +103,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
"AUTH_VERIFICATION_CODE_INVALID",
)
except AuthError as exc:
logger.warning("Create phone session failed", error_type=type(exc).__name__)
logger.warning("Create email session failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise _auth_error(
status_code=503,
@@ -169,9 +182,9 @@ class SupabaseAuthGateway(AuthServiceGateway):
detail="Invalid refresh token",
) from exc
async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse:
normalized_phone = _normalize_phone(phone)
if not normalized_phone:
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
normalized_email = _normalize_email(email)
if not normalized_email:
raise _auth_error(
status_code=404,
code="AUTH_USER_NOT_FOUND",
@@ -180,7 +193,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
await self._refresh_user_lookup_cache_if_needed()
user = self._users_by_phone.get(normalized_phone)
user = self._users_by_email.get(normalized_email)
if user is None:
raise _auth_error(
status_code=404,
@@ -188,21 +201,21 @@ class SupabaseAuthGateway(AuthServiceGateway):
detail="User not found",
)
user_phone = _normalize_phone(getattr(user, "phone", ""))
if not user_phone:
user_email = _normalize_email(getattr(user, "email", ""))
if not user_email:
raise _auth_error(
status_code=404,
code="AUTH_USER_NOT_FOUND",
detail="User not found",
)
return UserByPhoneResponse(
return UserByEmailResponse(
id=str(getattr(user, "id", "")),
phone=user_phone,
email=user_email,
created_at=str(getattr(user, "created_at", "")),
phone_confirmed_at=(
str(getattr(user, "phone_confirmed_at", ""))
if getattr(user, "phone_confirmed_at", None)
email_confirmed_at=(
str(getattr(user, "email_confirmed_at", ""))
if getattr(user, "email_confirmed_at", None)
else None
),
)
@@ -233,53 +246,27 @@ class SupabaseAuthGateway(AuthServiceGateway):
user_attrs = getattr(user, "user", user)
resolved[normalized_user_id] = UserByIdResponse(
id=str(getattr(user_attrs, "id", "")),
phone=getattr(user_attrs, "phone", None),
email=getattr(user_attrs, "email", None),
created_at=str(getattr(user_attrs, "created_at", "")),
phone_confirmed_at=(
str(getattr(user_attrs, "phone_confirmed_at", ""))
if getattr(user_attrs, "phone_confirmed_at", None)
email_confirmed_at=(
str(getattr(user_attrs, "email_confirmed_at", ""))
if getattr(user_attrs, "email_confirmed_at", None)
else None
),
)
return resolved
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
normalized_query = _normalize_phone_search_query(query)
async def search_user_ids_by_email(self, query: str, limit: int = 20) -> list[str]:
normalized_query = _normalize_email(query)
if not normalized_query:
return []
await self._refresh_user_lookup_cache_if_needed()
if normalized_query.startswith("+"):
matched_user = self._users_by_phone.get(normalized_query)
if matched_user is None:
return []
user_id = str(getattr(matched_user, "id", ""))
return [user_id] if user_id else []
digits = _digits_only(normalized_query)
if not digits:
matched_user = self._users_by_email.get(normalized_query)
if matched_user is None:
return []
matched_records: list[tuple[str, str]] = []
for cached_phone, candidate in self._users_by_phone.items():
candidate_digits = _digits_only(cached_phone)
if not candidate_digits.endswith(digits):
continue
user_id = str(getattr(candidate, "id", ""))
if user_id:
matched_records.append((cached_phone, user_id))
if not matched_records:
return []
unique_ids: list[str] = []
for _, user_id in sorted(matched_records, key=lambda item: item[0]):
if user_id in unique_ids:
continue
unique_ids.append(user_id)
if len(unique_ids) >= max(1, limit):
break
return unique_ids
user_id = str(getattr(matched_user, "id", ""))
return [user_id] if user_id else []
async def _refresh_user_lookup_cache_if_needed(self) -> None:
now = time.monotonic()
@@ -288,17 +275,17 @@ class SupabaseAuthGateway(AuthServiceGateway):
admin_client = self._get_admin_client()
users = await asyncio.to_thread(_list_auth_users, admin_client)
users_by_phone: dict[str, Any] = {}
users_by_email: dict[str, Any] = {}
users_by_id: dict[str, Any] = {}
for candidate in users:
candidate_id = str(getattr(candidate, "id", "")).strip()
if candidate_id:
users_by_id[candidate_id] = candidate
candidate_phone = _normalize_phone(getattr(candidate, "phone", ""))
if candidate_phone:
users_by_phone[candidate_phone] = candidate
candidate_email = _normalize_email(getattr(candidate, "email", ""))
if candidate_email:
users_by_email[candidate_email] = candidate
self._users_by_id = users_by_id
self._users_by_phone = users_by_phone
self._users_by_email = users_by_email
self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds
@@ -343,8 +330,8 @@ def _map_auth_response(
detail=failure_message,
)
phone = _normalize_phone(getattr(user, "phone", None))
if not phone:
email = _normalize_email(getattr(user, "email", None))
if not email:
raise _auth_error(
status_code=401,
code=failure_code,
@@ -352,10 +339,10 @@ def _map_auth_response(
)
try:
auth_user = AuthUser(id=str(user.id), phone=str(phone))
auth_user = AuthUser(id=str(user.id), email=str(email))
except ValidationError as exc:
logger.warning(
"Auth response returned invalid phone format",
"Auth response returned invalid email format",
error_type=type(exc).__name__,
)
raise _auth_error(
@@ -393,38 +380,13 @@ def _list_auth_users(client: Any) -> list[Any]:
return users
def _sanitize_phone_token(raw: object) -> str:
token = str(raw).strip()
for separator in (" ", "-", "(", ")"):
token = token.replace(separator, "")
return token
_EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
def _normalize_phone(raw_phone: object) -> str | None:
phone = _sanitize_phone_token(raw_phone)
if not phone:
def _normalize_email(raw_email: object) -> str | None:
if not isinstance(raw_email, str):
return None
if phone.startswith("00") and len(phone) > 2:
return f"+{phone[2:]}"
if phone.startswith("+"):
return phone
if phone.isdigit():
return f"+{phone}"
return None
def _normalize_phone_search_query(raw_query: str) -> str | None:
query = _sanitize_phone_token(raw_query)
if not query:
email = raw_email.strip().lower()
if not _EMAIL_PATTERN.fullmatch(email):
return None
if query.startswith("00") and len(query) > 2:
return f"+{query[2:]}"
if query.startswith("+"):
return query
if query.isdigit():
return query
return None
def _digits_only(value: str) -> str:
return "".join(ch for ch in value if ch.isdigit())
return email