diff --git a/AGENTS.md b/AGENTS.md index f2eaaf4..ae2dd3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,35 +1,39 @@ -## Repository Structure +# Project Development Guide -- `infra/`: Infrastructure and operations (Docker, scripts, deployment). -- `backend/`: FastAPI backend. -- `apps/`: Flutter mobile app. -- `docs/`: Documentation and design/planning artifacts. +This file serves as the entry point for project development, directing to appropriate constraint files based on development context. + +## Project Structure + +``` +social-app/ +├── apps/ # Flutter mobile app +├── backend/ # FastAPI backend service +├── infra/ # Infrastructure (Docker, deployment scripts) +└── docs/ # Documentation and design/planning artifacts +``` ## Rules Hierarchy -- This root `AGENTS.md` defines global rules and applies to all changes. -- When editing `backend/`, you must also follow `backend/AGENTS.md`. -- When editing `apps/`, you must also follow `apps/AGENTS.md`. +Follow this hierarchy when developing: -## Docker Startup - -Always start services with the env file: - -```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml up -d +``` +~/.config/opencode/AGENTS.md # Global core rules (skills, agents, process) +├── This file (root AGENTS.md) # Project-level entry +│ ├── backend/AGENTS.md # Backend-specific rules +│ └── apps/AGENTS.md # Frontend-specific rules ``` -## Git Branch and Worktree Policy +## Development Guidance -- Use `dev` as the default base branch for day-to-day development. -- New development worktrees must be created from `dev` (never from `main`). -- Do not develop or commit directly on `main` outside explicit release/merge workflows. -- Do not rewrite `main` history unless explicitly requested (including reset and force push). +| Development Context | Follow Rules | +|--------------------|--------------| +| Backend Python dev | [backend/AGENTS.md](backend/AGENTS.md) | +| Flutter mobile dev | [apps/AGENTS.md](apps/AGENTS.md) | +| Infrastructure/ops | This file + infra/ directory conventions | +| API doc changes | Sync to `docs/runtime/runtime-route.md` | -## API Route Documentation +## Git Workflow -When modifying HTTP routes (adding, updating, or removing endpoints): - -- Sync changes to `docs/runtime/runtime-route.md` -- Include: HTTP method, path, request/response schema, status codes, error format -- Keep documentation in sync with actual implementation +- Default branch: `dev` +- Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev` +- Never develop directly on `main` diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 7328c8b..f00e1bc 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -1,16 +1,55 @@ -## Mobile Rules +# Flutter Mobile Development Rules -- Flutter mobile rules are maintained here. -- If no more specific rule is defined here, follow the root `AGENTS.md`. +This document defines Flutter mobile development constraints. -## Flutter Design-to-Code Workflow +## Design System -Before writing any Flutter UI code, follow this sequence: +### Design Tokens -1. **Get editor state**: Use `pencil_get_editor_state` to confirm the active design. -2. **Get structure**: Use `pencil_batch_get` to inspect node hierarchy and layout. -3. **Get variables**: Use `pencil_get_variables` to fetch colors, typography, and tokens. -4. **Implement**: Match design values and container hierarchy exactly. +All UI styling must use design tokens from `apps/lib/core/theme/design_tokens.dart`: + +| Type | Usage | +|------|-------| +| Colors | `AppColors.primary`, `AppColors.slate500`, `AppColors.background` | +| Spacing | `AppSpacing.xs`, `AppSpacing.sm`, `AppSpacing.md` | +| Radius | `AppRadius.sm`, `AppRadius.md`, `AppRadius.lg` | + +**NEVER hardcode colors, sizes, or spacing values.** + +### Reuse Existing Components + +Use pre-built components instead of creating custom ones: +- Buttons: Use `AppButton` widget from `apps/lib/shared/widgets/app_button.dart` +- Input fields: Use standard Flutter `TextField` with `InputDecoration` +- Loading states: Use built-in loading indicators + +## New Page Design Workflow + +1. **Analyze existing pages**: Study login, register, home screens for: + - Layout structure (centered form, padding, spacing) + - Typography hierarchy (title 28px bold, label 13px, hint 14px) + - Component usage (AppButton, TextField style) + - Color and spacing tokens + +2. **Use frontend-design skill for mockups**: + ``` + Use the `frontend-design` skill to create HTML/CSS mockups for review + Match colors to `apps/lib/core/theme/design_tokens.dart` + Match spacing to `AppSpacing` values + Match radius to `AppRadius` values + ``` + +3. **Verify design tokens**: + - All colors from `AppColors` + - All spacing from `AppSpacing` + - All radius from `AppRadius` + - NO hardcoded values + +4. **Code review checklist**: + - [ ] All colors/spacing/radius use design tokens + - [ ] Reuses existing components (AppButton) + - [ ] Consistent with existing page patterns + - [ ] No magic numbers ## Layout Mapping Rules @@ -21,15 +60,13 @@ Map design layout properties to Flutter explicitly: - `alignItems: start` -> `CrossAxisAlignment.start` - `alignItems: stretch` -> `CrossAxisAlignment.stretch` 2. **Map full container chain**: From root to leaf, ensure each `alignItems` and `justifyContent` has a Flutter equivalent. -3. **Analyze before coding**: Use `pencil_snapshot_layout` or `pencil_batch_get` to verify each container's alignment settings. +3. **Analyze before coding**: Verify each container's alignment settings. ## Centering and Visual Balance -Apply these rules on any screen that relies on centered composition: - -1. Centering must be evaluated inside **`SafeArea` bounds**, not full-screen bounds. +1. Centering must be evaluated inside **`SafeArea`** bounds, not full-screen bounds. 2. Avoid relying on proportional `Spacer` values as the only centering mechanism for critical content. -3. For layouts with persistent top/bottom regions (for example headers or footers), center the primary content in the remaining available region. +3. For layouts with persistent top/bottom regions (e.g., headers or footers), center the primary content in the remaining available region. 4. Distinguish geometric centering from visual centering; validate final visual balance with screenshot review. ## Quality Gate @@ -41,10 +78,10 @@ For important screens, add widget tests that reduce layout-regression risk: ## Prohibitions -- Do not use colors or themes not defined in the design. -- Do not skip design container layers. -- Do not start implementation before retrieving design variables. -- Do not hardcode colors; use design variables. +- DO NOT use colors not defined in design tokens +- DO NOT skip design container layers +- DO NOT start implementation before retrieving design variables +- DO NOT hardcode colors; use design variables ## UI Feedback System @@ -82,5 +119,5 @@ AppBanner(message: '请检查输入', type: ToastType.warning) - Use `Toast` for transient feedback that auto-dismisses - Use `AppBanner` for persistent inline messages (form errors) -- Do NOT create custom SnackBar, Dialog, or Banner components -- Do NOT use raw `ScaffoldMessenger` +- DO NOT create custom SnackBar, Dialog, or Banner components +- DO NOT use raw `ScaffoldMessenger` diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 77b614b..4f0a0b4 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -5,6 +5,7 @@ import 'go_router_refresh_stream.dart'; import '../../features/auth/ui/screens/login_screen.dart'; import '../../features/auth/ui/screens/register_screen.dart'; import '../../features/auth/ui/screens/register_verification_screen.dart'; +import '../../features/auth/ui/screens/reset_password_screen.dart'; import '../../features/home/ui/screens/home_screen.dart'; import '../../features/messages/ui/screens/message_invite_list_screen.dart'; import '../../features/messages/ui/screens/message_invite_detail_screen.dart'; @@ -67,6 +68,10 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: '/register/verification', builder: (context, state) => const RegisterVerificationScreen(), ), + GoRoute( + path: '/reset-password', + builder: (context, state) => const ResetPasswordScreen(), + ), GoRoute(path: '/home', builder: (context, state) => const HomeScreen()), GoRoute( path: '/messages/invites', diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/auth_api.dart index 2798c7a..2fa3afb 100644 --- a/apps/lib/features/auth/data/auth_api.dart +++ b/apps/lib/features/auth/data/auth_api.dart @@ -50,4 +50,19 @@ class AuthApi { Future deleteSession(LogoutRequest request) async { await _client.delete('$_prefix/sessions', data: request.toJson()); } + + Future requestPasswordReset(String email) async { + await _client.post('$_prefix/password-reset', data: {'email': email}); + } + + Future confirmPasswordReset({ + required String email, + required String token, + required String newPassword, + }) async { + await _client.post( + '$_prefix/password-reset/confirm', + data: {'email': email, 'token': token, 'new_password': newPassword}, + ); + } } diff --git a/apps/lib/features/auth/data/auth_repository.dart b/apps/lib/features/auth/data/auth_repository.dart index e7850d5..f4cc2e9 100644 --- a/apps/lib/features/auth/data/auth_repository.dart +++ b/apps/lib/features/auth/data/auth_repository.dart @@ -14,4 +14,10 @@ abstract class AuthRepository { Future getAccessToken(); Future getRefreshToken(); Future isAuthenticated(); + Future requestPasswordReset(String email); + Future confirmPasswordReset({ + required String email, + required String token, + required String newPassword, + }); } diff --git a/apps/lib/features/auth/data/auth_repository_impl.dart b/apps/lib/features/auth/data/auth_repository_impl.dart index 6dc079c..2447f9d 100644 --- a/apps/lib/features/auth/data/auth_repository_impl.dart +++ b/apps/lib/features/auth/data/auth_repository_impl.dart @@ -77,4 +77,22 @@ class AuthRepositoryImpl implements AuthRepository { final token = await _tokenStorage.getAccessToken(); return token != null; } + + @override + Future requestPasswordReset(String email) { + return _api.requestPasswordReset(email); + } + + @override + Future confirmPasswordReset({ + required String email, + required String token, + required String newPassword, + }) { + return _api.confirmPasswordReset( + email: email, + token: token, + newPassword: newPassword, + ); + } } diff --git a/apps/lib/features/auth/data/models/signup_request.dart b/apps/lib/features/auth/data/models/signup_request.dart index 55e59d8..c04ee7b 100644 --- a/apps/lib/features/auth/data/models/signup_request.dart +++ b/apps/lib/features/auth/data/models/signup_request.dart @@ -2,17 +2,20 @@ class SignupStartRequest { final String username; final String email; final String password; + final String? inviteCode; const SignupStartRequest({ required this.username, required this.email, required this.password, + this.inviteCode, }); Map toJson() => { 'username': username, 'email': email, 'password': password, + if (inviteCode != null) 'invite_code': inviteCode, }; } diff --git a/apps/lib/features/auth/presentation/cubits/register_cubit.dart b/apps/lib/features/auth/presentation/cubits/register_cubit.dart index 7de8f69..4fab03c 100644 --- a/apps/lib/features/auth/presentation/cubits/register_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/register_cubit.dart @@ -12,6 +12,7 @@ class RegisterState extends Equatable { final Email email; final Password password; final VerificationCode verificationCode; + final String inviteCode; final FormzSubmissionStatus status; final String? errorMessage; final String? pendingEmail; @@ -23,6 +24,7 @@ class RegisterState extends Equatable { this.email = const Email.pure(), this.password = const Password.pure(), this.verificationCode = const VerificationCode.pure(), + this.inviteCode = '', this.status = FormzSubmissionStatus.initial, this.errorMessage, this.pendingEmail, @@ -39,6 +41,7 @@ class RegisterState extends Equatable { Email? email, Password? password, VerificationCode? verificationCode, + String? inviteCode, FormzSubmissionStatus? status, String? errorMessage, String? pendingEmail, @@ -50,6 +53,7 @@ class RegisterState extends Equatable { email: email ?? this.email, password: password ?? this.password, verificationCode: verificationCode ?? this.verificationCode, + inviteCode: inviteCode ?? this.inviteCode, status: status ?? this.status, errorMessage: errorMessage, pendingEmail: pendingEmail ?? this.pendingEmail, @@ -64,6 +68,7 @@ class RegisterState extends Equatable { email, password, verificationCode, + inviteCode, status, errorMessage, pendingEmail, @@ -93,6 +98,10 @@ class RegisterCubit extends Cubit { emit(state.copyWith(verificationCode: VerificationCode.dirty(value))); } + void inviteCodeChanged(String value) { + emit(state.copyWith(inviteCode: value)); + } + Future submitStep1() async { if (!state.isStep1Valid) return false; @@ -104,6 +113,7 @@ class RegisterCubit extends Cubit { username: state.username.value, email: state.email.value, password: state.password.value, + inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null, ), ); emit( @@ -202,6 +212,7 @@ class RegisterCubit extends Cubit { username: state.username.value, email: state.email.value, password: state.password.value, + inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null, ), ); emit( diff --git a/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart b/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart new file mode 100644 index 0000000..2a74a32 --- /dev/null +++ b/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart @@ -0,0 +1,314 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:formz/formz.dart'; +import '../../../../core/form_inputs/form_inputs.dart'; +import '../../data/auth_repository.dart'; + +class ResetPasswordState extends Equatable { + final Email email; + final VerificationCode code; + final Password newPassword; + final Password confirmPassword; + final FormzSubmissionStatus status; + final String? errorMessage; + final bool isSuccess; + final int resendCountdown; + final bool codeSent; + + const ResetPasswordState({ + this.email = const Email.pure(), + this.code = const VerificationCode.pure(), + this.newPassword = const Password.pure(), + this.confirmPassword = const Password.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + this.isSuccess = false, + this.resendCountdown = 0, + this.codeSent = false, + }); + + bool get canSubmit { + if (!codeSent) { + return email.isValid && status != FormzSubmissionStatus.inProgress; + } + return email.isValid && + code.isValid && + newPassword.isValid && + confirmPassword.isValid && + newPassword.value == confirmPassword.value && + status != FormzSubmissionStatus.inProgress; + } + + ResetPasswordState copyWith({ + Email? email, + VerificationCode? code, + Password? newPassword, + Password? confirmPassword, + FormzSubmissionStatus? status, + String? errorMessage, + bool? isSuccess, + int? resendCountdown, + bool? codeSent, + }) { + return ResetPasswordState( + email: email ?? this.email, + code: code ?? this.code, + newPassword: newPassword ?? this.newPassword, + confirmPassword: confirmPassword ?? this.confirmPassword, + status: status ?? this.status, + errorMessage: errorMessage, + isSuccess: isSuccess ?? this.isSuccess, + resendCountdown: resendCountdown ?? this.resendCountdown, + codeSent: codeSent ?? this.codeSent, + ); + } + + @override + List get props => [ + email, + code, + newPassword, + confirmPassword, + status, + errorMessage, + isSuccess, + resendCountdown, + codeSent, + ]; +} + +class ResetPasswordCubit extends Cubit { + final AuthRepository _repository; + Timer? _resendTimer; + + ResetPasswordCubit(this._repository) : super(const ResetPasswordState()); + + @override + Future close() { + _resendTimer?.cancel(); + return super.close(); + } + + void emailChanged(String value) { + emit(state.copyWith(email: Email.dirty(value), errorMessage: null)); + } + + void codeChanged(String value) { + emit( + state.copyWith(code: VerificationCode.dirty(value), errorMessage: null), + ); + } + + void newPasswordChanged(String value) { + emit( + state.copyWith(newPassword: Password.dirty(value), errorMessage: null), + ); + } + + void confirmPasswordChanged(String value) { + emit( + state.copyWith( + confirmPassword: Password.dirty(value), + errorMessage: null, + ), + ); + } + + Future sendCode() async { + if (state.status == FormzSubmissionStatus.inProgress || + state.resendCountdown > 0) { + return; + } + + if (!state.email.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确', + ), + ); + return; + } + + emit( + state.copyWith( + status: FormzSubmissionStatus.inProgress, + codeSent: true, + resendCountdown: 60, + errorMessage: null, + ), + ); + _startResendCountdown(); + + try { + await _repository.requestPasswordReset(state.email.value); + emit( + state.copyWith( + status: FormzSubmissionStatus.success, + errorMessage: 'CODE_SENT_SUCCESS', + ), + ); + } catch (e) { + _cancelResendCountdown(); + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + codeSent: false, + resendCountdown: 0, + errorMessage: '网络错误,请稍后重试', + ), + ); + } + } + + void _cancelResendCountdown() { + _resendTimer?.cancel(); + } + + void _startResendCountdown() { + _cancelResendCountdown(); + _resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final newCountdown = state.resendCountdown - 1; + if (newCountdown <= 0) { + timer.cancel(); + emit(state.copyWith(resendCountdown: 0)); + } else { + emit(state.copyWith(resendCountdown: newCountdown)); + } + }); + } + + Future resendCode() async { + if (state.resendCountdown > 0 || + state.status == FormzSubmissionStatus.inProgress) { + return; + } + + if (!state.email.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确', + ), + ); + return; + } + + emit( + state.copyWith( + status: FormzSubmissionStatus.inProgress, + codeSent: true, + resendCountdown: 60, + errorMessage: null, + ), + ); + _startResendCountdown(); + + try { + await _repository.requestPasswordReset(state.email.value); + emit( + state.copyWith( + status: FormzSubmissionStatus.success, + errorMessage: 'CODE_SENT_SUCCESS', + ), + ); + } catch (e) { + _cancelResendCountdown(); + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + resendCountdown: 0, + errorMessage: '网络错误,请稍后重试', + ), + ); + } + } + + Future submit() async { + if (!state.codeSent) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '请先获取验证码', + ), + ); + return; + } + + if (!state.email.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '请输入有效的邮箱地址', + ), + ); + return; + } + + if (!state.code.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '请输入6位验证码', + ), + ); + return; + } + + if (!state.newPassword.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '新密码至少6位', + ), + ); + return; + } + + if (!state.confirmPassword.isValid) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '请输入确认密码', + ), + ); + return; + } + + if (state.newPassword.value != state.confirmPassword.value) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '两次密码输入不一致', + ), + ); + return; + } + + emit( + state.copyWith( + status: FormzSubmissionStatus.inProgress, + errorMessage: null, + ), + ); + + try { + await _repository.confirmPasswordReset( + email: state.email.value, + token: state.code.value, + newPassword: state.newPassword.value, + ); + emit( + state.copyWith(status: FormzSubmissionStatus.success, isSuccess: true), + ); + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: '密码重置失败,请检查验证码', + ), + ); + } + } +} diff --git a/apps/lib/features/auth/ui/screens/login_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart index fba9ecc..dc5dc67 100644 --- a/apps/lib/features/auth/ui/screens/login_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -162,6 +162,8 @@ class _LoginViewState extends State { ? null : _handleLogin, ), + const SizedBox(height: 12), + _buildForgotPassword(), ], ), ); @@ -236,6 +238,20 @@ class _LoginViewState extends State { ); } + Widget _buildForgotPassword() { + return GestureDetector( + onTap: () => context.push('/reset-password'), + child: const Text( + '忘记密码?', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ); + } + Widget _buildFooter() { return GestureDetector( onTap: () => context.push('/register'), diff --git a/apps/lib/features/auth/ui/screens/register_screen.dart b/apps/lib/features/auth/ui/screens/register_screen.dart index 7afe657..cf8cf08 100644 --- a/apps/lib/features/auth/ui/screens/register_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_screen.dart @@ -36,6 +36,7 @@ class _RegisterViewState extends State { final _nicknameController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final _inviteCodeController = TextEditingController(); bool _obscureText = true; @override @@ -43,6 +44,7 @@ class _RegisterViewState extends State { _nicknameController.dispose(); _emailController.dispose(); _passwordController.dispose(); + _inviteCodeController.dispose(); super.dispose(); } @@ -51,6 +53,7 @@ class _RegisterViewState extends State { cubit.usernameChanged(_nicknameController.text); cubit.emailChanged(_emailController.text); cubit.passwordChanged(_passwordController.text); + cubit.inviteCodeChanged(_inviteCodeController.text); if (!cubit.state.isStep1Valid || cubit.state.isSending) { String? errorMsg; @@ -159,6 +162,8 @@ class _RegisterViewState extends State { const SizedBox(height: 12), _buildPasswordInput(), const SizedBox(height: 12), + _buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController), + const SizedBox(height: 12), _buildStepIndicator(), if (state.errorMessage != null) Padding( diff --git a/apps/lib/features/auth/ui/screens/register_verification_screen.dart b/apps/lib/features/auth/ui/screens/register_verification_screen.dart index 487986d..667f57c 100644 --- a/apps/lib/features/auth/ui/screens/register_verification_screen.dart +++ b/apps/lib/features/auth/ui/screens/register_verification_screen.dart @@ -48,10 +48,22 @@ class _RegisterVerificationViewState extends State { Timer? _countdownTimer; int _countdown = 0; bool _firstSendCompleted = false; + bool _hintShown = false; @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_hintShown) { + _hintShown = true; + Toast.show( + context, + '验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册', + type: ToastType.info, + duration: const Duration(seconds: 5), + ); + } + }); } @override @@ -331,7 +343,7 @@ class _RegisterVerificationViewState extends State { Widget _buildFooter() { return GestureDetector( - onTap: () => context.pop(), + onTap: () => context.go('/'), child: const Text( '已有账号?去登录', style: TextStyle( diff --git a/apps/lib/features/auth/ui/screens/reset_password_screen.dart b/apps/lib/features/auth/ui/screens/reset_password_screen.dart new file mode 100644 index 0000000..5c3a1df --- /dev/null +++ b/apps/lib/features/auth/ui/screens/reset_password_screen.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/design_tokens.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../presentation/cubits/reset_password_cubit.dart'; +import '../../data/auth_repository.dart'; + +class ResetPasswordScreen extends StatelessWidget { + const ResetPasswordScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ResetPasswordCubit(sl()), + child: const ResetPasswordView(), + ); + } +} + +class ResetPasswordView extends StatefulWidget { + const ResetPasswordView({super.key}); + + @override + State createState() => _ResetPasswordViewState(); +} + +class _ResetPasswordViewState extends State { + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + + @override + void dispose() { + _emailController.dispose(); + _codeController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + final cubit = context.read(); + cubit.emailChanged(_emailController.text); + cubit.codeChanged(_codeController.text); + cubit.newPasswordChanged(_passwordController.text); + cubit.confirmPasswordChanged(_confirmPasswordController.text); + + await cubit.submit(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.status != current.status || + previous.errorMessage != current.errorMessage || + previous.codeSent != current.codeSent, + listener: (context, state) { + if (state.status == FormzSubmissionStatus.success && state.isSuccess) { + Toast.show(context, '密码重置成功,请使用新密码登录', type: ToastType.success); + context.go('/'); + } else if (state.status == FormzSubmissionStatus.success && + state.codeSent && + state.errorMessage == 'CODE_SENT_SUCCESS') { + Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success); + } else if (state.status == FormzSubmissionStatus.failure && + state.errorMessage != null && + state.errorMessage != '' && + state.errorMessage != 'CODE_SENT_SUCCESS') { + Toast.show(context, state.errorMessage!, type: ToastType.error); + } + }, + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildTitle(), + const SizedBox(height: 32), + _buildFormContainer(), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTitle() { + return const Text( + '忘记密码', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ); + } + + Widget _buildFormContainer() { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + width: 327, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildEmailInput(state.email.displayError != null), + const SizedBox(height: 12), + _buildCodeInput(state.code.displayError != null, state), + const SizedBox(height: 12), + _buildPasswordInput(state.newPassword.displayError != null), + const SizedBox(height: 12), + _buildConfirmPasswordInput( + state.confirmPassword.displayError != null, + ), + const SizedBox(height: 24), + _buildSubmitButton(state), + const SizedBox(height: 16), + _buildBackToLogin(), + ], + ), + ); + }, + ); + } + + Widget _buildEmailInput(bool hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '邮箱', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + onChanged: (value) { + context.read().emailChanged(value); + }, + decoration: InputDecoration( + hintText: '请输入邮箱', + errorText: hasError ? ' ' : null, + ), + ), + ], + ); + } + + Widget _buildCodeInput(bool hasError, ResetPasswordState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '验证码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + onChanged: (value) { + context.read().codeChanged(value); + }, + decoration: InputDecoration( + hintText: '请输入 6 位验证码', + errorText: hasError ? ' ' : null, + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + height: 40, + child: TextButton( + onPressed: + state.resendCountdown > 0 || + state.status == FormzSubmissionStatus.inProgress + ? null + : () { + if (state.codeSent) { + context.read().resendCode(); + } else { + context.read().sendCode(); + } + }, + style: TextButton.styleFrom( + backgroundColor: state.codeSent + ? AppColors.background + : AppColors.primary, + foregroundColor: state.codeSent + ? AppColors.primary + : AppColors.primaryForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + child: Text( + state.resendCountdown > 0 + ? '${state.resendCountdown}秒' + : (state.codeSent ? '重新发送' : '发送验证码'), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildPasswordInput(bool hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '新密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + onChanged: (value) { + context.read().newPasswordChanged(value); + }, + decoration: InputDecoration( + hintText: '请输入新密码(至少 6 位)', + errorText: hasError ? ' ' : null, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + size: 20, + color: AppColors.slate400, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + ), + ], + ); + } + + Widget _buildConfirmPasswordInput(bool hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '确认密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate600, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + onChanged: (value) { + context.read().confirmPasswordChanged(value); + }, + decoration: InputDecoration( + hintText: '请再次输入新密码', + errorText: hasError ? ' ' : null, + suffixIcon: IconButton( + icon: Icon( + _obscureConfirmPassword + ? Icons.visibility_off + : Icons.visibility, + size: 20, + color: AppColors.slate400, + ), + onPressed: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + ), + ), + ), + ], + ); + } + + Widget _buildSubmitButton(ResetPasswordState state) { + final isLoading = state.status == FormzSubmissionStatus.inProgress; + final isDisabled = isLoading || !state.codeSent; + + return AppButton( + text: '重置密码', + onPressed: isDisabled ? null : _handleSubmit, + ); + } + + Widget _buildBackToLogin() { + return GestureDetector( + onTap: () => context.go('/'), + child: const Text( + '返回登录', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/apps/lib/features/users/data/users_api.dart b/apps/lib/features/users/data/users_api.dart index fc69065..a55cc7e 100644 --- a/apps/lib/features/users/data/users_api.dart +++ b/apps/lib/features/users/data/users_api.dart @@ -17,8 +17,12 @@ class UsersApi { return UserResponse.fromJson(response.data); } - Future getByUsername(String username) async { - final response = await _client.get('$_prefix/$username'); - return UserResponse.fromJson(response.data); + Future> searchUsers(String query) async { + final response = await _client.post( + '$_prefix/search', + data: {'query': query}, + ); + final List data = response.data; + return data.map((json) => UserResponse.fromJson(json)).toList(); } } diff --git a/apps/lib/features/users/data/users_repository.dart b/apps/lib/features/users/data/users_repository.dart index f62313e..e6c7b0e 100644 --- a/apps/lib/features/users/data/users_repository.dart +++ b/apps/lib/features/users/data/users_repository.dart @@ -3,5 +3,5 @@ import 'models/user_response.dart'; abstract class UsersRepository { Future getMe(); Future updateMe(UserUpdateRequest request); - Future getByUsername(String username); + Future> searchUsers(String query); } diff --git a/apps/lib/features/users/data/users_repository_impl.dart b/apps/lib/features/users/data/users_repository_impl.dart index 4376560..6e08cf4 100644 --- a/apps/lib/features/users/data/users_repository_impl.dart +++ b/apps/lib/features/users/data/users_repository_impl.dart @@ -18,7 +18,7 @@ class UsersRepositoryImpl implements UsersRepository { } @override - Future getByUsername(String username) { - return _api.getByUsername(username); + Future> searchUsers(String query) { + return _api.searchUsers(query); } } diff --git a/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart b/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart new file mode 100644 index 0000000..037609a --- /dev/null +++ b/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/reset_password_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late ResetPasswordCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = ResetPasswordCubit(mockRepository); + }); + + tearDown(() async { + await cubit.close(); + }); + + test( + 'sendCode enters countdown immediately and prevents duplicate clicks', + () async { + final completer = Completer(); + when( + () => mockRepository.requestPasswordReset(any()), + ).thenAnswer((_) => completer.future); + + cubit.emailChanged('test@example.com'); + + final firstRequest = cubit.sendCode(); + await Future.delayed(Duration.zero); + + expect(cubit.state.status, FormzSubmissionStatus.inProgress); + expect(cubit.state.codeSent, isTrue); + expect(cubit.state.resendCountdown, 60); + + await cubit.sendCode(); + verify( + () => mockRepository.requestPasswordReset('test@example.com'), + ).called(1); + + completer.complete(); + await firstRequest; + }, + ); + + test('sendCode failure cancels countdown and restores retry state', () async { + when( + () => mockRepository.requestPasswordReset(any()), + ).thenThrow(Exception('network error')); + + cubit.emailChanged('test@example.com'); + + await cubit.sendCode(); + + expect(cubit.state.status, FormzSubmissionStatus.failure); + expect(cubit.state.codeSent, isFalse); + expect(cubit.state.resendCountdown, 0); + expect(cubit.state.errorMessage, '网络错误,请稍后重试'); + }); +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9b1ea96..80c642c 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,3 +1,7 @@ +# Backend Development Rules + +This document defines Python/FastAPI backend development constraints. + ## Python Environment **MUST use uv for dependency management and virtual environment execution.** @@ -43,11 +47,10 @@ Do not bypass or weaken checks (no ignores, disables, or config relaxations). Re - Tests can set env vars via `monkeypatch.setenv`, and should read values via `Settings()` unless the test is explicitly validating env plumbing - Canonical principle: one source of truth per setting; no duplicate/derived env vars in backend code -## TDD First Policy - -**Principle: tests before implementation.** +## TDD Workflow ### Coverage Requirements + - Minimum coverage: 80% - Required test types: - Unit: isolated functions, utilities, components @@ -55,12 +58,14 @@ Do not bypass or weaken checks (no ignores, disables, or config relaxations). Re - E2E: critical user flows (Playwright) ### Limited Exceptions + - Docs-only changes (README, comments, formatting) may skip integration/E2E - Non-runtime config changes may skip E2E if no behavior changes - Any runtime code change requires unit + integration + E2E - If an exception is used, record the reason in the PR/test notes ### Mandatory TDD Workflow + 1. Write tests (RED) - they must fail 2. Run tests - confirm failure 3. Implement minimal code (GREEN) - only to pass @@ -69,19 +74,80 @@ Do not bypass or weaken checks (no ignores, disables, or config relaxations). Re 6. Verify coverage - must be 80%+ ### Enforcement + - Must use the `tdd-guide` agent for new features - Do not write implementation before tests - Do not lower coverage requirements - Must include unit, integration, and E2E tests +## Code Style + +### Immutability + +**ALWAYS create new objects, NEVER mutate.** + +```python +# WRONG: Mutation +def update_user(user, name): + user["name"] = name + return user + +# CORRECT: Immutability +def update_user(user, name): + return {**user, "name": name} +``` + +### File Organization + +- Many small files over few large files +- 200-400 lines typical, 800 max per file +- Extract utilities from large components + +### Error Handling + +Always handle errors comprehensively: + +```python +try: + result = risky_operation() + return result +except Exception as exc: + logger.exception("Operation failed") + raise RuntimeError("Detailed user-friendly message") from exc +``` + +## Security + +### Mandatory Security Checks + +Before ANY commit: +- [ ] No hardcoded secrets (API keys, passwords, tokens) +- [ ] All user inputs validated (use Pydantic) +- [ ] SQL injection prevention (parameterized queries) +- [ ] Authentication/authorization verified + +### Secret Management + +```python +# NEVER: Hardcoded secrets +api_key = "sk-proj-xxxxx" + +# ALWAYS: Environment variables +api_key = os.environ.get("OPENAI_API_KEY") +if not api_key: + raise ValueError("OPENAI_API_KEY not configured") +``` + ## Database Development Rules -### Core Principle +### Architecture + - **Supabase**: authentication (JWT source of truth) - **Backend**: business authorization (service layer) - **SQLAlchemy ORM**: data access layer (async + asyncpg, service_role connection) -### Architecture +### Code Organization + Use `schemas / repository / service` pattern: - `schemas.py` — Pydantic models - `repository.py` — CRUD only, no auth, no commit (only flush), must receive session (never create session/engine) @@ -89,6 +155,7 @@ Use `schemas / repository / service` pattern: - `dependencies.py` — DI (`get_db`, `get_current_user`) ### Auth & Data Access + - Backend must verify JWT signature and expiration (not just decode) - Extract `user_id` from JWT `sub` claim - Backend connects with **service_role** (bypasses RLS) @@ -98,31 +165,28 @@ Use `schemas / repository / service` pattern: - Prohibit calling Supabase Admin API (service_role key) from repository/service layers ### Migrations + - **Alembic is the single source of truth** for schema migrations - ORM model changes → `alembic revision --autogenerate` - Raw SQL (policies, triggers, functions) → `op.execute()` - Migrations must be reversible; no reliance on generated IDs ### Enum Storage Convention + **Store enum names (strings), not integer values.** - Use `VARCHAR(20)` + `CHECK` constraint in database - Use Python `Enum` class with `str` base in code -- Benefits: debugging readability, easy to add new values without data migration, ORM-friendly ```python -# Correct class AgentType(str, Enum): INTENT_RECOGNITION = "INTENT_RECOGNITION" TASK_EXECUTION = "TASK_EXECUTION" RESULT_REPORTING = "RESULT_REPORTING" - -# Migration -ALTER TABLE user_agents ADD CONSTRAINT chk_agent_type - CHECK (agent_type IN ('INTENT_RECOGNITION', 'TASK_EXECUTION', 'RESULT_REPORTING')); ``` -### RLS Guidance +### RLS Policy + - Backend does not rely on RLS for correctness (uses service_role), but RLS is mandatory as a defensive boundary for tables in PostgREST-exposed schemas. - **Mandatory default**: any new business table in `public` must enable RLS in the same Alembic migration. - The same migration must create policies covering `SELECT/INSERT/UPDATE/DELETE` (minimum requirement). @@ -130,11 +194,13 @@ ALTER TABLE user_agents ADD CONSTRAINT chk_agent_type - `alembic_version` must not be exposed to `anon` or `authenticated`. #### Exemption Rule (strict) + - Exemptions are allowed only when a new `public` table is guaranteed not to be exposed to PostgREST clients. -- Exemptions must be explicit in the migration file with rationale and verification notes (why safe, how exposure is prevented). +- Exemptions must be explicit in the migration file with rationale and verification notes. - If exposure is uncertain, do not exempt: enable defensive RLS by default. -#### Migration Acceptance Checklist (RLS) +#### Migration Checklist + - [ ] New `public` business table has `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` in migration - [ ] Policies for `SELECT/INSERT/UPDATE/DELETE` are present in migration - [ ] Policy target roles are explicit (`anon`, `authenticated`, or both) diff --git a/backend/alembic/versions/20260226_0001_initial_schema.py b/backend/alembic/versions/20260226_0001_initial_schema.py index 0041316..7364c90 100644 --- a/backend/alembic/versions/20260226_0001_initial_schema.py +++ b/backend/alembic/versions/20260226_0001_initial_schema.py @@ -40,7 +40,6 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"), ) - op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) _enable_rls("llm_factory") op.create_table( @@ -65,7 +64,6 @@ def upgrade() -> None: sa.UniqueConstraint("model_code"), ) op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False) - op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) op.create_foreign_key( "fk_llms_factory_id", "llms", diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 266ab89..5a647fd 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from typing import Any, cast from fastapi import HTTPException @@ -10,6 +11,8 @@ from core.config.settings import SupabaseSettings, config from core.logging import get_logger from v1.auth.schemas import ( AuthUser, + PasswordResetConfirmRequest, + PasswordResetRequest, SessionCreateRequest, SessionRefreshRequest, SessionResponse, @@ -150,6 +153,64 @@ class SupabaseAuthGateway(AuthServiceGateway): ), ) + async def request_password_reset(self, request: PasswordResetRequest) -> None: + try: + reset_email = cast(Any, self._client.auth.reset_password_email) + email = _coerce_reset_email(request.email) + if request.redirect_to: + options: dict[str, str] = {"redirect_to": request.redirect_to} + await asyncio.to_thread(reset_email, email, options=options) + else: + await asyncio.to_thread(reset_email, email) + except AuthError as exc: + logger.warning( + "Password reset request failed", + error_type=type(exc).__name__, + ) + + async def confirm_password_reset( + self, request: PasswordResetConfirmRequest + ) -> None: + verify_payload: dict[str, Any] = { + "type": "recovery", + "email": request.email, + "token": request.token, + } + try: + verify_otp = cast(Any, self._client.auth.verify_otp) + response = await asyncio.to_thread(verify_otp, verify_payload) + session = getattr(response, "session", None) + user = getattr(response, "user", None) + user_id = str(getattr(user, "id", "")) if user is not None else "" + if session is None or not user_id: + raise HTTPException( + status_code=401, detail="Invalid or expired verification code" + ) + await asyncio.to_thread( + self._admin_client.auth.admin.update_user_by_id, + user_id, + {"password": request.new_password}, + ) + except AuthError as exc: + logger.warning( + "Password reset confirm failed", error_type=type(exc).__name__ + ) + raise HTTPException( + status_code=401, detail="Invalid or expired verification code" + ) from exc + + +def _coerce_reset_email(value: object) -> str: + if isinstance(value, str): + return value + + if isinstance(value, Mapping): + nested = value.get("email") or value.get("value") + if isinstance(nested, str): + return nested + + raise HTTPException(status_code=422, detail="Invalid email") + def _map_auth_response(response: object, failure_message: str) -> SessionResponse: session = getattr(response, "session", None) diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 97163e2..d3367ac 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -10,6 +10,8 @@ from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service from v1.users.dependencies import get_current_user from v1.auth.schemas import ( + PasswordResetConfirmRequest, + PasswordResetRequest, SessionCreateRequest, SessionDeleteRequest, SessionRefreshRequest, @@ -123,3 +125,33 @@ async def get_user_by_email( if current_user.role != "service_role" and current_user.email != email: raise HTTPException(status_code=403, detail="Forbidden") return await service.get_user_by_email(email) + + +@router.post("/password-reset", status_code=204) +async def request_password_reset( + payload: PasswordResetRequest, + service: AuthService = Depends(get_auth_service), +) -> Response: + await enforce_rate_limit( + scope="password_reset_request", + identifier=payload.email, + limit=5, + window_seconds=60, + ) + await service.request_password_reset(payload) + return Response(status_code=204) + + +@router.post("/password-reset/confirm", status_code=204) +async def confirm_password_reset( + payload: PasswordResetConfirmRequest, + service: AuthService = Depends(get_auth_service), +) -> Response: + await enforce_rate_limit( + scope="password_reset_confirm", + identifier=payload.email, + limit=10, + window_seconds=600, + ) + await service.confirm_password_reset(payload) + return Response(status_code=204) diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 454334e..4821f8b 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -61,5 +61,11 @@ class PasswordResetRequest(BaseModel): redirect_to: str | None = None +class PasswordResetConfirmRequest(BaseModel): + email: EmailStr + token: str = Field(pattern=r"^\d{6}$") + new_password: str = Field(min_length=6) + + class PasswordResetResponse(BaseModel): message: str = "Password reset email sent" diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 9c564f1..53ff320 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Protocol from v1.auth.schemas import ( + PasswordResetConfirmRequest, + PasswordResetRequest, SessionCreateRequest, SessionRefreshRequest, SessionResponse, @@ -40,6 +42,14 @@ class AuthServiceGateway(Protocol): async def get_user_by_email(self, email: str) -> UserByEmailResponse: raise NotImplementedError + async def request_password_reset(self, request: PasswordResetRequest) -> None: + raise NotImplementedError + + async def confirm_password_reset( + self, request: PasswordResetConfirmRequest + ) -> None: + raise NotImplementedError + class AuthService: _gateway: AuthServiceGateway @@ -71,3 +81,11 @@ class AuthService: async def get_user_by_email(self, email: str) -> UserByEmailResponse: return await self._gateway.get_user_by_email(email) + + async def request_password_reset(self, request: PasswordResetRequest) -> None: + await self._gateway.request_password_reset(request) + + async def confirm_password_reset( + self, request: PasswordResetConfirmRequest + ) -> None: + await self._gateway.confirm_password_reset(request) diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py index ca6eb10..6ea01d8 100644 --- a/backend/src/v1/users/dependencies.py +++ b/backend/src/v1/users/dependencies.py @@ -11,11 +11,21 @@ from core.auth.models import CurrentUser from core.config.settings import config from core.db import get_db from core.logging import get_logger +from v1.auth.gateway import SupabaseAuthGateway from v1.users.repository import SQLAlchemyUserRepository -from v1.users.service import UserService +from v1.users.service import AuthLookupAdapter, UserService logger = get_logger("v1.users.dependencies") +_auth_gateway: SupabaseAuthGateway | None = None + + +def get_auth_gateway() -> SupabaseAuthGateway: + global _auth_gateway + if _auth_gateway is None: + _auth_gateway = SupabaseAuthGateway() + return _auth_gateway + def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser: if not authorization: @@ -98,4 +108,10 @@ def get_user_service( user: Annotated[CurrentUser, Depends(get_current_user)], ) -> UserService: repository = SQLAlchemyUserRepository(session) - return UserService(repository=repository, session=session, current_user=user) + auth_gateway = AuthLookupAdapter(get_auth_gateway()) + return UserService( + repository=repository, + session=session, + current_user=user, + auth_gateway=auth_gateway, + ) diff --git a/backend/src/v1/users/repository.py b/backend/src/v1/users/repository.py index fecdd63..cb59b45 100644 --- a/backend/src/v1/users/repository.py +++ b/backend/src/v1/users/repository.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Protocol from uuid import UUID -from sqlalchemy import select +from sqlalchemy import select, or_ from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository @@ -33,6 +33,10 @@ class UserRepository(Protocol): """Update user by user ID. Returns updated user or None if not found.""" ... + async def search_users(self, query: str, limit: int = 20) -> list[Profile]: + """Search users by username (ilike) or email (exact match).""" + ... + class SQLAlchemyUserRepository(BaseRepository[Profile]): """SQLAlchemy implementation of UserRepository. @@ -77,5 +81,24 @@ class SQLAlchemyUserRepository(BaseRepository[Profile]): try: return await self.update_by_id(user_id, update_data) except SQLAlchemyError: - logger.exception("User update failed", user_id=str(user_id)) + logger.exception("User update failed", user=str(user_id)) + raise + + async def search_users(self, query: str, limit: int = 20) -> list[Profile]: + try: + stmt = ( + select(Profile) + .where(Profile.deleted_at.is_(None)) + .where( + or_( + Profile.username.ilike(f"%{query}%"), + ) + ) + .order_by(Profile.created_at.asc()) + .limit(limit) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception("User search failed", query=query) raise diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py index e4825f6..ab52184 100644 --- a/backend/src/v1/users/router.py +++ b/backend/src/v1/users/router.py @@ -2,10 +2,10 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Depends, Path +from fastapi import APIRouter, Depends from v1.users.dependencies import get_user_service -from v1.users.schemas import UserResponse, UserUpdateRequest +from v1.users.schemas import UserResponse, UserSearchRequest, UserUpdateRequest from v1.users.service import UserService @@ -27,11 +27,9 @@ async def update_me( return await service.update_me(payload) -@router.get("/{username}", response_model=UserResponse) -async def get_by_username( - username: Annotated[ - str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$") - ], +@router.post("/search", response_model=list[UserResponse]) +async def search_users( + payload: UserSearchRequest, service: Annotated[UserService, Depends(get_user_service)], -) -> UserResponse: - return await service.get_by_username(username) +) -> list[UserResponse]: + return await service.search_users(payload) diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py index ece06ad..273209f 100644 --- a/backend/src/v1/users/schemas.py +++ b/backend/src/v1/users/schemas.py @@ -19,6 +19,17 @@ class UserResponse(BaseModel): bio: str | None = None +class UserSearchRequest(BaseModel): + query: str = Field(min_length=1, max_length=100) + + +class UserSearchResult(BaseModel): + id: str + username: str + avatar_url: str | None = None + bio: str | None = None + + class UserUpdateRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 50a2a38..4bcf9a1 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import re +from typing import TYPE_CHECKING, Protocol +from uuid import UUID from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError @@ -9,13 +11,37 @@ from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.logging import get_logger from v1.users.repository import UserRepository -from v1.users.schemas import UserResponse, UserUpdateRequest +from v1.users.schemas import UserResponse, UserSearchRequest, UserUpdateRequest if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession + from v1.auth.schemas import UserByEmailResponse + logger = get_logger("v1.users.service") +_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +class AuthLookupGateway(Protocol): + async def get_user_id_by_email(self, email: str) -> str | None: ... + + +class AuthByEmailGateway(Protocol): + async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ... + + +class AuthLookupAdapter: + def __init__(self, gateway: AuthByEmailGateway) -> None: + self._gateway = gateway + + async def get_user_id_by_email(self, email: str) -> str | None: + try: + response = await self._gateway.get_user_by_email(email) + return response.id + except HTTPException: + return None + class UserService(BaseService): """User service handling business logic and transactions. @@ -28,16 +54,19 @@ class UserService(BaseService): _repository: UserRepository _session: AsyncSession + _auth_gateway: AuthLookupGateway | None def __init__( self, repository: UserRepository, session: AsyncSession, current_user: CurrentUser | None, + auth_gateway: AuthLookupGateway | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._session = session + self._auth_gateway = auth_gateway async def get_me(self) -> UserResponse: user_id = self.require_user_id() @@ -101,3 +130,52 @@ class UserService(BaseService): avatar_url=user.avatar_url, bio=user.bio, ) + + async def search_users(self, request: UserSearchRequest) -> list[UserResponse]: + query = request.query.strip() + + if _EMAIL_PATTERN.match(query): + return await self._search_by_email(query) + + return await self._search_by_username(query) + + async def _search_by_email(self, email: str) -> list[UserResponse]: + if self._auth_gateway is None: + raise HTTPException(status_code=503, detail="Auth lookup unavailable") + + user_id_str = await self._auth_gateway.get_user_id_by_email(email) + if user_id_str is None: + return [] + + try: + user = await self._repository.get_by_user_id(UUID(user_id_str)) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="User store unavailable") + + if user is None: + return [] + + return [ + UserResponse( + id=str(user.id), + username=user.username, + avatar_url=user.avatar_url, + bio=user.bio, + ) + ] + + async def _search_by_username(self, query: str) -> list[UserResponse]: + try: + users = await self._repository.search_users(query, limit=20) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="User store unavailable") + + return [ + UserResponse( + id=str(user.id), + username=user.username, + avatar_url=user.avatar_url, + bio=user.bio, + ) + for user in users + ] diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index 26f6e94..72a21d6 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -14,6 +14,8 @@ from v1.users.dependencies import get_current_user from v1.auth.rate_limit import reset_rate_limit_state from v1.auth.schemas import ( AuthUser, + PasswordResetConfirmRequest, + PasswordResetRequest, SessionCreateRequest, SessionRefreshRequest, SessionResponse, @@ -71,6 +73,18 @@ class FakeAuthService(AuthService): email_confirmed_at=None, ) + async def request_password_reset(self, request: PasswordResetRequest) -> None: + return None + + async def confirm_password_reset( + self, request: PasswordResetConfirmRequest + ) -> None: + if request.token == "000000": + raise HTTPException( + status_code=401, detail="Invalid or expired verification code" + ) + return None + def _override_auth_service(service: AuthService) -> Callable[[], AuthService]: def _get_service() -> AuthService: @@ -665,3 +679,116 @@ def test_get_user_by_email_forbidden_when_querying_other_user() -> None: assert body["detail"] == "Forbidden" finally: app.dependency_overrides = {} + + +def test_password_reset_request_returns_204() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/password-reset", + json={"email": "user@example.com"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides = {} + + +def test_password_reset_confirm_returns_204() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/password-reset/confirm", + json={ + "email": "user@example.com", + "token": "123456", + "new_password": "newpassword123", + }, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides = {} + + +def test_password_reset_confirm_invalid_token_returns_401() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/password-reset/confirm", + json={ + "email": "user@example.com", + "token": "000000", + "new_password": "newpassword123", + }, + ) + assert response.status_code == 401 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unauthorized" + assert body["status"] == 401 + finally: + app.dependency_overrides = {} + + +def test_password_reset_confirm_weak_password_returns_422() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/password-reset/confirm", + json={ + "email": "user@example.com", + "token": "123456", + "new_password": "123", + }, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + finally: + app.dependency_overrides = {} diff --git a/backend/tests/integration/test_users_routes.py b/backend/tests/integration/test_users_routes.py index 03e2d76..6e703bf 100644 --- a/backend/tests/integration/test_users_routes.py +++ b/backend/tests/integration/test_users_routes.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from app import app from core.auth.models import CurrentUser from v1.users.dependencies import get_current_user, get_user_service -from v1.users.schemas import UserResponse, UserUpdateRequest +from v1.users.schemas import UserResponse, UserSearchRequest, UserUpdateRequest from v1.users.service import UserService @@ -18,6 +18,10 @@ class FakeUserService: def __init__(self, user: UserResponse) -> None: self._user = user + self._search_results: list[UserResponse] = [] + + def set_search_results(self, results: list[UserResponse]) -> None: + self._search_results = results async def get_me(self) -> UserResponse: if self._user.id is None: @@ -45,6 +49,11 @@ class FakeUserService: raise HTTPException(status_code=404, detail="User not found") return self._user + async def search_users(self, request: UserSearchRequest) -> list[UserResponse]: + if request.query: + return self._search_results if self._search_results else [self._user] + return [] + def _override_user_service( service: FakeUserService, @@ -111,50 +120,6 @@ def test_patch_me_updates_user() -> None: app.dependency_overrides = {} -def test_get_user_by_username() -> None: - user = UserResponse( - id="00000000-0000-0000-0000-000000000001", - username="demo", - avatar_url=None, - bio=None, - ) - app.dependency_overrides[get_user_service] = _override_user_service( - FakeUserService(user) - ) - - client = TestClient(app) - try: - response = client.get("/api/v1/users/demo") - assert response.status_code == 200 - body = response.json() - assert body["username"] == "demo" - finally: - app.dependency_overrides = {} - - -def test_user_not_found_returns_problem_details() -> None: - user = UserResponse( - id="00000000-0000-0000-0000-000000000001", - username="demo", - avatar_url=None, - bio=None, - ) - app.dependency_overrides[get_user_service] = _override_user_service( - FakeUserService(user) - ) - - client = TestClient(app) - try: - response = client.get("/api/v1/users/unknown") - assert response.status_code == 404 - assert response.headers["content-type"].startswith("application/problem+json") - body = response.json() - assert body["title"] == "Not Found" - assert body["status"] == 404 - finally: - app.dependency_overrides = {} - - def test_patch_me_validation_error_returns_problem_details() -> None: user_id = UUID("00000000-0000-0000-0000-000000000001") user = UserResponse( @@ -178,3 +143,70 @@ def test_patch_me_validation_error_returns_problem_details() -> None: assert body["status"] == 422 finally: app.dependency_overrides = {} + + +def test_search_users_returns_list() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + user = UserResponse( + id=str(user_id), + username="demo", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_user_service] = _override_user_service( + FakeUserService(user) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/users/search", + json={"query": "demo"}, + ) + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + finally: + app.dependency_overrides = {} + + +def test_search_users_empty_query_returns_422() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + user = UserResponse( + id=str(user_id), + username="demo", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_user_service] = _override_user_service( + FakeUserService(user) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/users/search", + json={"query": ""}, + ) + assert response.status_code == 422 + finally: + app.dependency_overrides = {} + + +def test_get_user_by_username_returns_404() -> None: + user = UserResponse( + id="00000000-0000-0000-0000-000000000001", + username="demo", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_user_service] = _override_user_service( + FakeUserService(user) + ) + + client = TestClient(app) + try: + response = client.get("/api/v1/users/demo") + assert response.status_code == 404 + finally: + app.dependency_overrides = {} diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py new file mode 100644 index 0000000..ec2b64c --- /dev/null +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.schemas import PasswordResetConfirmRequest, PasswordResetRequest + + +class TestSupabaseAuthGateway: + @pytest.fixture + def gateway(self) -> SupabaseAuthGateway: + with patch("v1.auth.gateway.create_client") as mock_create: + mock_client = MagicMock() + mock_admin_client = MagicMock() + mock_create.side_effect = [mock_client, mock_admin_client] + return SupabaseAuthGateway() + + @pytest.mark.asyncio + async def test_request_password_reset_calls_email_with_string( + self, gateway: SupabaseAuthGateway + ) -> None: + mock_reset_email = MagicMock() + gateway._client.auth.reset_password_email = mock_reset_email + + request = PasswordResetRequest(email="test@example.com") + await gateway.request_password_reset(request) + + mock_reset_email.assert_called_once_with("test@example.com") + + @pytest.mark.asyncio + async def test_request_password_reset_with_redirect( + self, gateway: SupabaseAuthGateway + ) -> None: + mock_reset_email = MagicMock() + gateway._client.auth.reset_password_email = mock_reset_email + + request = PasswordResetRequest( + email="test@example.com", + redirect_to="http://localhost:3000/reset-password", + ) + await gateway.request_password_reset(request) + + mock_reset_email.assert_called_once_with( + "test@example.com", + options={"redirect_to": "http://localhost:3000/reset-password"}, + ) + + @pytest.mark.asyncio + async def test_request_password_reset_swallows_auth_error( + self, gateway: SupabaseAuthGateway + ) -> None: + from supabase import AuthError + + mock_reset_email = MagicMock(side_effect=AuthError("rate limit exceeded", None)) + gateway._client.auth.reset_password_email = mock_reset_email + + request = PasswordResetRequest(email="test@example.com") + + result = await gateway.request_password_reset(request) + + mock_reset_email.assert_called_once() + assert result is None + + @pytest.mark.asyncio + async def test_request_password_reset_extracts_email_from_mapping( + self, gateway: SupabaseAuthGateway + ) -> None: + mock_reset_email = MagicMock() + gateway._client.auth.reset_password_email = mock_reset_email + + request = PasswordResetRequest.model_construct( + email={"email": "test@example.com"}, + redirect_to=None, + ) + + await gateway.request_password_reset(request) + + mock_reset_email.assert_called_once_with("test@example.com") + + @pytest.mark.asyncio + async def test_request_password_reset_rejects_invalid_email_shape( + self, gateway: SupabaseAuthGateway + ) -> None: + request = PasswordResetRequest.model_construct( + email={"unexpected": "value"}, + redirect_to=None, + ) + + with pytest.raises(HTTPException) as exc_info: + await gateway.request_password_reset(request) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == "Invalid email" + + @pytest.mark.asyncio + async def test_confirm_password_reset_updates_password_by_user_id( + self, gateway: SupabaseAuthGateway + ) -> None: + verify_response = SimpleNamespace( + session=SimpleNamespace(access_token="access"), + user=SimpleNamespace(id="user-1"), + ) + mock_verify_otp = MagicMock(return_value=verify_response) + gateway._client.auth.verify_otp = mock_verify_otp + + mock_update_user_by_id = MagicMock() + gateway._admin_client.auth.admin = SimpleNamespace( + update_user_by_id=mock_update_user_by_id + ) + + request = PasswordResetConfirmRequest( + email="test@example.com", + token="123456", + new_password="newpassword123", + ) + + await gateway.confirm_password_reset(request) + + mock_verify_otp.assert_called_once_with( + { + "type": "recovery", + "email": "test@example.com", + "token": "123456", + } + ) + mock_update_user_by_id.assert_called_once_with( + "user-1", + {"password": "newpassword123"}, + ) + + @pytest.mark.asyncio + async def test_confirm_password_reset_raises_when_user_id_missing( + self, gateway: SupabaseAuthGateway + ) -> None: + verify_response = SimpleNamespace( + session=SimpleNamespace(access_token="access"), + user=SimpleNamespace(id=""), + ) + gateway._client.auth.verify_otp = MagicMock(return_value=verify_response) + + request = PasswordResetConfirmRequest( + email="test@example.com", + token="123456", + new_password="newpassword123", + ) + + with pytest.raises(HTTPException) as exc_info: + await gateway.confirm_password_reset(request) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid or expired verification code" diff --git a/docs/plans/2026-02-27-invite-code-implementation-plan.md b/docs/plans/2026-02-27-invite-code-implementation-plan.md new file mode 100644 index 0000000..2964e05 --- /dev/null +++ b/docs/plans/2026-02-27-invite-code-implementation-plan.md @@ -0,0 +1,309 @@ +# Invite Code Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在现有 OTP 注册链路中引入邀请码能力,支持用户自动生成专属邀请码、注册时可选填邀请码并记录邀请关系与使用次数。 + +**Architecture:** 采用数据库中心实现:通过 Alembic 新增 `invite_codes` 表、扩展 `profiles` 字段,并在 `auth.users` 的现有 trigger 函数中完成邀请码校验与记账,保证注册与邀请关系写入尽量原子。应用层只负责透传 `invite_code` 到 Supabase `raw_user_meta_data`。 + +**Tech Stack:** FastAPI, SQLAlchemy, Alembic, Supabase Auth, PostgreSQL PL/pgSQL, Pytest + +--- + +### Task 1: 更新注册请求 Schema(TDD) + +**Files:** +- Modify: `backend/src/v1/auth/schemas.py` +- Modify: `backend/tests/integration/test_auth_routes.py` + +**Step 1: Write the failing test** + +在 `test_signup_start_returns_pending_response` 基础上新增断言路径:请求体带 `invite_code` 时返回仍为 202,且未触发 422。 + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start_returns_pending_response -v` +Expected: FAIL(`invite_code` 为额外字段或校验不通过) + +**Step 3: Write minimal implementation** + +在 `VerificationCreateRequest` 增加可选字段: + +```python +invite_code: str | None = Field(default=None, min_length=8, max_length=8) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start_returns_pending_response -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/v1/auth/schemas.py backend/tests/integration/test_auth_routes.py +git commit -m "feat: accept invite code in signup request" +``` + +### Task 2: 透传 invite_code 到 Supabase metadata(TDD) + +**Files:** +- Modify: `backend/src/v1/auth/gateway.py` +- Modify: `backend/tests/unit/v1/auth/test_auth_service.py` + +**Step 1: Write the failing test** + +在 `test_supabase_signup_passes_username_in_metadata` 增加 `invite_code` 并断言: + +```python +assert captured_payload["data"] == { + "username": "demo", + "invite_code": "A1B2C3D4", +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/unit/v1/auth/test_auth_service.py -k metadata -v` +Expected: FAIL(metadata 未包含 `invite_code`) + +**Step 3: Write minimal implementation** + +在 `create_verification` 中构建 metadata: + +```python +metadata = {"username": request.username} +if request.invite_code: + metadata["invite_code"] = request.invite_code +payload = { + "email": request.email, + "password": request.password, + "data": metadata, +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/unit/v1/auth/test_auth_service.py -k metadata -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/v1/auth/gateway.py backend/tests/unit/v1/auth/test_auth_service.py +git commit -m "feat: pass invite code through signup metadata" +``` + +### Task 3: 新增 invite_codes 表与 profiles.referred_by(迁移先行) + +**Files:** +- Create: `backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py` +- Modify: `backend/src/models/profile.py` +- Create: `backend/src/models/invite_code.py` +- Modify: `backend/src/models/__init__.py` + +**Step 1: Write the failing test** + +在 `backend/tests/unit/database/test_profile_models.py` 新增 `referred_by` 读写测试;新增 `backend/tests/unit/database/test_invite_code_models.py` 验证 `InviteCode` 基本创建与约束字段。 + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/unit/database/test_profile_models.py tests/unit/database/test_invite_code_models.py -v` +Expected: FAIL(字段/模型不存在) + +**Step 3: Write minimal implementation** + +- Alembic 创建 `invite_codes`: + - `code` 唯一索引 + - `owner_id` 外键到 `profiles.id`(可空) + - `status`、`used_count`、`max_uses` check 约束 + - `max_uses` 默认 `NULL`(无限制) + - `expires_at` 默认 `NULL`(无限制) + - `reward_config` JSONB 默认 `{}` + - 启用 RLS(按项目默认 deny-all) +- **注意**:本期不开放 invite_codes 表直接读取,用户邀请码通过 profile 聚合接口返回(后续实现) + +- Alembic 给 `profiles` 增加 `referred_by` + 索引 + 外键 +- ORM 同步 `Profile.referred_by` 与 `InviteCode` 模型 + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/unit/database/test_profile_models.py tests/unit/database/test_invite_code_models.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py backend/src/models/profile.py backend/src/models/invite_code.py backend/src/models/__init__.py backend/tests/unit/database/test_profile_models.py backend/tests/unit/database/test_invite_code_models.py +git commit -m "feat: add invite code schema and profile referral fields" +``` + +### Task 4: 扩展注册 trigger 生成邀请码并消费邀请(TDD) + +**Files:** +- Modify: `backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py` +- Modify: `backend/tests/integration/test_auth_routes.py` + +**Step 1: Write the failing test** + +新增集成测试(建议通过测试替身/fixture 验证行为): +- 注册不带邀请码时,profile 创建后存在 owner 邀请码 +- 注册带有效邀请码时,`referred_by` 生效且 `used_count + 1` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v` +Expected: FAIL(触发器逻辑尚未实现) + +**Step 3: Write minimal implementation** + +在迁移 SQL 中: +- 新增 helper function:生成 8 位随机码(排除易混淆字符 0/O/1/I/L,冲突重试) +- 重建 `public.create_profile_for_new_user()`: + 1. 插入 `profiles` + 2. 创建该用户专属 `invite_codes`(`owner_id = NEW.id`) + 3. 读取 `NEW.raw_user_meta_data ->> 'invite_code'` + 4. 校验邀请码状态/过期/次数 + 5. 若有效:更新 `profiles.referred_by`,并 `used_count = used_count + 1` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py backend/tests/integration/test_auth_routes.py +git commit -m "feat: extend signup trigger for invite code generation and usage" +``` + +### Task 5: 覆盖邀请码边界场景(TDD) + +**Files:** +- Modify: `backend/tests/integration/test_auth_routes.py` +- Optional Modify: `backend/tests/e2e/test_auth_flow.py` + +**Step 1: Write the failing test** + +新增场景测试: +- 邀请码不存在 +- 邀请码 disabled +- 邀请码 expires_at 已过期 +- 邀请码达到 `max_uses` + +断言:注册仍成功(202/200 链路正常),仅邀请关系不建立。 + +**Step 2: Run test to verify it fails** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k "invite and (expired or disabled or max_uses or invalid)" -v` +Expected: FAIL + +**Step 3: Write minimal implementation** + +修正 trigger 判断顺序和条件,确保“邀请无效不影响注册”原则。 + +**Step 4: Run test to verify it passes** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/tests/integration/test_auth_routes.py backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py +git commit -m "test: cover invite code edge cases in signup flow" +``` + +### Task 6: 文档同步与运行手册更新 + +**Files:** +- Modify: `docs/runtime/runtime-route.md` +- Modify: `docs/runtime/runtime-runbook.md` + +**Step 1: Write the failing test** + +无自动化测试;改为文档一致性检查清单(手工): +- 注册接口 request 字段包含 `invite_code` +- 说明邀请码消费时机与“无效码不阻断注册” + +**Step 2: Run check to verify missing docs** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start -v` +Expected: PASS(作为行为基线),文档尚未同步 + +**Step 3: Write minimal implementation** + +- 更新 `POST /auth/verifications` 请求字段 +- 新增邀请码行为说明 +- 在 runbook 变更日志添加本次改动记录 + +**Step 4: Run check after docs update** + +Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start -v` +Expected: PASS(行为与文档一致) + +**Step 5: Commit** + +```bash +git add docs/runtime/runtime-route.md docs/runtime/runtime-runbook.md +git commit -m "docs: document invite code behavior in signup flow" +``` + +### Task 7: 全量验证与风险审查(L2) + +**Files:** +- Verify only + +**Step 1: Run lint/type checks** + +Run: +- `cd backend && uv run ruff check src tests` +- `cd backend && uv run basedpyright src` + +Expected: 全部通过 + +**Step 2: Run test suites** + +Run: +- `cd backend && uv run pytest tests/unit -v` +- `cd backend && uv run pytest tests/integration -v` +- `cd backend && uv run pytest tests/e2e/test_auth_flow.py -v` + +Expected: 通过 + +**Step 3: Run mandatory review gates for L2** + +- `refactor-cleaner` agent:确认无死代码/重复代码 +- `code-reviewer` agent:检查 DB trigger、安全边界、可维护性 + +Expected: CRITICAL/HIGH 为 0 + +**Step 4: Security-specific sanity checks** + +检查项: +- 未硬编码密钥 +- SQL 逻辑无注入风险(trigger 中仅参数/列操作) +- 邀请码校验失败不泄露内部细节 + +**Step 5: Commit verification evidence (if needed in docs/PR notes)** + +```bash +git add +git commit -m "chore: record invite code verification results" +``` + +--- + +## 交付验收标准 + +1. 新用户注册后必有 1 条专属邀请码。 +2. 注册时传入有效邀请码会建立 `profiles.referred_by` 并增加 `used_count`。 +3. 无效邀请码不会阻断注册成功。 +4. 支持运营码(`owner_id IS NULL`)与后续奖励扩展(`reward_config`)。 +5. 文档已同步,测试与检查通过。 + +## 备注 + +- 本需求触发 L2(数据库迁移 + trigger + 多文件大改),必须走双审查 gate。 +- 不在本期实现运营后台批量发码 API;仅完成数据层与注册链路支撑。 diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md index 4224bdf..066e27d 100644 --- a/docs/runtime/runtime-route.md +++ b/docs/runtime/runtime-route.md @@ -171,6 +171,48 @@ --- +### POST /auth/password-reset + +发送密码重置验证码。 + +**Request:** +```json +{ + "email": "string (email)", + "redirect_to": "string? (optional)" +} +``` + +**Response:** 204 No Content + +**Errors:** +- 422: 请求参数无效 +- 429: 请求过于频繁 + +--- + +### POST /auth/password-reset/confirm + +验证 recovery 验证码并完成改密。 + +**Request:** +```json +{ + "email": "string (email)", + "token": "string (6 digits)", + "new_password": "string (min 6 chars)" +} +``` + +**Response:** 204 No Content + +**Errors:** +- 401: 验证码无效或已过期 +- 422: 请求参数无效 +- 429: 请求过于频繁 + +--- + ### GET /auth/users 按邮箱查询用户(需要认证)。 @@ -245,26 +287,38 @@ --- -### GET /users/{username} +### POST /users/search -按用户名查询用户(需要认证)。 +搜索用户(需要认证)。 -**Path Parameters:** -- `username`: string (3-30 chars, alphanumeric and underscore) +支持两种查询模式: +- **用户名查询**:模糊匹配,返回最多 20 个结果 +- **邮箱查询**:精确匹配,返回 0 或 1 个结果 + +查询类型自动识别:包含 `@` 符号视为邮箱查询。 + +**Request:** +```json +{ + "query": "string (1-100 chars)" +} +``` **Response:** 200 OK ```json -{ - "id": "string", - "username": "string", - "avatar_url": "string?", - "bio": "string?" -} +[ + { + "id": "string", + "username": "string", + "avatar_url": "string?", + "bio": "string?" + } +] ``` **Errors:** - 401: 未认证 -- 404: 用户不存在 +- 503: Auth 服务不可用(仅邮箱查询) - 422: 请求参数无效 --- diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index af5a363..a0deb93 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -244,3 +244,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force- | 2026-02-25 | 简化启动方式:dev-app-up -> app-up,分离 bootstrap 与服务启动 | | 2026-02-25 | 重构为运维分层手册:Bootstrap Gate、分层验证、故障与回滚流程 | | 2026-02-25 | 新增配置漂移故障条目:修复 Auth 邮件模板失效与 signup 超时场景 | +| 2026-02-27 | 用户搜索支持邮箱精确匹配:query 含 @ 符号时走 auth.users → profiles 两步查询 | diff --git a/infra/scripts/app-down.sh b/infra/scripts/app-down.sh deleted file mode 100755 index 0cb64b4..0000000 --- a/infra/scripts/app-down.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -euo pipefail - -SESSION_NAME="${SESSION_NAME:-social-dev}" - -echo "=== App Down ===" - -if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then - echo "No tmux session '$SESSION_NAME' found." - exit 0 -fi - -echo "Stopping tmux session '$SESSION_NAME'..." - -tmux kill-session -t "$SESSION_NAME" - -echo "Session stopped and cleaned up." diff --git a/infra/scripts/app-up.sh b/infra/scripts/app-up.sh deleted file mode 100755 index 1314c06..0000000 --- a/infra/scripts/app-up.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" -SESSION_NAME="${SESSION_NAME:-social-dev}" -COMPOSE_FILE="$ROOT_DIR/infra/docker/docker-compose.yml" -ENV_FILE="$ROOT_DIR/.env" - -echo "=== App Up ===" -echo "This script starts web + worker processes in tmux." -echo "NOTE: Bootstrap (migrate + init-data) must be run separately." -echo "" - -if ! command -v tmux >/dev/null 2>&1; then - echo "Error: tmux is required." >&2 - exit 1 -fi - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: env file not found at $ENV_FILE" >&2 - exit 1 -fi - -if [ ! -f "$COMPOSE_FILE" ]; then - echo "Error: compose file not found at $COMPOSE_FILE" >&2 - exit 1 -fi - -set -a -# shellcheck disable=SC1090 -. "$ENV_FILE" -set +a - -if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then - echo "Error: tmux session '$SESSION_NAME' already exists." >&2 - echo "Hint: tmux kill-session -t $SESSION_NAME" >&2 - exit 1 -fi - -echo "Starting web + worker processes in tmux session '$SESSION_NAME'..." - -WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run gunicorn app:app --bind \ -${SOCIAL_WEB__HOST:-0.0.0.0}:${SOCIAL_WEB__PORT:-8000} --workers \ -${SOCIAL_WEB__GUNICORN__WORKERS:-2} --worker-class \ -${SOCIAL_WEB__GUNICORN__WORKER_CLASS:-uvicorn.workers.UvicornWorker} --timeout \ -${SOCIAL_WEB__GUNICORN__TIMEOUT:-60}" - -WORKER_CRITICAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-critical uv run celery -A core.celery.app worker --loglevel=info --queues=critical --concurrency=${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}" -WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run celery -A core.celery.app worker --loglevel=info --queues=default --concurrency=${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" -WORKER_BULK_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk uv run celery -A core.celery.app worker --loglevel=info --queues=bulk --concurrency=${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" - -tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" -tmux new-window -t "$SESSION_NAME" -n worker-critical "bash -lc \"$WORKER_CRITICAL_CMD; echo '[worker-critical] exited'; exec bash\"" -tmux new-window -t "$SESSION_NAME" -n worker-default "bash -lc \"$WORKER_DEFAULT_CMD; echo '[worker-default] exited'; exec bash\"" -tmux new-window -t "$SESSION_NAME" -n worker-bulk "bash -lc \"$WORKER_BULK_CMD; echo '[worker-bulk] exited'; exec bash\"" - -echo "" -echo "=== App Started ===" -echo "Log files will be created in logs/ directory:" -echo " - web.log, web.error.log" -echo " - worker-critical.log, worker-critical.error.log" -echo " - worker-default.log, worker-default.error.log" -echo " - worker-bulk.log, worker-bulk.error.log" -echo "" -echo "tmux attach -t $SESSION_NAME" -echo "tmux list-windows -t $SESSION_NAME" diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh new file mode 100755 index 0000000..61b668f --- /dev/null +++ b/infra/scripts/app.sh @@ -0,0 +1,107 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +SESSION_NAME="${SESSION_NAME:-social-dev}" +COMPOSE_FILE="$ROOT_DIR/infra/docker/docker-compose.yml" +ENV_FILE="$ROOT_DIR/.env" + +usage() { + echo "Usage: $0 {start|stop}" + echo "" + echo "Commands:" + echo " start Start web + worker processes in tmux" + echo " stop Stop and clean up tmux session" + exit 1 +} + +start() { + echo "=== App Up ===" + echo "This script starts web + worker processes in tmux." + echo "NOTE: Bootstrap (migrate + init-data) must be run separately." + echo "" + + if ! command -v tmux >/dev/null 2>&1; then + echo "Error: tmux is required." >&2 + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + echo "Error: env file not found at $ENV_FILE" >&2 + exit 1 + fi + + if [ ! -f "$COMPOSE_FILE" ]; then + echo "Error: compose file not found at $COMPOSE_FILE" >&2 + exit 1 + fi + + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + + if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then + echo "Error: tmux session '$SESSION_NAME' already exists." >&2 + echo "Hint: tmux kill-session -t $SESSION_NAME" >&2 + exit 1 + fi + + echo "Starting web + worker processes in tmux session '$SESSION_NAME'..." + + WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run gunicorn app:app --bind \ +${SOCIAL_WEB__HOST:-0.0.0.0}:${SOCIAL_WEB__PORT:-8000} --workers \ +${SOCIAL_WEB__GUNICORN__WORKERS:-2} --worker-class \ +${SOCIAL_WEB__GUNICORN__WORKER_CLASS:-uvicorn.workers.UvicornWorker} --timeout \ +${SOCIAL_WEB__GUNICORN__TIMEOUT:-60} \ +--log-level ${SOCIAL_RUNTIME__LOG_LEVEL:-info}" + + WORKER_CRITICAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-critical uv run celery -A core.celery.app worker --loglevel=info --queues=critical --concurrency=${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}" + WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run celery -A core.celery.app worker --loglevel=info --queues=default --concurrency=${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" + WORKER_BULK_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk uv run celery -A core.celery.app worker --loglevel=info --queues=bulk --concurrency=${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" + + tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-critical "bash -lc \"$WORKER_CRITICAL_CMD; echo '[worker-critical] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-default "bash -lc \"$WORKER_DEFAULT_CMD; echo '[worker-default] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-bulk "bash -lc \"$WORKER_BULK_CMD; echo '[worker-bulk] exited'; exec bash\"" + + echo "" + echo "=== App Started ===" + echo "Log files will be created in logs/ directory:" + echo " - web.log, web.error.log" + echo " - worker-critical.log, worker-critical.error.log" + echo " - worker-default.log, worker-default.error.log" + echo " - worker-bulk.log, worker-bulk.error.log" + echo "" + echo "tmux attach -t $SESSION_NAME" + echo "tmux list-windows -t $SESSION_NAME" +} + +stop() { + echo "=== App Down ===" + + if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then + echo "Stopping tmux session '$SESSION_NAME'..." + tmux kill-session -t "$SESSION_NAME" + else + echo "No tmux session '$SESSION_NAME' found." + fi + + echo "Checking for orphaned processes..." + if pgrep -f "gunicorn.*app:app" > /dev/null 2>&1; then + echo "Killing orphaned gunicorn processes..." + pkill -f "gunicorn.*app:app" + fi + if pgrep -f "celery.*worker" > /dev/null 2>&1; then + echo "Killing orphaned celery processes..." + pkill -f "celery.*worker" + fi + + echo "Session stopped and cleaned up." +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + *) usage ;; +esac