From a83001de0d1097761490a9ba83a7972af9166814 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Tue, 28 Apr 2026 17:18:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(locale):=20=E5=AE=9E=E7=8E=B0=20App=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=B6=E8=AF=AD=E8=A8=80=E5=92=8C=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=E8=87=AA=E5=8A=A8=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增系统语言/时区读取工具函数 - SessionStore 扩展支持时区存储 - 启动流程自动检测并保存系统语言/时区 - 注册时传递语言/时区到后端 - 登录后从服务器同步语言/时区 --- apps/lib/app/app.dart | 45 +++- apps/lib/core/auth/session_store.dart | 9 + apps/lib/core/localization/system_locale.dart | 27 ++ apps/lib/core/timezone/system_timezone.dart | 5 + .../lib/features/auth/data/apis/auth_api.dart | 10 +- .../data/repositories/auth_repository.dart | 11 +- .../auth/presentation/bloc/auth_bloc.dart | 9 +- apps/lib/shared/utils/time_format.dart | 30 +++ backend/src/v1/auth/router.py | 21 +- backend/src/v1/auth/schemas.py | 2 + .../test_locale_timezone_bootstrap.py | 247 ++++++++++++++++++ 11 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 apps/lib/core/localization/system_locale.dart create mode 100644 apps/lib/core/timezone/system_timezone.dart create mode 100644 apps/lib/shared/utils/time_format.dart create mode 100644 backend/tests/integration/test_locale_timezone_bootstrap.py diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 72da6e7..ad98b26 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../core/auth/session_store.dart'; +import '../core/localization/system_locale.dart'; import '../core/logging/logger.dart'; +import '../core/timezone/system_timezone.dart'; import '../data/network/api_client.dart'; import '../data/storage/local_kv_store.dart'; import '../features/auth/data/apis/auth_api.dart'; @@ -42,6 +44,7 @@ class _EryaoAppState extends State { late final NotificationRepository _notificationRepository; late final NotificationBloc _notificationBloc; Locale _locale = const Locale('zh'); + String _timezone = 'Asia/Shanghai'; ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), ); @@ -300,11 +303,14 @@ class _EryaoAppState extends State { if (!mounted) { return; } - final serverLanguage = profile.preferences.interfaceLanguage; + final serverLanguage = profile.preferences.language; + final serverTimezone = profile.preferences.timezone; final serverLocale = localeFromLanguageTag(serverLanguage); await _sessionStore.saveLocaleTag(serverLanguage); + await _sessionStore.saveTimezone(serverTimezone); setState(() { _locale = serverLocale; + _timezone = serverTimezone; _profileSettings = profile; _loadedProfileUserEmail = userEmail; }); @@ -352,8 +358,8 @@ class _EryaoAppState extends State { Future _saveProfileSettings(ProfileSettingsV1 next) async { try { - final oldLanguage = _profileSettings.preferences.interfaceLanguage; - final newLanguage = next.preferences.interfaceLanguage; + final oldLanguage = _profileSettings.preferences.language; + final newLanguage = next.preferences.language; final saved = await _profileApi.updateSettings(next); if (!mounted) { return; @@ -383,13 +389,29 @@ class _EryaoAppState extends State { } Future _bootstrap() async { - final localeTag = await _sessionStore.getLocaleTag(); - final locale = localeTag != null - ? localeFromLanguageTag(localeTag) - : const Locale('zh'); + final savedLocaleTag = await _sessionStore.getLocaleTag(); + final Locale locale; + if (savedLocaleTag != null) { + locale = localeFromLanguageTag(savedLocaleTag); + } else { + final systemLocale = getSystemLocale(); + locale = resolveSystemLocale(systemLocale) ?? const Locale('zh'); + await _sessionStore.saveLocaleTag(languageTagFromLocale(locale)); + } + + final savedTimezone = await _sessionStore.getTimezone(); + final String timezone; + if (savedTimezone != null) { + timezone = savedTimezone; + } else { + timezone = await getSystemTimezone(); + await _sessionStore.saveTimezone(timezone); + } + if (mounted) { setState(() { _locale = locale; + _timezone = timezone; _profileSettings = ProfileSettingsV1.defaultsForLocale(locale); }); } @@ -406,7 +428,7 @@ class _EryaoAppState extends State { _locale = locale; _profileSettings = _profileSettings.copyWith( preferences: _profileSettings.preferences.copyWith( - interfaceLanguage: languageTag, + language: languageTag, ), ); }); @@ -486,7 +508,12 @@ class _EryaoAppState extends State { onLocaleChanged: (_) {}, onRequestOtp: _authBloc.sendOtp, onLoginWithOtp: (email, otp) { - return _authBloc.loginWithOtp(email: email, otp: otp); + return _authBloc.loginWithOtp( + email: email, + otp: otp, + language: languageTagFromLocale(_locale), + timezone: _timezone, + ); }, ); } diff --git a/apps/lib/core/auth/session_store.dart b/apps/lib/core/auth/session_store.dart index 83f111d..12c7e0f 100644 --- a/apps/lib/core/auth/session_store.dart +++ b/apps/lib/core/auth/session_store.dart @@ -14,6 +14,7 @@ class SessionStore { static const String _emailKey = 'saved_email'; static const String _welcomeReadKey = 'has_seen_welcome_dialog'; static const String _localeKey = 'selected_locale'; + static const String _timezoneKey = 'selected_timezone'; Future saveToken(String token) async { await _secureStorage.write(key: _tokenKey, value: token); @@ -66,4 +67,12 @@ class SessionStore { Future getLocaleTag() async { return _kvStore.getString(_localeKey); } + + Future saveTimezone(String timezone) async { + await _kvStore.setString(_timezoneKey, timezone); + } + + Future getTimezone() async { + return _kvStore.getString(_timezoneKey); + } } diff --git a/apps/lib/core/localization/system_locale.dart b/apps/lib/core/localization/system_locale.dart new file mode 100644 index 0000000..273a32b --- /dev/null +++ b/apps/lib/core/localization/system_locale.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +Locale? resolveSystemLocale(Locale systemLocale) { + final lang = systemLocale.languageCode.toLowerCase(); + final script = systemLocale.scriptCode; + final country = systemLocale.countryCode; + + if (lang == 'en') { + return const Locale('en'); + } + + if (lang == 'zh') { + if (script == 'Hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + } + return const Locale('zh'); + } + + return null; +} + +Locale getSystemLocale() { + return PlatformDispatcher.instance.locale; +} diff --git a/apps/lib/core/timezone/system_timezone.dart b/apps/lib/core/timezone/system_timezone.dart new file mode 100644 index 0000000..0a2c62c --- /dev/null +++ b/apps/lib/core/timezone/system_timezone.dart @@ -0,0 +1,5 @@ +import 'package:flutter_timezone/flutter_timezone.dart'; + +Future getSystemTimezone() async { + return FlutterTimezone.getLocalTimezone(); +} diff --git a/apps/lib/features/auth/data/apis/auth_api.dart b/apps/lib/features/auth/data/apis/auth_api.dart index 4647c2b..bfaa7a5 100644 --- a/apps/lib/features/auth/data/apis/auth_api.dart +++ b/apps/lib/features/auth/data/apis/auth_api.dart @@ -19,10 +19,18 @@ class AuthApi { Future createEmailSession({ required String email, required String token, + String? language, + String? timezone, }) async { + final data = { + 'email': email, + 'token': token, + }; + if (language != null) data['language'] = language; + if (timezone != null) data['timezone'] = timezone; final json = await _apiClient.postJson( '/api/v1/auth/email-session', - data: {'email': email, 'token': token}, + data: data, ); return SessionResponse.fromJson(json); } diff --git a/apps/lib/features/auth/data/repositories/auth_repository.dart b/apps/lib/features/auth/data/repositories/auth_repository.dart index 5e4141b..d497fd3 100644 --- a/apps/lib/features/auth/data/repositories/auth_repository.dart +++ b/apps/lib/features/auth/data/repositories/auth_repository.dart @@ -9,6 +9,8 @@ abstract class AuthRepository { Future loginWithEmailOtp({ required String email, required String otp, + String? language, + String? timezone, }); Future recoverSession(); @@ -37,8 +39,15 @@ class AuthRepositoryImpl implements AuthRepository { Future loginWithEmailOtp({ required String email, required String otp, + String? language, + String? timezone, }) async { - final session = await _authApi.createEmailSession(email: email, token: otp); + final session = await _authApi.createEmailSession( + email: email, + token: otp, + language: language, + timezone: timezone, + ); await _sessionStore.saveToken(session.accessToken); await _sessionStore.saveRefreshToken(session.refreshToken); await _sessionStore.saveEmail(email); diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index 86a0715..8730bff 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -50,8 +50,15 @@ class AuthBloc extends ChangeNotifier { Future loginWithOtp({ required String email, required String otp, + String? language, + String? timezone, }) async { - final user = await _repository.loginWithEmailOtp(email: email, otp: otp); + final user = await _repository.loginWithEmailOtp( + email: email, + otp: otp, + language: language, + timezone: timezone, + ); _logger.info(message: 'User logged in', extra: {'user_id': user.id}); _state = AuthState(status: AuthStatus.authenticated, user: user); notifyListeners(); diff --git a/apps/lib/shared/utils/time_format.dart b/apps/lib/shared/utils/time_format.dart new file mode 100644 index 0000000..ca56e15 --- /dev/null +++ b/apps/lib/shared/utils/time_format.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +import '../../l10n/app_localizations.dart'; + +String formatRelativeTime(BuildContext context, DateTime dateTime) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(dateTime); + if (diff.inMinutes < 1) return l10n.timeJustNow; + if (diff.inHours < 1) return l10n.timeMinutesAgo(diff.inMinutes); + if (diff.inDays < 1) return l10n.timeHoursAgo(diff.inHours); + if (diff.inDays < 30) return l10n.timeDaysAgo(diff.inDays); + return '${dateTime.month}/${dateTime.day}'; +} + +String formatCompactLocalDateTime(String isoString) { + final dateTime = DateTime.parse(isoString).toLocal(); + final now = DateTime.now(); + final timeText = + '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + + if (now.year == dateTime.year && + now.month == dateTime.month && + now.day == dateTime.day) { + return timeText; + } + + final dateText = + '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}'; + return '$dateText $timeText'; +} diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index de106ad..645349d 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -22,6 +22,7 @@ from v1.auth.schemas import ( from v1.auth.service import AuthService from v1.points.repository import PointsRepository from v1.points.service import PointsService +from v1.users.repository import SQLAlchemyUserRepository router = APIRouter(prefix="/auth", tags=["auth"]) @@ -72,14 +73,30 @@ async def create_email_session( window_seconds=300, ) result = await service.create_email_session(payload) + user_id = UUID(result.user.id) + + if payload.language is not None or payload.timezone is not None: + user_repository = SQLAlchemyUserRepository(session) + profile = await user_repository.get_profile_by_user_id(user_id=user_id) + if profile is not None: + settings: dict[str, object] = dict(profile.settings or {}) + prefs_raw = settings.get("preferences", {}) + preferences: dict[str, object] = dict(prefs_raw) if isinstance(prefs_raw, dict) else {} + if payload.language is not None: + preferences["language"] = payload.language + if payload.timezone is not None: + preferences["timezone"] = payload.timezone + settings["preferences"] = preferences + profile.settings = settings + points_service = PointsService(repository=PointsRepository(session)) bonus_result = await points_service.grant_register_bonus_if_eligible( - user_id=UUID(result.user.id), + user_id=user_id, user_email=result.user.email, ) notification_service = NotificationService(NotificationRepository(session)) linked_count = await notification_service.link_notifications_for_registered_user( - user_id=UUID(result.user.id), + user_id=user_id, is_first_registration=bonus_result.is_first_registration, ) await session.commit() diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 3a3240a..655c07a 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -17,6 +17,8 @@ class EmailSessionCreateRequest(BaseModel): email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) token: str = Field(min_length=6, max_length=6) + language: str | None = Field(default=None, max_length=20) + timezone: str | None = Field(default=None, max_length=50) class SessionRefreshRequest(BaseModel): diff --git a/backend/tests/integration/test_locale_timezone_bootstrap.py b/backend/tests/integration/test_locale_timezone_bootstrap.py new file mode 100644 index 0000000..320613c --- /dev/null +++ b/backend/tests/integration/test_locale_timezone_bootstrap.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import time + +import httpx +import pytest + +from core.db.session import AsyncSessionLocal +from models.profile import Profile +from sqlalchemy import select + + +async def _create_email_session( + client: httpx.AsyncClient, + *, + email: str, + code: str, + language: str | None = None, + timezone: str | None = None, +) -> dict: + payload: dict = {"email": email, "token": code} + if language is not None: + payload["language"] = language + if timezone is not None: + payload["timezone"] = timezone + resp = await client.post( + "/api/v1/auth/email-session", + json=payload, + ) + resp.raise_for_status() + return resp.json() + + +async def _get_profile( + client: httpx.AsyncClient, + *, + access_token: str, +) -> dict: + headers = {"Authorization": f"Bearer {access_token}"} + resp = await client.get("/api/v1/users/me/profile", headers=headers) + resp.raise_for_status() + return resp.json() + + +@pytest.mark.integration +async def test_new_user_registration_with_language_timezone( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景1: 新用户首次打开App,注册时语言和时区写入后端""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="America/New_York", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "en-US", f"Expected en-US, got {preferences['language']}" + assert preferences["timezone"] == "America/New_York", f"Expected America/New_York, got {preferences['timezone']}" + + +@pytest.mark.integration +async def test_new_user_registration_with_zh_hant( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景1变体: 新用户注册时使用繁体中文""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="zh-Hant", + timezone="Asia/Taipei", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-Hant" + assert preferences["timezone"] == "Asia/Taipei" + + +@pytest.mark.integration +async def test_new_user_registration_without_language_timezone( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景2: 新用户注册时不传语言时区,使用后端默认值""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-CN", "Default language should be zh-CN" + assert preferences["timezone"] == "Asia/Shanghai", "Default timezone should be Asia/Shanghai" + + +@pytest.mark.integration +async def test_existing_user_login_sync_from_server( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景2: 已有用户在新设备登录,从服务器同步语言时区""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session1 = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="Europe/London", + ) + access_token1 = str(session1["access_token"]) + + profile1 = await _get_profile(api_client, access_token=access_token1) + preferences1 = profile1["settings"]["preferences"] + assert preferences1["language"] == "en-US" + assert preferences1["timezone"] == "Europe/London" + + time.sleep(1) + + session2 = await _create_email_session( + api_client, + email=email, + code=code, + ) + access_token2 = str(session2["access_token"]) + + profile2 = await _get_profile(api_client, access_token=access_token2) + preferences2 = profile2["settings"]["preferences"] + + assert preferences2["language"] == "en-US", "Should sync language from server" + assert preferences2["timezone"] == "Europe/London", "Should sync timezone from server" + + +@pytest.mark.integration +async def test_partial_language_only( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """边界情况: 只传语言不传时区""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="zh-Hant", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-Hant" + assert preferences["timezone"] == "Asia/Shanghai", "Timezone should use default" + + +@pytest.mark.integration +async def test_partial_timezone_only( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """边界情况: 只传时区不传语言""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + timezone="Australia/Sydney", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-CN", "Language should use default" + assert preferences["timezone"] == "Australia/Sydney" + + +@pytest.mark.integration +async def test_profile_settings_persisted_in_database( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """验证语言时区正确持久化到数据库""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="America/Los_Angeles", + ) + user_id = str(session_data["user"]["id"]) + + async with AsyncSessionLocal() as db_session: + stmt = select(Profile).where(Profile.id == user_id) + result = await db_session.execute(stmt) + profile = result.scalar_one_or_none() + + assert profile is not None, "Profile should exist in database" + settings = profile.settings + assert settings is not None + preferences = settings.get("preferences", {}) + assert preferences.get("language") == "en-US" + assert preferences.get("timezone") == "America/Los_Angeles"