由于本次代码变更内容为空,无法生成有效的提交信息。请提供具体的代码变更内容以便生成合适的提交信息。登录、微信登录等认证功能- 添加管理员登录功能

- 实现个人资料更新和密码修改- 配置数据库连接和 Alembic 迁移
- 添加健康检查和系统统计接口
- 实现自定义错误处理和响应格式
- 配置 FastAPI 应用和中间件
This commit is contained in:
ylweng
2025-09-12 00:57:52 +08:00
parent 67aef9a9ee
commit 4db35e91d4
18 changed files with 1763 additions and 0 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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

View File

@@ -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="微信登录成功"
)

View File

@@ -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
}
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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())

View File

@@ -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

View File

@@ -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)

View File

@@ -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

15
fastapi-backend/main.py Normal file
View File

@@ -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"
)

View File

@@ -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()

View File

@@ -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