diff --git a/fastapi-backend/alembic.ini b/fastapi-backend/alembic.ini new file mode 100644 index 0000000..52cd8d1 --- /dev/null +++ b/fastapi-backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +# 模板路径 +script_location = fastapi-backend/alembic + +# 数据库连接URL +sqlalchemy.url = mysql+pymysql://jiebanke:aiot741$12346@nj-cdb-3pwh2kz1.sql.tencentcdb.com:20784/jbkdata + +# 日志配置 +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/fastapi-backend/alembic/env.py b/fastapi-backend/alembic/env.py new file mode 100644 index 0000000..99b9f96 --- /dev/null +++ b/fastapi-backend/alembic/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 导入模型 +from app.models.user import Base + +# 导入配置 +from app.core.config import settings + +# 确保Python能够找到app模块 +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# 这是Alembic Config对象,它提供对.ini文件中值的访问 +config = context.config + +# 解释配置文件并设置日志记录器 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 添加你的模型元数据对象 +target_metadata = Base.metadata + +# 其他值来自config,可以通过以下方式定义: +# my_important_option = config.get_main_option("my_important_option") +# ... 等等。 + + +def run_migrations_offline() -> None: + """在'offline'模式下运行迁移。 + + 这配置了上下文,只需要一个URL,并且不要求引擎可用。 + 跳过引擎创建,甚至不需要DBAPI可用。 + + 调用context.execute()来执行迁移。 + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """在'online'模式下运行迁移。 + + 在这种情况下,我们创建了一个Engine并将其与迁移上下文关联。 + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/fastapi-backend/alembic/versions/0001_initial_migration.py b/fastapi-backend/alembic/versions/0001_initial_migration.py new file mode 100644 index 0000000..f568c27 --- /dev/null +++ b/fastapi-backend/alembic/versions/0001_initial_migration.py @@ -0,0 +1,62 @@ +"""initial migration + +Revision ID: 0001 +Revises: +Create Date: 2025-09-11 16:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # 创建用户表 + op.create_table( + 'users', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('username', sa.String(50), unique=True, index=True, nullable=False), + sa.Column('password_hash', sa.String(255), nullable=False), + sa.Column('user_type', sa.Enum('farmer', 'merchant', 'admin', 'super_admin'), server_default='farmer'), + sa.Column('real_name', sa.String(50)), + sa.Column('nickname', sa.String(50)), + sa.Column('avatar_url', sa.String(255)), + sa.Column('email', sa.String(100), unique=True, index=True), + sa.Column('phone', sa.String(20), unique=True, index=True), + sa.Column('gender', sa.Enum('male', 'female', 'other'), server_default='other'), + sa.Column('birthday', sa.DateTime), + sa.Column('status', sa.Enum('active', 'inactive'), server_default='active'), + sa.Column('wechat_openid', sa.String(100), unique=True, index=True), + sa.Column('wechat_unionid', sa.String(100), unique=True, index=True), + sa.Column('level', sa.Integer, server_default='1'), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column('last_login', sa.DateTime) + ) + + # 创建管理员表 + op.create_table( + 'admins', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('username', sa.String(50), unique=True, index=True, nullable=False), + sa.Column('password', sa.String(255), nullable=False), + sa.Column('email', sa.String(100), unique=True, index=True), + sa.Column('nickname', sa.String(50)), + sa.Column('avatar', sa.String(255)), + sa.Column('role', sa.Enum('admin', 'super_admin'), server_default='admin'), + sa.Column('status', sa.Enum('active', 'inactive'), server_default='active'), + sa.Column('last_login', sa.DateTime), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now()) + ) + + +def downgrade(): + op.drop_table('admins') + op.drop_table('users') \ No newline at end of file diff --git a/fastapi-backend/app/api/api.py b/fastapi-backend/app/api/api.py new file mode 100644 index 0000000..d34f3a1 --- /dev/null +++ b/fastapi-backend/app/api/api.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.api.endpoints import auth, users +from app.core.config import settings +from app.utils.response import error_response + +# 创建FastAPI应用 +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + docs_url="/api-docs", + redoc_url="/redoc", +) + +# 配置CORS +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) +else: + # 默认CORS配置 + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# 创建API路由 +api_router = APIRouter() + +# 添加各个端点路由 +api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) +api_router.include_router(users.router, prefix="/users", tags=["用户"]) + +# 将API路由添加到应用 +app.include_router(api_router, prefix=settings.API_V1_STR) + + +# 自定义异常处理 +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request, exc): + return JSONResponse( + status_code=exc.status_code, + content=error_response( + message=str(exc.detail), + code=exc.status_code + ) + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + errors = [] + for error in exc.errors(): + error_msg = f"{error['loc'][-1]}: {error['msg']}" + errors.append(error_msg) + + return JSONResponse( + status_code=400, + content=error_response( + message="请求参数验证失败", + code=400, + data={"errors": errors} + ) + ) + + +# 健康检查路由 +@app.get("/health") +async def health_check(): + import platform + import psutil + from datetime import datetime + + return { + "status": "OK", + "timestamp": datetime.now().isoformat(), + "uptime": psutil.boot_time(), + "environment": settings.DEBUG and "development" or "production", + "no_db_mode": settings.NO_DB_MODE, + "system_info": { + "python_version": platform.python_version(), + "platform": platform.platform(), + "cpu_count": psutil.cpu_count(), + "memory": { + "total": psutil.virtual_memory().total, + "available": psutil.virtual_memory().available, + } + } + } + + +# 系统统计路由 +@app.get("/system-stats") +async def system_stats(): + import platform + import psutil + from datetime import datetime + + return { + "status": "OK", + "timestamp": datetime.now().isoformat(), + "environment": settings.DEBUG and "development" or "production", + "python_version": platform.python_version(), + "memory_usage": dict(psutil.virtual_memory()._asdict()), + "uptime": psutil.boot_time(), + "cpu_count": psutil.cpu_count(), + "platform": platform.platform(), + "architecture": platform.architecture(), + "no_db_mode": settings.NO_DB_MODE + } \ No newline at end of file diff --git a/fastapi-backend/app/api/deps.py b/fastapi-backend/app/api/deps.py new file mode 100644 index 0000000..56fe47e --- /dev/null +++ b/fastapi-backend/app/api/deps.py @@ -0,0 +1,115 @@ +from typing import Generator, Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import verify_password +from app.crud.user import user, admin +from app.db.session import SessionLocal +from app.models.user import User, Admin +from app.schemas.user import TokenPayload + +# OAuth2密码承载令牌 +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login" +) + +# 获取数据库会话 +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + +# 获取当前用户 +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = TokenPayload(**payload) + + # 检查令牌是否过期 + if token_data.exp is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="令牌无效", + headers={"WWW-Authenticate": "Bearer"}, + ) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无法验证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 获取用户 + current_user = user.get(db, user_id=token_data.sub) + if not current_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + # 检查用户是否活跃 + if not user.is_active(current_user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账户已被禁用") + + return current_user + +# 获取当前活跃用户 +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if not user.is_active(current_user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账户已被禁用") + return current_user + +# 获取当前管理员 +def get_current_admin( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> Admin: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无法验证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 获取管理员 + current_admin = admin.get(db, admin_id=token_data.sub) + if not current_admin: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="管理员不存在") + + # 检查管理员是否活跃 + if not admin.is_active(current_admin): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账户已被禁用") + + return current_admin + +# 获取当前活跃管理员 +def get_current_active_admin( + current_admin: Admin = Depends(get_current_admin), +) -> Admin: + if not admin.is_active(current_admin): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账户已被禁用") + return current_admin + +# 获取超级管理员 +def get_current_super_admin( + current_admin: Admin = Depends(get_current_admin), +) -> Admin: + if current_admin.role != "super_admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要超级管理员权限" + ) + return current_admin \ No newline at end of file diff --git a/fastapi-backend/app/api/endpoints/auth.py b/fastapi-backend/app/api/endpoints/auth.py new file mode 100644 index 0000000..e20fa85 --- /dev/null +++ b/fastapi-backend/app/api/endpoints/auth.py @@ -0,0 +1,332 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.api.deps import get_db, get_current_user +from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token +from app.crud.user import user, admin +from app.schemas.user import ( + UserCreate, UserResponse, UserLogin, UserWithToken, + Token, PasswordChange, AdminWithToken +) +from app.utils.response import success_response, error_response +from app.utils.errors import BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError + +router = APIRouter() + + +@router.post("/register", response_model=UserWithToken, status_code=status.HTTP_201_CREATED) +def register( + *, + db: Session = Depends(get_db), + user_in: UserCreate, +) -> Any: + """ + 用户注册 + """ + # 检查用户名是否已存在 + if user.get_by_username(db, username=user_in.username): + raise BadRequestError("用户名已存在") + + # 检查邮箱是否已存在 + if user_in.email and user.get_by_email(db, email=user_in.email): + raise BadRequestError("邮箱已存在") + + # 检查手机号是否已存在 + if user_in.phone and user.get_by_phone(db, phone=user_in.phone): + raise BadRequestError("手机号已存在") + + # 创建新用户 + db_user = user.create(db, obj_in=user_in) + + # 更新最后登录时间 + user.update_last_login(db, db_obj=db_user) + + # 生成令牌 + access_token = create_access_token(db_user.id) + refresh_token = create_refresh_token(db_user.id) + + return success_response( + data={ + "user": db_user, + "token": access_token, + "refresh_token": refresh_token + }, + message="注册成功", + code=201 + ) + + +@router.post("/login", response_model=UserWithToken) +def login( + *, + db: Session = Depends(get_db), + form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + 用户登录 + """ + # 验证用户 + db_user = user.authenticate(db, username=form_data.username, password=form_data.password) + if not db_user: + raise UnauthorizedError("用户名或密码错误") + + # 检查用户状态 + if not user.is_active(db_user): + raise ForbiddenError("账户已被禁用") + + # 更新最后登录时间 + user.update_last_login(db, db_obj=db_user) + + # 生成令牌 + access_token = create_access_token(db_user.id) + refresh_token = create_refresh_token(db_user.id) + + return success_response( + data={ + "user": db_user, + "token": access_token, + "refresh_token": refresh_token + }, + message="登录成功" + ) + + +@router.post("/login/json", response_model=UserWithToken) +def login_json( + *, + db: Session = Depends(get_db), + login_in: UserLogin +) -> Any: + """ + 用户登录(JSON格式) + """ + # 验证用户 + db_user = user.authenticate(db, username=login_in.username, password=login_in.password) + if not db_user: + raise UnauthorizedError("用户名或密码错误") + + # 检查用户状态 + if not user.is_active(db_user): + raise ForbiddenError("账户已被禁用") + + # 更新最后登录时间 + user.update_last_login(db, db_obj=db_user) + + # 生成令牌 + access_token = create_access_token(db_user.id) + refresh_token = create_refresh_token(db_user.id) + + return success_response( + data={ + "user": db_user, + "token": access_token, + "refresh_token": refresh_token + }, + message="登录成功" + ) + + +@router.post("/refresh-token", response_model=Token) +def refresh_token( + *, + db: Session = Depends(get_db), + refresh_token: str = Body(..., embed=True) +) -> Any: + """ + 刷新访问令牌 + """ + try: + from jose import jwt + from pydantic import ValidationError + from app.schemas.user import TokenPayload + + payload = jwt.decode( + refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = TokenPayload(**payload) + + # 检查令牌类型 + if token_data.type != "refresh": + raise UnauthorizedError("无效的刷新令牌") + + # 检查用户是否存在 + db_user = user.get(db, user_id=token_data.sub) + if not db_user: + raise NotFoundError("用户不存在") + + # 检查用户状态 + if not user.is_active(db_user): + raise ForbiddenError("账户已被禁用") + + # 生成新令牌 + access_token = create_access_token(db_user.id) + new_refresh_token = create_refresh_token(db_user.id) + + return success_response( + data={ + "access_token": access_token, + "refresh_token": new_refresh_token, + "token_type": "bearer" + } + ) + except (jwt.JWTError, ValidationError): + raise UnauthorizedError("无效的刷新令牌") + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info( + current_user: UserResponse = Depends(get_current_user) +) -> Any: + """ + 获取当前用户信息 + """ + return success_response(data=current_user) + + +@router.put("/profile", response_model=UserResponse) +def update_profile( + *, + db: Session = Depends(get_db), + current_user: UserResponse = Depends(get_current_user), + profile_in: dict = Body(...) +) -> Any: + """ + 更新用户个人信息 + """ + # 更新用户信息 + db_user = user.update(db, db_obj=current_user, obj_in=profile_in) + + return success_response( + data=db_user, + message="个人信息更新成功" + ) + + +@router.put("/password", response_model=dict) +def change_password( + *, + db: Session = Depends(get_db), + current_user: UserResponse = Depends(get_current_user), + password_in: PasswordChange +) -> Any: + """ + 修改密码 + """ + from app.core.security import verify_password + + # 验证当前密码 + if not verify_password(password_in.current_password, current_user.password_hash): + raise UnauthorizedError("当前密码错误") + + # 更新密码 + user.update_password(db, db_obj=current_user, new_password=password_in.new_password) + + return success_response(message="密码修改成功") + + +@router.post("/admin/login", response_model=AdminWithToken) +def admin_login( + *, + db: Session = Depends(get_db), + login_in: UserLogin +) -> Any: + """ + 管理员登录 + """ + # 验证管理员 + db_admin = admin.authenticate(db, username=login_in.username, password=login_in.password) + if not db_admin: + raise UnauthorizedError("用户名或密码错误") + + # 检查管理员状态 + if not admin.is_active(db_admin): + raise ForbiddenError("账户已被禁用") + + # 更新最后登录时间 + admin.update_last_login(db, db_obj=db_admin) + + # 生成令牌 + access_token = create_access_token(db_admin.id) + refresh_token = create_refresh_token(db_admin.id) + + return success_response( + data={ + "admin": db_admin, + "token": access_token, + "refresh_token": refresh_token + }, + message="管理员登录成功" + ) + + +@router.post("/wechat", response_model=UserWithToken) +def wechat_login( + *, + db: Session = Depends(get_db), + code: str = Body(...), + user_info: dict = Body(None) +) -> Any: + """ + 微信登录/注册 + """ + # 模拟获取微信用户信息 + wechat_user_info = { + "openid": f"mock_openid_{code}", + "unionid": f"mock_unionid_{code}", + "nickname": user_info.get("nickName") if user_info else "微信用户", + "avatar": user_info.get("avatarUrl") if user_info else "", + "gender": "male" if user_info and user_info.get("gender") == 1 else + "female" if user_info and user_info.get("gender") == 2 else "other" + } + + # 查找是否已存在微信用户 + db_user = user.get_by_wechat_openid(db, openid=wechat_user_info["openid"]) + + if db_user: + # 更新最后登录时间 + user.update_last_login(db, db_obj=db_user) + else: + # 创建新用户(微信注册) + import secrets + import string + from app.schemas.user import UserCreate + + # 生成随机密码 + alphabet = string.ascii_letters + string.digits + random_password = ''.join(secrets.choice(alphabet) for _ in range(12)) + + # 创建用户 + user_in = UserCreate( + username=f"wx_{wechat_user_info['openid'][-8:]}", + password=random_password, + nickname=wechat_user_info["nickname"], + user_type="farmer" + ) + + db_user = user.create(db, obj_in=user_in) + + # 更新微信信息 + user.update(db, db_obj=db_user, obj_in={ + "wechat_openid": wechat_user_info["openid"], + "wechat_unionid": wechat_user_info["unionid"], + "avatar_url": wechat_user_info["avatar"], + "gender": wechat_user_info["gender"] + }) + + # 生成令牌 + access_token = create_access_token(db_user.id) + refresh_token = create_refresh_token(db_user.id) + + return success_response( + data={ + "user": db_user, + "token": access_token, + "refresh_token": refresh_token + }, + message="微信登录成功" + ) \ No newline at end of file diff --git a/fastapi-backend/app/api/endpoints/users.py b/fastapi-backend/app/api/endpoints/users.py new file mode 100644 index 0000000..220b5c5 --- /dev/null +++ b/fastapi-backend/app/api/endpoints/users.py @@ -0,0 +1,174 @@ +from typing import Any, List + +from fastapi import APIRouter, Body, Depends, Query, Path, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db, get_current_user, get_current_admin, get_current_super_admin +from app.crud.user import user +from app.models.user import User +from app.schemas.user import ( + UserResponse, UserUpdate, UserListResponse, + PaginationResponse, UserStatistics, BatchUserStatusUpdate +) +from app.utils.response import success_response +from app.utils.errors import NotFoundError, BadRequestError + +router = APIRouter() + + +@router.get("/profile", response_model=UserResponse) +def get_user_profile( + current_user: User = Depends(get_current_user), +) -> Any: + """ + 获取当前用户个人信息 + """ + return success_response(data=current_user) + + +@router.put("/profile", response_model=UserResponse) +def update_user_profile( + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + user_in: UserUpdate, +) -> Any: + """ + 更新当前用户个人信息 + """ + # 更新用户信息 + db_user = user.update(db, db_obj=current_user, obj_in=user_in) + + return success_response( + data=db_user, + message="个人信息更新成功" + ) + + +@router.get("", response_model=UserListResponse) +def get_users( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + user_type: str = Query(None, description="用户类型"), + status: str = Query(None, description="用户状态"), + keyword: str = Query(None, description="搜索关键词"), +) -> Any: + """ + 获取用户列表(管理员) + """ + # 计算分页参数 + skip = (page - 1) * page_size + + # 获取用户列表 + users_list = user.get_multi( + db, skip=skip, limit=page_size, + user_type=user_type, status=status, keyword=keyword + ) + + # 获取用户总数 + total = user.count( + db, user_type=user_type, status=status, keyword=keyword + ) + + # 计算总页数 + total_pages = (total + page_size - 1) // page_size + + # 构建分页信息 + pagination = PaginationResponse( + page=page, + page_size=page_size, + total=total, + total_pages=total_pages + ) + + return success_response( + data={ + "users": users_list, + "pagination": pagination + } + ) + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user_by_id( + user_id: int = Path(..., ge=1), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin), +) -> Any: + """ + 获取用户详情(管理员) + """ + # 获取用户 + db_user = user.get(db, user_id=user_id) + if not db_user: + raise NotFoundError("用户不存在") + + return success_response(data=db_user) + + +@router.get("/statistics", response_model=UserStatistics) +def get_user_statistics( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin), +) -> Any: + """ + 获取用户统计信息(管理员) + """ + # 获取统计信息 + statistics = user.get_statistics(db) + + return success_response(data=statistics) + + +@router.post("/batch-status", response_model=dict) +def batch_update_user_status( + *, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin), + batch_in: BatchUserStatusUpdate, +) -> Any: + """ + 批量操作用户状态(管理员) + """ + # 检查用户ID列表是否为空 + if not batch_in.user_ids: + raise BadRequestError("用户ID列表不能为空") + + # 批量更新用户状态 + affected_rows = user.batch_update_status( + db, user_ids=batch_in.user_ids, status=batch_in.status + ) + + return success_response( + data={ + "message": f"成功更新{affected_rows}个用户的状态", + "affected_rows": affected_rows + } + ) + + +@router.delete("/{user_id}", response_model=dict) +def delete_user( + user_id: int = Path(..., ge=1), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_super_admin), +) -> Any: + """ + 删除用户(超级管理员) + """ + # 获取用户 + db_user = user.get(db, user_id=user_id) + if not db_user: + raise NotFoundError("用户不存在") + + # 删除用户 + user.remove(db, user_id=user_id) + + return success_response( + data={ + "message": "用户删除成功", + "user_id": user_id + } + ) \ No newline at end of file diff --git a/fastapi-backend/app/core/config.py b/fastapi-backend/app/core/config.py new file mode 100644 index 0000000..985a7ef --- /dev/null +++ b/fastapi-backend/app/core/config.py @@ -0,0 +1,59 @@ +import os +from typing import Any, Dict, List, Optional, Union +from pydantic import AnyHttpUrl, field_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # 基本配置 + PROJECT_NAME: str = "结伴客API" + API_V1_STR: str = "/api/v1" + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + + # 服务器配置 + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "3110")) + + # 安全配置 + SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-jwt-secret-key-2024") + ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 + REFRESH_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_MINUTES", "43200")) # 30天 + ALGORITHM: str = "HS256" + + # 数据库配置 + DB_HOST: str = os.getenv("DB_HOST", "nj-cdb-3pwh2kz1.sql.tencentcdb.com") + DB_PORT: int = int(os.getenv("DB_PORT", "20784")) + DB_USER: str = os.getenv("DB_USER", "jiebanke") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "aiot741$12346") + DB_NAME: str = os.getenv("DB_NAME", "jbkdata") + + # 构建数据库URL + @property + def SQLALCHEMY_DATABASE_URI(self) -> str: + return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + # CORS配置 + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + @field_validator("BACKEND_CORS_ORIGINS", mode="before") + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # 上传文件配置 + UPLOAD_DIR: str = "uploads" + MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 10MB + ALLOWED_EXTENSIONS: List[str] = ["jpg", "jpeg", "png", "gif", "webp"] + + # 无数据库模式 + NO_DB_MODE: bool = os.getenv("NO_DB_MODE", "False").lower() == "true" + + class Config: + case_sensitive = True + env_file = ".env" + + +settings = Settings() \ No newline at end of file diff --git a/fastapi-backend/app/core/security.py b/fastapi-backend/app/core/security.py new file mode 100644 index 0000000..b5533dd --- /dev/null +++ b/fastapi-backend/app/core/security.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +# 密码上下文 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# 验证密码 +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +# 获取密码哈希 +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +# 创建访问令牌 +def create_access_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +# 创建刷新令牌 +def create_refresh_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt \ No newline at end of file diff --git a/fastapi-backend/app/crud/user.py b/fastapi-backend/app/crud/user.py new file mode 100644 index 0000000..57bdce2 --- /dev/null +++ b/fastapi-backend/app/crud/user.py @@ -0,0 +1,287 @@ +from datetime import datetime +from typing import Any, Dict, Optional, Union, List +from sqlalchemy.orm import Session +from sqlalchemy import func, or_ + +from app.core.security import get_password_hash, verify_password +from app.models.user import User, Admin +from app.schemas.user import UserCreate, UserUpdate, AdminCreate, AdminUpdate + + +# 用户CRUD操作 +class CRUDUser: + # 根据ID获取用户 + def get(self, db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + # 根据用户名获取用户 + def get_by_username(self, db: Session, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + # 根据邮箱获取用户 + def get_by_email(self, db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + # 根据手机号获取用户 + def get_by_phone(self, db: Session, phone: str) -> Optional[User]: + return db.query(User).filter(User.phone == phone).first() + + # 根据微信OpenID获取用户 + def get_by_wechat_openid(self, db: Session, openid: str) -> Optional[User]: + return db.query(User).filter(User.wechat_openid == openid).first() + + # 创建用户 + def create(self, db: Session, obj_in: UserCreate) -> User: + db_obj = User( + username=obj_in.username, + password_hash=get_password_hash(obj_in.password), + user_type=obj_in.user_type, + real_name=obj_in.real_name or obj_in.username, + nickname=obj_in.nickname or obj_in.username, + email=obj_in.email, + phone=obj_in.phone, + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新用户 + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + # 更新时间 + update_data["updated_at"] = datetime.now() + + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新用户密码 + def update_password(self, db: Session, db_obj: User, new_password: str) -> User: + db_obj.password_hash = get_password_hash(new_password) + db_obj.updated_at = datetime.now() + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新用户最后登录时间 + def update_last_login(self, db: Session, db_obj: User) -> User: + db_obj.last_login = datetime.now() + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 验证用户密码 + def authenticate(self, db: Session, username: str, password: str) -> Optional[User]: + user = self.get_by_username(db, username) + if not user: + user = self.get_by_email(db, username) + if not user: + user = self.get_by_phone(db, username) + if not user: + return None + if not verify_password(password, user.password_hash): + return None + return user + + # 检查用户是否活跃 + def is_active(self, user: User) -> bool: + return user.status == "active" + + # 获取用户列表(带分页) + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100, + user_type: Optional[str] = None, status: Optional[str] = None, + keyword: Optional[str] = None + ) -> List[User]: + query = db.query(User) + + # 应用过滤条件 + if user_type: + query = query.filter(User.user_type == user_type) + if status: + query = query.filter(User.status == status) + if keyword: + query = query.filter( + or_( + User.username.like(f"%{keyword}%"), + User.real_name.like(f"%{keyword}%"), + User.nickname.like(f"%{keyword}%"), + User.email.like(f"%{keyword}%"), + User.phone.like(f"%{keyword}%") + ) + ) + + return query.offset(skip).limit(limit).all() + + # 获取用户总数 + def count( + self, db: Session, *, user_type: Optional[str] = None, + status: Optional[str] = None, keyword: Optional[str] = None + ) -> int: + query = db.query(func.count(User.id)) + + # 应用过滤条件 + if user_type: + query = query.filter(User.user_type == user_type) + if status: + query = query.filter(User.status == status) + if keyword: + query = query.filter( + or_( + User.username.like(f"%{keyword}%"), + User.real_name.like(f"%{keyword}%"), + User.nickname.like(f"%{keyword}%"), + User.email.like(f"%{keyword}%"), + User.phone.like(f"%{keyword}%") + ) + ) + + return query.scalar() + + # 批量更新用户状态 + def batch_update_status( + self, db: Session, user_ids: List[int], status: str + ) -> int: + result = db.query(User).filter(User.id.in_(user_ids)).update( + {"status": status, "updated_at": datetime.now()}, + synchronize_session=False + ) + db.commit() + return result + + # 删除用户 + def remove(self, db: Session, *, user_id: int) -> Optional[User]: + user = db.query(User).filter(User.id == user_id).first() + if user: + db.delete(user) + db.commit() + return user + + # 获取用户统计信息 + def get_statistics(self, db: Session) -> Dict[str, Any]: + total_users = db.query(func.count(User.id)).scalar() + farmers = db.query(func.count(User.id)).filter(User.user_type == "farmer").scalar() + merchants = db.query(func.count(User.id)).filter(User.user_type == "merchant").scalar() + admins = db.query(func.count(User.id)).filter( + or_(User.user_type == "admin", User.user_type == "super_admin") + ).scalar() + active_users = db.query(func.count(User.id)).filter(User.status == "active").scalar() + inactive_users = db.query(func.count(User.id)).filter(User.status == "inactive").scalar() + + return { + "total_users": total_users, + "farmers": farmers, + "merchants": merchants, + "admins": admins, + "active_users": active_users, + "inactive_users": inactive_users, + "date": datetime.now() + } + + +# 管理员CRUD操作 +class CRUDAdmin: + # 根据ID获取管理员 + def get(self, db: Session, admin_id: int) -> Optional[Admin]: + return db.query(Admin).filter(Admin.id == admin_id).first() + + # 根据用户名获取管理员 + def get_by_username(self, db: Session, username: str) -> Optional[Admin]: + return db.query(Admin).filter(Admin.username == username).first() + + # 创建管理员 + def create(self, db: Session, obj_in: AdminCreate) -> Admin: + db_obj = Admin( + username=obj_in.username, + password=get_password_hash(obj_in.password), + email=obj_in.email, + nickname=obj_in.nickname or obj_in.username, + avatar=obj_in.avatar, + role=obj_in.role, + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新管理员 + def update( + self, db: Session, *, db_obj: Admin, obj_in: Union[AdminUpdate, Dict[str, Any]] + ) -> Admin: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + # 更新时间 + update_data["updated_at"] = datetime.now() + + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新管理员密码 + def update_password(self, db: Session, db_obj: Admin, new_password: str) -> Admin: + db_obj.password = get_password_hash(new_password) + db_obj.updated_at = datetime.now() + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 更新管理员最后登录时间 + def update_last_login(self, db: Session, db_obj: Admin) -> Admin: + db_obj.last_login = datetime.now() + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + # 验证管理员密码 + def authenticate(self, db: Session, username: str, password: str) -> Optional[Admin]: + admin = self.get_by_username(db, username) + if not admin: + return None + if not verify_password(password, admin.password): + return None + return admin + + # 检查管理员是否活跃 + def is_active(self, admin: Admin) -> bool: + return admin.status == "active" + + # 删除管理员 + def remove(self, db: Session, *, admin_id: int) -> Optional[Admin]: + admin = db.query(Admin).filter(Admin.id == admin_id).first() + if admin: + db.delete(admin) + db.commit() + return admin + + +# 实例化CRUD对象 +user = CRUDUser() +admin = CRUDAdmin() \ No newline at end of file diff --git a/fastapi-backend/app/db/session.py b/fastapi-backend/app/db/session.py new file mode 100644 index 0000000..c11086b --- /dev/null +++ b/fastapi-backend/app/db/session.py @@ -0,0 +1,29 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +# 创建数据库引擎 +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URI, + pool_pre_ping=True, + pool_recycle=3600, + pool_size=20, + max_overflow=0, + echo=settings.DEBUG +) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建基础模型类 +Base = declarative_base() + +# 获取数据库会话 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/fastapi-backend/app/models/user.py b/fastapi-backend/app/models/user.py new file mode 100644 index 0000000..607add5 --- /dev/null +++ b/fastapi-backend/app/models/user.py @@ -0,0 +1,44 @@ +from datetime import datetime +from sqlalchemy import Boolean, Column, String, Integer, DateTime, Enum +from sqlalchemy.sql import func + +from app.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + password_hash = Column(String(255), nullable=False) + user_type = Column(Enum('farmer', 'merchant', 'admin', 'super_admin'), default='farmer') + real_name = Column(String(50)) + nickname = Column(String(50)) + avatar_url = Column(String(255)) + email = Column(String(100), unique=True, index=True) + phone = Column(String(20), unique=True, index=True) + gender = Column(Enum('male', 'female', 'other'), default='other') + birthday = Column(DateTime) + status = Column(Enum('active', 'inactive'), default='active') + wechat_openid = Column(String(100), unique=True, index=True) + wechat_unionid = Column(String(100), unique=True, index=True) + level = Column(Integer, default=1) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + last_login = Column(DateTime) + + +class Admin(Base): + __tablename__ = "admins" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + password = Column(String(255), nullable=False) + email = Column(String(100), unique=True, index=True) + nickname = Column(String(50)) + avatar = Column(String(255)) + role = Column(Enum('admin', 'super_admin'), default='admin') + status = Column(Enum('active', 'inactive'), default='active') + last_login = Column(DateTime) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) \ No newline at end of file diff --git a/fastapi-backend/app/schemas/user.py b/fastapi-backend/app/schemas/user.py new file mode 100644 index 0000000..9fabbd9 --- /dev/null +++ b/fastapi-backend/app/schemas/user.py @@ -0,0 +1,167 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, validator + + +# 用户基础模式 +class UserBase(BaseModel): + username: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + user_type: Optional[str] = None + real_name: Optional[str] = None + nickname: Optional[str] = None + avatar_url: Optional[str] = None + gender: Optional[str] = None + birthday: Optional[datetime] = None + + +# 创建用户请求模式 +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=6) + email: Optional[EmailStr] = None + phone: Optional[str] = None + real_name: Optional[str] = None + nickname: Optional[str] = None + user_type: str = "farmer" + + +# 更新用户请求模式 +class UserUpdate(BaseModel): + nickname: Optional[str] = None + avatar_url: Optional[str] = None + gender: Optional[str] = None + birthday: Optional[datetime] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + + +# 用户登录请求模式 +class UserLogin(BaseModel): + username: str + password: str + + +# 密码更改请求模式 +class PasswordChange(BaseModel): + current_password: str + new_password: str = Field(..., min_length=6) + + +# 用户响应模式 +class UserResponse(UserBase): + id: int + status: str + created_at: datetime + updated_at: datetime + last_login: Optional[datetime] = None + + class Config: + orm_mode = True + + +# 带令牌的用户响应模式 +class UserWithToken(BaseModel): + user: UserResponse + token: str + refresh_token: Optional[str] = None + + +# 令牌响应模式 +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +# 令牌数据模式 +class TokenPayload(BaseModel): + sub: Optional[int] = None + exp: Optional[int] = None + type: Optional[str] = None + + +# 管理员基础模式 +class AdminBase(BaseModel): + username: str + email: Optional[EmailStr] = None + nickname: Optional[str] = None + avatar: Optional[str] = None + role: str = "admin" + + +# 创建管理员请求模式 +class AdminCreate(AdminBase): + password: str = Field(..., min_length=6) + + +# 更新管理员请求模式 +class AdminUpdate(BaseModel): + nickname: Optional[str] = None + avatar: Optional[str] = None + email: Optional[EmailStr] = None + role: Optional[str] = None + status: Optional[str] = None + + +# 管理员响应模式 +class AdminResponse(AdminBase): + id: int + status: str + created_at: datetime + updated_at: datetime + last_login: Optional[datetime] = None + + class Config: + orm_mode = True + + +# 带令牌的管理员响应模式 +class AdminWithToken(BaseModel): + admin: AdminResponse + token: str + refresh_token: Optional[str] = None + + +# 微信登录请求模式 +class WechatLogin(BaseModel): + code: str + user_info: Optional[dict] = None + + +# 分页响应模式 +class PaginationResponse(BaseModel): + page: int + page_size: int + total: int + total_pages: int + + +# 用户列表响应模式 +class UserListResponse(BaseModel): + users: List[UserResponse] + pagination: PaginationResponse + + +# 用户统计响应模式 +class UserStatistics(BaseModel): + total_users: int + farmers: int + merchants: int + admins: int + active_users: int + inactive_users: int + date: Optional[datetime] = None + + +# 批量更新用户状态请求模式 +class BatchUserStatusUpdate(BaseModel): + user_ids: List[int] + status: str + + @validator("status") + def validate_status(cls, v): + if v not in ["active", "inactive"]: + raise ValueError("状态必须是 'active' 或 'inactive'") + return v \ No newline at end of file diff --git a/fastapi-backend/app/utils/errors.py b/fastapi-backend/app/utils/errors.py new file mode 100644 index 0000000..599d063 --- /dev/null +++ b/fastapi-backend/app/utils/errors.py @@ -0,0 +1,59 @@ +from fastapi import HTTPException, status + + +class AppError(HTTPException): + """ + 应用程序自定义错误类 + """ + def __init__( + self, + detail: str, + status_code: int = status.HTTP_400_BAD_REQUEST, + headers: dict = None + ): + super().__init__(status_code=status_code, detail=detail, headers=headers) + + +# 常用错误 +class NotFoundError(AppError): + """ + 资源未找到错误 + """ + def __init__(self, detail: str = "资源未找到"): + super().__init__(detail=detail, status_code=status.HTTP_404_NOT_FOUND) + + +class UnauthorizedError(AppError): + """ + 未授权错误 + """ + def __init__(self, detail: str = "未授权"): + super().__init__( + detail=detail, + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Bearer"} + ) + + +class ForbiddenError(AppError): + """ + 禁止访问错误 + """ + def __init__(self, detail: str = "禁止访问"): + super().__init__(detail=detail, status_code=status.HTTP_403_FORBIDDEN) + + +class BadRequestError(AppError): + """ + 请求参数错误 + """ + def __init__(self, detail: str = "请求参数错误"): + super().__init__(detail=detail, status_code=status.HTTP_400_BAD_REQUEST) + + +class ConflictError(AppError): + """ + 资源冲突错误 + """ + def __init__(self, detail: str = "资源冲突"): + super().__init__(detail=detail, status_code=status.HTTP_409_CONFLICT) \ No newline at end of file diff --git a/fastapi-backend/app/utils/response.py b/fastapi-backend/app/utils/response.py new file mode 100644 index 0000000..9d33794 --- /dev/null +++ b/fastapi-backend/app/utils/response.py @@ -0,0 +1,43 @@ +from typing import Any, Dict, Optional + + +def success_response( + data: Any = None, + message: Optional[str] = None, + code: int = 200 +) -> Dict[str, Any]: + """ + 标准成功响应格式 + """ + response = { + "success": True, + "code": code + } + + if data is not None: + response["data"] = data + + if message: + response["message"] = message + + return response + + +def error_response( + message: str, + code: int = 400, + data: Any = None +) -> Dict[str, Any]: + """ + 标准错误响应格式 + """ + response = { + "success": False, + "code": code, + "message": message + } + + if data is not None: + response["data"] = data + + return response \ No newline at end of file diff --git a/fastapi-backend/main.py b/fastapi-backend/main.py new file mode 100644 index 0000000..cbf0984 --- /dev/null +++ b/fastapi-backend/main.py @@ -0,0 +1,15 @@ +import uvicorn +from app.core.config import settings +from app.api.api import app as application + +# 导出应用实例 +app = application + +if __name__ == "__main__": + uvicorn.run( + "app.api.api:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + log_level="info" + ) \ No newline at end of file diff --git a/fastapi-backend/migrations/env.py b/fastapi-backend/migrations/env.py new file mode 100644 index 0000000..064d073 --- /dev/null +++ b/fastapi-backend/migrations/env.py @@ -0,0 +1,74 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 导入模型 +from app.models.user import Base + +# 导入配置 +from app.core.config import settings + +# 这是Alembic Config对象,它提供对.ini文件中值的访问 +config = context.config + +# 解释配置文件并设置日志记录器 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 添加你的模型元数据对象 +target_metadata = Base.metadata + +# 其他值来自config,可以通过以下方式定义: +# my_important_option = config.get_main_option("my_important_option") +# ... 等等。 + + +def run_migrations_offline() -> None: + """在'offline'模式下运行迁移。 + + 这配置了上下文,只需要一个URL,并且不要求引擎可用。 + 跳过引擎创建,甚至不需要DBAPI可用。 + + 调用context.execute()来执行迁移。 + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """在'online'模式下运行迁移。 + + 在这种情况下,我们创建了一个Engine并将其与迁移上下文关联。 + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/fastapi-backend/requirements.txt b/fastapi-backend/requirements.txt new file mode 100644 index 0000000..86c38d8 --- /dev/null +++ b/fastapi-backend/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.110.0 +uvicorn==0.27.1 +pydantic==2.6.1 +pydantic-settings==2.1.0 +sqlalchemy==2.0.27 +pymysql==1.1.0 +cryptography==42.0.2 +python-jose==3.3.0 +passlib==1.7.4 +python-multipart==0.0.9 +email-validator==2.1.0 +python-dotenv==1.0.1 +alembic==1.13.1 +pytest==7.4.3 +httpx==0.26.0 +bcrypt==4.1.2 \ No newline at end of file