diff --git a/backend/app/api/service/user_service.py b/backend/app/api/service/user_service.py index 12bec538..882e4953 100644 --- a/backend/app/api/service/user_service.py +++ b/backend/app/api/service/user_service.py @@ -1,29 +1,18 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import os -from hashlib import sha256 - from email_validator import validate_email, EmailNotValidError -from fast_captcha import text_captcha -from fastapi import Request, HTTPException, Response, UploadFile from fastapi.security import OAuth2PasswordRequestForm -from fastapi_pagination.ext.async_sqlalchemy import paginate +from fastapi_pagination.ext.sqlalchemy import paginate from backend.app.api import jwt from backend.app.common.exception import errors -from backend.app.common.log import log -from backend.app.common.redis import redis_client -from backend.app.common.response.response_code import CodeEnum -from backend.app.core.conf import settings from backend.app.core.path_conf import AvatarPath from backend.app.crud.crud_user import UserDao from backend.app.database.db_mysql import async_db_session from backend.app.models import User -from backend.app.schemas.user import CreateUser, ResetPassword, UpdateUser, ELCode, Auth2 +from backend.app.schemas.user import CreateUser, ResetPassword, UpdateUser, Avatar from backend.app.utils import re_verify from backend.app.utils.format_string import cut_path -from backend.app.utils.generate_string import get_current_timestamp, get_uuid -from backend.app.utils.send_email import send_verification_code_email, SEND_EMAIL_LOGIN_TEXT class UserService: @@ -60,49 +49,6 @@ class UserService: # access_token = jwt.create_access_token(current_user.id) # return access_token, current_user.is_superuser - @staticmethod - async def login_email(*, request: Request, obj: Auth2): - async with async_db_session() as db: - current_email = await UserDao.check_email(db, obj.email) - if not current_email: - raise errors.NotFoundError(msg='邮箱不存在') - username = await UserDao.get_username_by_email(db, obj.email) - current_user = await UserDao.get_user_by_username(db, username) - if not current_user.is_active: - raise errors.AuthorizationError(msg='该用户已被锁定,无法登录') - try: - uid = request.app.state.email_login_code - except Exception: - raise errors.ForbiddenError(msg='请先获取邮箱验证码再登陆') - r_code = await redis_client.get(f'{uid}') - if not r_code: - raise errors.NotFoundError(msg='验证码失效,请重新获取') - if r_code != obj.code: - raise errors.CodeError(error=CodeEnum.CAPTCHA_ERROR) - await UserDao.update_user_login_time(db, username) - access_token = jwt.create_access_token(current_user.id) - return access_token, current_user.is_superuser - - @staticmethod - async def send_login_email_captcha(request: Request, obj: ELCode): - async with async_db_session() as db: - if not await UserDao.check_email(db, obj.email): - raise errors.NotFoundError(msg='邮箱不存在') - username = await UserDao.get_username_by_email(db, obj.email) - current_user = await UserDao.get_user_by_username(db, username) - if not current_user.is_active: - raise errors.ForbiddenError(msg='该用户已被锁定,无法登录,发送验证码失败') - try: - code = text_captcha() - await send_verification_code_email(obj.email, code, SEND_EMAIL_LOGIN_TEXT) - except Exception as e: - log.error('验证码发送失败 {}', e) - raise errors.ServerError(msg=f'验证码发送失败: {e}') - else: - uid = get_uuid() - await redis_client.set(uid, code, settings.EMAIL_LOGIN_CODE_MAX_AGE) - request.app.state.email_login_code = uid - @staticmethod async def register(obj: CreateUser): async with async_db_session.begin() as db: @@ -119,71 +65,13 @@ class UserService: await UserDao.create_user(db, obj) @staticmethod - async def get_pwd_rest_captcha(*, username_or_email: str, response: Response): - async with async_db_session() as db: - code = text_captcha() - if await UserDao.get_user_by_username(db, username_or_email): - try: - response.delete_cookie(key='fastapi_reset_pwd_code') - response.delete_cookie(key='fastapi_reset_pwd_username') - response.set_cookie( - key='fastapi_reset_pwd_code', - value=sha256(code.encode('utf-8')).hexdigest(), - max_age=settings.COOKIES_MAX_AGE - ) - response.set_cookie( - key='fastapi_reset_pwd_username', - value=username_or_email, - max_age=settings.COOKIES_MAX_AGE - ) - except Exception as e: - log.exception('无法发送验证码 {}', e) - raise e - current_user_email = await UserDao.get_email_by_username(db, username_or_email) - await send_verification_code_email(current_user_email, code) - else: - try: - validate_email(username_or_email, check_deliverability=False) - except EmailNotValidError: - raise HTTPException(status_code=404, detail='用户名不存在') - email_result = await UserDao.check_email(db, username_or_email) - if not email_result: - raise HTTPException(status_code=404, detail='邮箱不存在') - try: - response.delete_cookie(key='fastapi_reset_pwd_code') - response.delete_cookie(key='fastapi_reset_pwd_username') - response.set_cookie( - key='fastapi_reset_pwd_code', - value=sha256(code.encode('utf-8')).hexdigest(), - max_age=settings.COOKIES_MAX_AGE - ) - username = await UserDao.get_username_by_email(db, username_or_email) - response.set_cookie( - key='fastapi_reset_pwd_username', - value=username, - max_age=settings.COOKIES_MAX_AGE - ) - except Exception as e: - log.exception('无法发送验证码 {}', e) - raise e - await send_verification_code_email(username_or_email, code) - - @staticmethod - async def pwd_reset(*, obj: ResetPassword, request: Request, response: Response): + async def pwd_reset(obj: ResetPassword): async with async_db_session.begin() as db: pwd1 = obj.password1 pwd2 = obj.password2 - cookie_reset_pwd_code = request.cookies.get('fastapi_reset_pwd_code') - cookie_reset_pwd_username = request.cookies.get('fastapi_reset_pwd_username') if pwd1 != pwd2: raise errors.ForbiddenError(msg='两次密码输入不一致') - if cookie_reset_pwd_username is None or cookie_reset_pwd_code is None: - raise errors.NotFoundError(msg='验证码已失效,请重新获取验证码') - if cookie_reset_pwd_code != sha256(obj.code.encode('utf-8')).hexdigest(): - raise errors.ForbiddenError(msg='验证码错误') - await UserDao.reset_password(db, cookie_reset_pwd_username, obj.password2) - response.delete_cookie(key='fastapi_reset_pwd_code') - response.delete_cookie(key='fastapi_reset_pwd_username') + await UserDao.reset_password(db, obj.id, obj.password2) @staticmethod async def get_user_info(username: str): @@ -219,17 +107,11 @@ class UserService: if obj.mobile_number is not None: if not re_verify.is_mobile(obj.mobile_number): raise errors.ForbiddenError(msg='手机号码输入有误') - if obj.wechat is not None: - if not re_verify.is_wechat(obj.wechat): - raise errors.ForbiddenError(msg='微信号码输入有误') - if obj.qq is not None: - if not re_verify.is_qq(obj.qq): - raise errors.ForbiddenError(msg='QQ号码输入有误') count = await UserDao.update_userinfo(db, input_user, obj) return count @staticmethod - async def update_avatar(*, username: str, current_user: User, avatar: UploadFile): + async def update_avatar(*, username: str, current_user: User, avatar: Avatar): async with async_db_session.begin() as db: if not current_user.is_superuser: if not username == current_user.username: @@ -237,46 +119,7 @@ class UserService: input_user = await UserDao.get_user_by_username(db, username) if not input_user: raise errors.NotFoundError(msg='用户不存在') - input_user_avatar = input_user.avatar - if avatar is not None: - if input_user_avatar is not None: - try: - os.remove(AvatarPath + input_user_avatar) - except Exception as e: - log.error('用户 {} 更新头像时,原头像文件 {} 删除失败\n{}', current_user.username, - input_user_avatar, - e) - new_file = await avatar.read() - if 'image' not in avatar.content_type: - raise errors.ForbiddenError(msg='图片格式错误,请重新选择图片') - file_name = str(get_current_timestamp()) + '_' + avatar.filename - if not os.path.exists(AvatarPath): - os.makedirs(AvatarPath) - with open(AvatarPath + f'{file_name}', 'wb') as f: - f.write(new_file) - else: - file_name = input_user_avatar - count = await UserDao.update_avatar(db, input_user, file_name) - return count - - @staticmethod - async def delete_avatar(*, username: str, current_user: User): - async with async_db_session.begin() as db: - if not current_user.is_superuser: - if not username == current_user.username: - raise errors.AuthorizationError - input_user = await UserDao.get_user_by_username(db, username) - if not input_user: - raise errors.NotFoundError(msg='用户不存在') - input_user_avatar = input_user.avatar - if input_user_avatar is not None: - try: - os.remove(AvatarPath + input_user_avatar) - except Exception as e: - log.error('用户 {} 删除头像文件 {} 失败\n{}', input_user.username, input_user_avatar, e) - else: - raise errors.NotFoundError(msg='用户没有头像文件,请上传头像文件后再执行此操作') - count = await UserDao.delete_avatar(db, input_user.id) + count = await UserDao.update_avatar(db, input_user, avatar) return count @staticmethod @@ -312,12 +155,5 @@ class UserService: input_user = await UserDao.get_user_by_username(db, username) if not input_user: raise errors.NotFoundError(msg='用户不存在') - input_user_avatar = input_user.avatar - try: - if input_user_avatar is not None: - os.remove(AvatarPath + input_user_avatar) - except Exception as e: - log.error(f'删除用户 {input_user.username} 头像文件:{input_user_avatar} 失败\n{e}') - finally: - count = await UserDao.delete_user(db, input_user.id) - return count + count = await UserDao.delete_user(db, input_user.id) + return count diff --git a/backend/app/api/v1/auth/user.py b/backend/app/api/v1/auth/user.py index 91d72af1..d21ef97b 100644 --- a/backend/app/api/v1/auth/user.py +++ b/backend/app/api/v1/auth/user.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from fastapi import APIRouter, Depends, Request, Response, UploadFile +from fastapi import APIRouter, Depends from fastapi.security import OAuth2PasswordRequestForm from backend.app.api.jwt import CurrentUser, DependsUser, DependsSuperUser @@ -8,7 +8,7 @@ from backend.app.api.service.user_service import UserService from backend.app.common.pagination import Page from backend.app.common.response.response_schema import response_base from backend.app.schemas.token import Token -from backend.app.schemas.user import CreateUser, GetUserInfo, ResetPassword, Auth2, ELCode, UpdateUser +from backend.app.schemas.user import CreateUser, GetUserInfo, ResetPassword, UpdateUser, Avatar router = APIRouter() @@ -26,43 +26,15 @@ async def user_login(form_data: OAuth2PasswordRequestForm = Depends()): # return Token(access_token=token, is_superuser=is_super) -@router.post('/login/email/captcha', summary='发送邮箱登录验证码') -async def user_login_email_captcha(request: Request, obj: ELCode): - await UserService.send_login_email_captcha(request, obj) - return response_base.response_200() - - -@router.post('/login/email', summary='邮箱登录', description='邮箱登录', response_model=Token) -async def user_login_email(request: Request, obj: Auth2): - token, is_super = await UserService.login_email(request=request, obj=obj) - return Token(access_token=token, is_superuser=is_super) - - -@router.post('/logout', summary='用户退出', dependencies=[DependsUser]) -async def logout(): - return response_base.response_200() - - @router.post('/register', summary='用户注册') async def user_register(obj: CreateUser): await UserService.register(obj) return response_base.response_200() -@router.post('/password/reset/code', summary='发送密码重置验证码', description='可以通过用户名或者邮箱重置密码') -async def password_reset_captcha(username_or_email: str, response: Response): - await UserService.get_pwd_rest_captcha(username_or_email=username_or_email, response=response) - return response_base.response_200() - - -@router.post('/password/reset', summary='密码重置请求') -async def password_reset(obj: ResetPassword, request: Request, response: Response): - await UserService.pwd_reset(obj=obj, request=request, response=response) - return response_base.response_200() - - -@router.get('/password/reset/done', summary='重置密码完成') -def password_reset_done(): +@router.post('/password/reset', summary='密码重置') +async def password_reset(obj: ResetPassword): + await UserService.pwd_reset(obj) return response_base.response_200() @@ -81,21 +53,13 @@ async def update_userinfo(username: str, obj: UpdateUser, current_user: CurrentU @router.put('/{username}/avatar', summary='更新头像') -async def update_avatar(username: str, avatar: UploadFile, current_user: CurrentUser): +async def update_avatar(username: str, avatar: Avatar, current_user: CurrentUser): count = await UserService.update_avatar(username=username, current_user=current_user, avatar=avatar) if count > 0: return response_base.response_200() return response_base.fail() -@router.delete('/{username}/avatar', summary='删除头像文件') -async def delete_avatar(username: str, current_user: CurrentUser): - count = await UserService.delete_avatar(username=username, current_user=current_user) - if count > 0: - return response_base.response_200() - return response_base.fail() - - @router.get('', summary='获取所有用户', dependencies=[DependsUser]) async def get_all_users() -> Page[GetUserInfo]: return await UserService.get_user_list() diff --git a/backend/app/crud/crud_user.py b/backend/app/crud/crud_user.py index b771d236..41b8d1f2 100644 --- a/backend/app/crud/crud_user.py +++ b/backend/app/crud/crud_user.py @@ -9,7 +9,7 @@ from sqlalchemy.sql import Select from backend.app.api import jwt from backend.app.crud.base import CRUDBase from backend.app.models import User -from backend.app.schemas.user import CreateUser, UpdateUser +from backend.app.schemas.user import CreateUser, UpdateUser, Avatar class CRUDUser(CRUDBase[User, CreateUser, UpdateUser]): @@ -28,18 +28,6 @@ class CRUDUser(CRUDBase[User, CreateUser, UpdateUser]): ) return user.rowcount - async def get_email_by_username(self, db: AsyncSession, username: str) -> str: - user = await self.get_user_by_username(db, username) - return user.email - - async def get_username_by_email(self, db: AsyncSession, email: str) -> str: - user = await db.execute(select(self.model).where(self.model.email == email)) - return user.scalars().first().username - - async def get_avatar_by_username(self, db: AsyncSession, username: str) -> str: - user = await self.get_user_by_username(db, username) - return user.avatar - async def create_user(self, db: AsyncSession, create: CreateUser) -> NoReturn: create.password = jwt.get_hash_password(create.password) new_user = self.model(**create.dict()) @@ -53,7 +41,7 @@ class CRUDUser(CRUDBase[User, CreateUser, UpdateUser]): ) return user.rowcount - async def update_avatar(self, db: AsyncSession, current_user: User, avatar: str) -> int: + async def update_avatar(self, db: AsyncSession, current_user: User, avatar: Avatar) -> int: user = await db.execute( update(self.model) .where(self.model.id == current_user.id) @@ -68,18 +56,10 @@ class CRUDUser(CRUDBase[User, CreateUser, UpdateUser]): mail = await db.execute(select(self.model).where(self.model.email == email)) return mail.scalars().first() - async def delete_avatar(self, db: AsyncSession, user_id: int) -> int: + async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int: user = await db.execute( update(self.model) - .where(self.model.id == user_id) - .values(avatar=None) - ) - return user.rowcount - - async def reset_password(self, db: AsyncSession, username: str, password: str) -> int: - user = await db.execute( - update(self.model) - .where(self.model.username == username) + .where(self.model.id == pk) .values(password=jwt.get_hash_password(password)) ) return user.rowcount diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 19208898..38b74adf 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -23,9 +23,5 @@ class User(DataClassBase): is_active: Mapped[bool] = mapped_column(default=True, comment='用户账号状态') avatar: Mapped[Optional[str]] = mapped_column(String(255), default=None, comment='头像') mobile_number: Mapped[Optional[str]] = mapped_column(String(11), default=None, comment='手机号') - wechat: Mapped[Optional[str]] = mapped_column(String(20), default=None, comment='微信') - qq: Mapped[Optional[str]] = mapped_column(String(10), default=None, comment='QQ') - blog_address: Mapped[Optional[str]] = mapped_column(String(255), default=None, comment='博客地址') - introduction: Mapped[Optional[str]] = mapped_column(LONGTEXT, default=None, comment='自我介绍') time_joined: Mapped[datetime] = mapped_column(init=False, default=func.now(), comment='注册时间') last_login: Mapped[Optional[datetime]] = mapped_column(init=False, onupdate=func.now(), comment='上次登录') diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index c6615521..18503bc6 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -3,7 +3,7 @@ import datetime from typing import Optional -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, HttpUrl class Auth(BaseModel): @@ -11,14 +11,6 @@ class Auth(BaseModel): password: str -class ELCode(BaseModel): - email: EmailStr - - -class Auth2(ELCode): - code: str - - class CreateUser(Auth): email: str = Field(..., example='user@example.com') @@ -27,10 +19,10 @@ class UpdateUser(BaseModel): username: str email: str mobile_number: Optional[str] = None - wechat: Optional[str] = None - qq: Optional[str] = None - blog_address: Optional[str] = None - introduction: Optional[str] = None + + +class Avatar(BaseModel): + url: HttpUrl = Field(..., description='头像地址') class GetUserInfo(UpdateUser): @@ -47,6 +39,6 @@ class GetUserInfo(UpdateUser): class ResetPassword(BaseModel): - code: str + id: int = Field(..., example='1', description='用户ID') password1: str password2: str