新增用户注册功能

This commit is contained in:
Cheng Zhou
2025-12-22 21:19:48 +08:00
parent 03fddba406
commit 6ddf9901a0
33 changed files with 1389 additions and 138 deletions
+62
View File
@@ -0,0 +1,62 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.crud import user as user_crud
from app.models.user import User, UserRole, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users")
@router.get("/", response_model=AdminUserListResponse)
async def list_users_for_review(
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users))
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.role == UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
) -> UserResponse:
if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
return user
@router.post("/{user_id}/reject", response_model=UserResponse)
async def reject_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
) -> UserResponse:
if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
return user
+92 -9
View File
@@ -1,40 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.security import create_access_token, verify_password
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
from app.schemas.user import Token, UserRead
from app.models.user import UserRole, UserStatus
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from fastapi.responses import FileResponse
class LoginRequest(BaseModel):
username: str = Field(min_length=1)
email: EmailStr
password: str = Field(min_length=1)
router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
db: AsyncSession = Depends(get_db_session),
):
if payload.role == UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号")
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload)
return {"message": "注册成功,等待管理员审核"}
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await user_crud.get_by_username(db, payload.username)
if not db_user or not verify_password(payload.password, db_user.hashed_password):
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
detail="账号不存在",
)
if not db_user.is_active:
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
detail="密码错误",
)
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号未审核或不可用",
)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role,
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
)
return Token(access_token=access_token, token_type="bearer")
@@ -43,3 +69,60 @@ async def login_for_access_token(
@router.get("/me", response_model=UserRead)
async def read_me(current_user=Depends(get_current_user)) -> UserRead:
return current_user
@router.patch("/me", response_model=UserRead)
async def update_me(
payload: UserSelfUpdate,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> UserRead:
if payload.password:
if not payload.current_password or not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
update_data = {
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
"department": payload.department if payload.department is not None else current_user.department,
"password": payload.password if payload.password else None,
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
}
updated = await user_crud.update_user(db, current_user, UserUpdate(**update_data))
return updated
@router.post("/me/avatar", response_model=UserRead)
async def upload_avatar(
file: UploadFile = File(...),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> UserRead:
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
user_dir = AVATAR_ROOT / str(current_user.id)
user_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix or ".png"
filename = f"{uuid.uuid4()}{ext}"
dest = user_dir / filename
content = await file.read()
dest.write_bytes(content)
# Optionally clean old files
for old in user_dir.iterdir():
if old.name != filename:
try:
old.unlink()
except OSError:
pass
rel_url = f"/api/v1/auth/avatar/{current_user.id}/{filename}"
updated = await user_crud.update_user(
db,
current_user,
UserUpdate(avatar_url=rel_url),
)
return updated
@router.get("/avatar/{user_id}/{filename}")
async def get_avatar(user_id: str, filename: str):
file_path = AVATAR_ROOT / user_id / filename
if not file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
return FileResponse(file_path)
+1 -1
View File
@@ -48,7 +48,7 @@ async def create_comment(
entity_type=entity_type,
entity_id=entity_id,
action="CREATE_COMMENT",
detail=f"Comment created by {current_user.username}",
detail=f"Comment created by {current_user.full_name}",
operator_id=current_user.id,
operator_role=current_user.role,
)
+2 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
from app.api.v1 import auth, users, admin_users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
+3 -3
View File
@@ -32,11 +32,11 @@ async def create_user(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserRead:
existing = await user_crud.get_by_username(db, user_in.username)
existing = await user_crud.get_by_email(db, user_in.email)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists",
detail="Email already exists",
)
user = await user_crud.create_user(db, user_in)
return user
@@ -64,7 +64,7 @@ async def delete_user(
current_user=Depends(require_roles(["ADMIN"])),
):
admin_password = payload.get("admin_password")
if not admin_password or not verify_password(admin_password, current_user.hashed_password):
if not admin_password or not verify_password(admin_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
db_user = await user_crud.get_by_id(db, user_id)
if not db_user:
+8 -4
View File
@@ -46,7 +46,8 @@ def require_roles(roles: Iterable[str]) -> Callable:
roles_set = set(roles)
async def dependency(current_user=Depends(get_current_user)):
if current_user.role not in roles_set:
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
if current_role not in roles_set:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
@@ -61,7 +62,8 @@ async def get_study_member(
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
return await member_crud.get_member(db, study_id, current_user.id)
@@ -72,7 +74,8 @@ def require_study_member():
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
@@ -94,7 +97,8 @@ def require_study_roles(roles: Iterable[str]):
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
+70 -23
View File
@@ -1,17 +1,17 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update, delete
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.models.study_member import StudyMember
from app.models.user import User, UserRole, UserStatus
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
async def get_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
@@ -20,12 +20,17 @@ async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
return result.scalar_one_or_none()
async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
async def create_user(
db: AsyncSession, user_in: UserCreate, *, status: UserStatus | None = UserStatus.ACTIVE
) -> User:
status_value = status or UserStatus(user_in.status)
user = User(
username=user_in.username,
hashed_password=hash_password(user_in.password),
role=user_in.role,
is_active=True,
email=user_in.email,
password_hash=hash_password(user_in.password),
full_name=user_in.full_name,
role=UserRole(user_in.role),
department=user_in.department,
status=status_value,
)
db.add(user)
await db.commit()
@@ -33,21 +38,29 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
return user
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING)
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
update_data = {}
if user_in.role is not None:
update_data["role"] = user_in.role
update_data["role"] = UserRole(user_in.role)
if user_in.status is not None:
update_data["status"] = UserStatus(user_in.status)
if user_in.is_active is not None:
update_data["is_active"] = user_in.is_active
update_data["status"] = UserStatus.ACTIVE if user_in.is_active else UserStatus.DISABLED
if user_in.avatar_url is not None:
update_data["avatar_url"] = user_in.avatar_url
if user_in.password:
update_data["hashed_password"] = hash_password(user_in.password)
update_data["password_hash"] = hash_password(user_in.password)
if user_in.full_name is not None:
update_data["full_name"] = user_in.full_name
if user_in.department is not None:
update_data["department"] = user_in.department
if update_data:
await db.execute(
update(User)
.where(User.id == user.id)
.values(**update_data)
)
await db.execute(update(User).where(User.id == user.id).values(**update_data))
await db.commit()
await db.refresh(user)
return user
@@ -58,16 +71,28 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
return result.scalars().all()
async def list_users_by_status(
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
) -> Sequence[User]:
stmt = select(User).offset(skip).limit(limit)
if status:
stmt = stmt.where(User.status == status)
result = await db.execute(stmt.order_by(User.created_at.desc()))
return result.scalars().all()
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None:
result = await db.execute(select(User).where(User.username == "admin"))
result = await db.execute(select(User).where(User.role == UserRole.ADMIN))
admin = result.scalar_one_or_none()
if admin:
return
new_admin = User(
username="admin",
hashed_password=hash_password(default_password),
role="ADMIN",
is_active=True,
email="admin@ctms.local",
password_hash=hash_password(default_password),
full_name="System Admin",
role=UserRole.ADMIN,
department="SYSTEM",
status=UserStatus.ACTIVE,
)
db.add(new_admin)
await db.commit()
@@ -85,3 +110,25 @@ async def delete_user(db: AsyncSession, user: User) -> None:
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user)
await db.commit()
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
await db.execute(
update(User)
.where(User.id == user.id)
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
)
await db.commit()
await db.refresh(user)
return user
+54 -6
View File
@@ -1,19 +1,67 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class UserRole(str, enum.Enum):
ADMIN = "ADMIN"
PM = "PM"
CRA = "CRA"
PV = "PV"
IMP = "IMP"
class UserStatus(str, enum.Enum):
PENDING = "PENDING"
ACTIVE = "ACTIVE"
REJECTED = "REJECTED"
DISABLED = "DISABLED"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
department: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[UserStatus] = mapped_column(
Enum(UserStatus, name="user_status"),
nullable=False,
server_default=UserStatus.PENDING.value,
default=UserStatus.PENDING,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
@property
def is_active(self) -> bool: # compatibility helper for existing checks
return self.status == UserStatus.ACTIVE
@property
def username(self) -> str: # backward compatibility for existing UI copy
return self.email
+71 -21
View File
@@ -1,54 +1,104 @@
import re
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
RegisterRole = Literal["PM", "CRA", "PV", "IMP"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
class UserDisplay(BaseModel):
id: uuid.UUID
username: str
display_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
username: str = Field(min_length=1)
id: uuid.UUID
email: EmailStr
full_name: str
role: UserRole
is_active: bool = True
avatar_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserCreate(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class _PasswordValidator(BaseModel):
password: Optional[str] = Field(default=None, min_length=8)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
if not PASSWORD_REGEX.match(v):
raise ValueError("密码需至少 8 位且包含字母和数字")
return v
class UserRegisterRequest(_PasswordValidator):
password: str = Field(min_length=8)
email: EmailStr
full_name: str = Field(min_length=1)
role: RegisterRole
department: str = Field(min_length=1)
class UserCreate(_PasswordValidator):
password: str = Field(min_length=8)
email: EmailStr
full_name: str = Field(min_length=1)
role: UserRole
department: str = Field(min_length=1)
status: UserStatus = "ACTIVE"
class UserRead(BaseModel):
id: uuid.UUID
email: EmailStr
username: str
full_name: str
role: UserRole
department: str
status: UserStatus
is_active: bool
created_at: datetime
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
avatar_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserUpdate(BaseModel):
class UserUpdate(_PasswordValidator):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
role: Optional[UserRole] = None
department: Optional[str] = None
status: Optional[UserStatus] = None
password: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = Field(default=None, min_length=1)
avatar_url: Optional[str] = None
class UserInDB(UserBase):
id: uuid.UUID
hashed_password: str
created_at: datetime
class UserResponse(UserRead):
pass
model_config = ConfigDict(from_attributes=True)
class UserAdminReviewRequest(BaseModel):
action: Literal["approve", "reject"]
class AdminUserListResponse(BaseModel):
items: list[UserResponse]
total: int
class UserSelfUpdate(_PasswordValidator):
full_name: Optional[str] = None
department: Optional[str] = None
password: Optional[str] = None
current_password: Optional[str] = None
avatar_url: Optional[str] = None
class Token(BaseModel):