From 0776e91735620343d0777dfd625a4f3ef61e29ad Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 26 Jul 2026 02:20:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=94=A8=E6=88=B7=E6=8E=A5=E5=8F=A3=20POST?= =?UTF-8?q?=20/admin/users=EF=BC=8C=E5=88=9B=E5=BB=BA=E5=90=8E=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E8=87=AA=E5=8A=A8=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/admin.py | 29 ++++++++++++++++++ app/schemas.py | 15 +++++++++ tests/test_admin.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/app/routers/admin.py b/app/routers/admin.py index 8fdef77..f130793 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query, status from sqlalchemy import case, func, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload, selectinload from app.deps import AdminUser, DbSession @@ -13,6 +14,7 @@ from app.models import Cloud, Profile, User from app.schemas import ( AdminCloudStatusIn, AdminCloudVisibilityIn, + AdminCreateUserIn, AdminStatsOut, AdminUserOut, AdminUserUpdateIn, @@ -22,6 +24,7 @@ from app.schemas import ( DeleteResultOut, PageOut, ) +from app.security import hash_password, utc_now from app.serializers import cloud_out, profile_out, user_out from app.services.storage import delete_files @@ -83,6 +86,32 @@ async def list_users( ) +@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED) +async def create_user( + payload: AdminCreateUserIn, + _: AdminUser, + db: DbSession, +) -> AdminUserOut: + email = str(payload.email).strip().lower() + user = User( + email=email, + password_hash=hash_password(payload.password), + email_verified_at=utc_now(), + ) + user.profile = Profile(username=payload.username, role=payload.role) + db.add(user) + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + message = str(exc.orig).lower() + if "email" in message: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc + await db.refresh(user, ["profile"]) + return AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) + + @router.patch("/users/{user_id}", response_model=AdminUserOut) async def update_user( user_id: uuid.UUID, diff --git a/app/schemas.py b/app/schemas.py index 6046a1b..facd7cd 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -242,6 +242,21 @@ class AdminUserUpdateIn(BaseModel): return self +class AdminCreateUserIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + username: str = Field(min_length=2, max_length=32) + role: Role = "user" + + @field_validator("username") + @classmethod + def strip_username(cls, value: str) -> str: + value = value.strip() + if len(value) < 2: + raise ValueError("昵称至少需要 2 个字符") + return value + + class AdminCloudStatusIn(BatchIdsIn): status: CloudStatus diff --git a/tests/test_admin.py b/tests/test_admin.py index fa74ded..1d13efa 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -52,6 +52,78 @@ def test_list_users(client: TestClient) -> None: assert r.json()["total"] >= 1 +# --------------------------------------------------------------------------- +# create user +# --------------------------------------------------------------------------- + + +def test_create_user(client: TestClient) -> None: + adm = admin_token(client) + r = client.post( + "/api/v1/admin/users", + headers=bearer(adm), + json={ + "email": f"admin-created-{uuid.uuid4().hex[:8]}@example.com", + "password": "createdpass12", + "username": "管理员创建的用户", + }, + ) + assert r.status_code == 201 + body = r.json() + assert body["user"]["email_verified"] is True + assert body["profile"]["role"] == "user" + + +def test_create_user_with_role(client: TestClient) -> None: + adm = admin_token(client) + r = client.post( + "/api/v1/admin/users", + headers=bearer(adm), + json={ + "email": f"newadmin-{uuid.uuid4().hex[:8]}@example.com", + "password": "newadmin12", + "username": "新建管理员", + "role": "admin", + }, + ) + assert r.status_code == 201 + assert r.json()["profile"]["role"] == "admin" + + +def test_create_user_duplicate_email(client: TestClient) -> None: + adm = admin_token(client) + email = f"dup-admin-{uuid.uuid4().hex[:8]}@example.com" + client.post( + "/api/v1/admin/users", + headers=bearer(adm), + json={"email": email, "password": "dup1pass12", "username": "第一人"}, + ) + r = client.post( + "/api/v1/admin/users", + headers=bearer(adm), + json={"email": email, "password": "dup2pass12", "username": "第二人"}, + ) + assert r.status_code == 409 + + +def test_create_user_non_admin(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"na-create-{uuid.uuid4().hex[:8]}@example.com", + "nonadmin1", "非管理员创建", + ) + r = client.post( + "/api/v1/admin/users", + headers=bearer(info["token"]), + json={ + "email": f"should-fail-{uuid.uuid4().hex[:8]}@example.com", + "password": "failpass12", + "username": "失败的用户", + }, + ) + assert r.status_code == 403 + + # --------------------------------------------------------------------------- # update user # ---------------------------------------------------------------------------