diff --git a/app/api/v1/oem_users.py b/app/api/v1/oem_users.py new file mode 100644 index 0000000..4c4ae7d --- /dev/null +++ b/app/api/v1/oem_users.py @@ -0,0 +1,103 @@ +"""管理员用户 CRUD。""" + +from fastapi import APIRouter, Query + +from app.dependencies import OemUser, DbSession +from app.schemas.admin_user import AdminUserCreate, AdminUserOut, AdminUserUpdate +from app.schemas.common import ApiResponse, PaginatedData +from app.services import admin_user as oem_user_service + +router = APIRouter(prefix="/oem/users", tags=["管理-用户"]) + + +@router.get("", response_model=ApiResponse[PaginatedData[AdminUserOut]]) +async def list_users( + _oem: OemUser, + db: DbSession, + page: int = Query(1, ge=1, description="页码,从 1 开始"), + page_size: int = Query( + oem_user_service.DEFAULT_PAGE_SIZE, + ge=1, + le=oem_user_service.MAX_PAGE_SIZE, + description="每页条数", + ), + username: str | None = Query(None, description="用户名模糊搜索"), + agent_id: int | None = Query(None, description="按所属代理用户 ID 筛选"), + role_id: int | None = Query(None, ge=0, le=3, description="按角色筛选"), +) -> ApiResponse[PaginatedData[AdminUserOut]]: + oem_id=_oem.id + users, total = await oem_user_service.list_users( + db, + page=page, + page_size=page_size, + username=username, + oem_id=oem_id, + agent_id=agent_id, + role_id=role_id, + ) + return ApiResponse( + ok=True, + message="", + data=PaginatedData.build( + [AdminUserOut.model_validate(u) for u in users], + total=total, + page=page, + page_size=page_size, + ), + ) + + +@router.post("", response_model=ApiResponse[AdminUserOut]) +async def create_user( + body: AdminUserCreate, + oem: OemUser, + db: DbSession, +) -> ApiResponse[AdminUserOut]: + try: + user = await oem_user_service.create_user(db, body,oem) + except oem_user_service.AdminUserError as exc: + return ApiResponse(ok=False, message=exc.message) + + return ApiResponse( + ok=True, + message="用户已创建", + data=AdminUserOut.model_validate(user), + ) + + +@router.patch("/{user_id}", response_model=ApiResponse[AdminUserOut]) +async def update_user( + user_id: int, + body: AdminUserUpdate, + oem: OemUser, + db: DbSession, +) -> ApiResponse[AdminUserOut]: + try: + user = await oem_user_service.update_user( + db, + user_id, + body, + actor_id=oem.id, + ) + except oem_user_service.AdminUserError as exc: + return ApiResponse(ok=False, message=exc.message) + + return ApiResponse( + ok=True, + message="用户已更新", + data=AdminUserOut.model_validate(user), + ) + + +@router.delete("/{user_id}", response_model=ApiResponse[None]) +async def delete_user( + user_id: int, + oem: OemUser, + db: DbSession, +) -> ApiResponse[None]: + try: + await oem_user_service.delete_user(db, user_id, actor_id=admin.id) + except oem_user_service.AdminUserError as exc: + return ApiResponse(ok=False, message=exc.message) + + return ApiResponse(ok=True, message="用户已删除") diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 94bf6fb..a6790c6 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -7,12 +7,14 @@ from app.api.v1 import ( admin_users, app_config, oem_public, + oem_users, auth, nodejs_scripts, quickjs_scripts, ) api_router = APIRouter() +api_router.include_router(oem_users.router) api_router.include_router(oem_public.router) api_router.include_router(auth.router) api_router.include_router(admin_users.router) diff --git a/app/dependencies.py b/app/dependencies.py index b23e353..64c2c68 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -4,7 +4,7 @@ import redis.asyncio as redis from fastapi import Depends, Header, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.roles import ROLE_ADMIN +from app.core.roles import ROLE_ADMIN, ROLE_OEM,ROLE_AGENT from app.core.security import decode_access_token from app.database import get_db from app.models.user import User @@ -63,5 +63,23 @@ async def require_admin(current_user: CurrentUser) -> User: ) return current_user +async def require_oem(current_user: CurrentUser) -> User: + if current_user.role_id != ROLE_OEM: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要 OEM 权限", + ) + return current_user +async def require_agent(current_user: CurrentUser) -> User: + if current_user.role_id != ROLE_AGENT: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要代理权限", + ) + return current_user + + AdminUser = Annotated[User, Depends(require_admin)] +OemUser = Annotated[User, Depends(require_oem)] +AgentUser = Annotated[User, Depends(require_agent)] diff --git a/app/schemas/auth.py b/app/schemas/auth.py index dd1af82..daae87a 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -38,6 +40,7 @@ class UserPublic(BaseModel): id: int username: str role_id: int + vip_end_time: datetime | None = None class TokenResponse(BaseModel): diff --git a/app/schemas/oem_public.py b/app/schemas/oem_public.py index 56fbe3a..dc35906 100644 --- a/app/schemas/oem_public.py +++ b/app/schemas/oem_public.py @@ -10,3 +10,4 @@ class OemPublicBrandingOut(BaseModel): software_name: str = "" logo_url: str | None = None wechat: str | None = None + wechat_qrcode_url: str | None = None diff --git a/app/services/admin_user.py b/app/services/admin_user.py index 09a67eb..55259c0 100644 --- a/app/services/admin_user.py +++ b/app/services/admin_user.py @@ -82,7 +82,7 @@ async def _validate_owner_ids( raise AdminUserError("所选代理不存在") -async def create_user(db: AsyncSession, body: AdminUserCreate) -> User: +async def create_user(db: AsyncSession, body: AdminUserCreate,oem: User) -> User: existing = await db.scalar(select(User).where(User.username == body.username)) if existing is not None: raise AdminUserError("用户名已存在") @@ -92,14 +92,14 @@ async def create_user(db: AsyncSession, body: AdminUserCreate) -> User: if phone_taken is not None: raise AdminUserError("手机号已被使用") - await _validate_owner_ids(db, oem_id=body.oem_id, agent_id=body.agent_id) + await _validate_owner_ids(db, oem_id=oem.id, agent_id=body.agent_id) user = User( username=body.username, password_hash=hash_password(body.password), phone=body.phone, role_id=body.role_id, - oem_id=body.oem_id, + oem_id=oem.id, agent_id=body.agent_id, vip_end_time=body.vip_end_time, ) @@ -111,6 +111,7 @@ async def create_user(db: AsyncSession, body: AdminUserCreate) -> User: async def update_user( db: AsyncSession, + oem: User, user_id: int, body: AdminUserUpdate, *, @@ -119,7 +120,7 @@ async def update_user( user = await db.get(User, user_id) if user is None: raise AdminUserError("用户不存在") - + body.oem_id=oem.id if body.username is not None and body.username != user.username: taken = await db.scalar(select(User).where(User.username == body.username)) if taken is not None: @@ -162,12 +163,13 @@ async def update_user( return user -async def delete_user(db: AsyncSession, user_id: int, *, actor_id: int) -> None: +async def delete_user(db: AsyncSession, oem: User, user_id: int, *, actor_id: int) -> None: if user_id == actor_id: raise AdminUserError("不能删除当前登录账号") user = await db.get(User, user_id) if user is None: raise AdminUserError("用户不存在") - + if user.oem_id != oem.id: + raise AdminUserError("无权限删除该用户") await db.delete(user) diff --git a/app/services/oem_public.py b/app/services/oem_public.py index 835d098..64fda5b 100644 --- a/app/services/oem_public.py +++ b/app/services/oem_public.py @@ -5,10 +5,10 @@ from app.models.oem import Oem from app.schemas.oem_public import OemPublicBrandingOut -def _logo_url(logo_path: str | None, base_url: str) -> str | None: - if not logo_path or not logo_path.strip(): +def _image_url(image_path: str | None, base_url: str) -> str | None: + if not image_path or not image_path.strip(): return None - path = logo_path.strip().replace("\\", "/").lstrip("/") + path = image_path.strip().replace("\\", "/").lstrip("/") if path.startswith("http://") or path.startswith("https://"): return path if not path.startswith("images/"): @@ -21,11 +21,18 @@ async def get_oem_public_branding(db: AsyncSession, oem_id: int) -> OemPublicBra base_url = settings.api_public_base.rstrip("/") oem = await db.get(Oem, oem_id) if oem is None: - return OemPublicBrandingOut(oem_id=oem_id, software_name="", logo_url=None, wechat=None) + return OemPublicBrandingOut( + oem_id=oem_id, + software_name="", + logo_url=None, + wechat=None, + wechat_qrcode_url=None, + ) return OemPublicBrandingOut( oem_id=oem.id, software_name=oem.software_name or "", - logo_url=_logo_url(oem.logo_path, base_url), + logo_url=_image_url(oem.logo_path, base_url), wechat=oem.wechat, + wechat_qrcode_url=_image_url(oem.wechat_qrcode, base_url), ) diff --git a/app/services/oem_user.py b/app/services/oem_user.py new file mode 100644 index 0000000..09a67eb --- /dev/null +++ b/app/services/oem_user.py @@ -0,0 +1,173 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import ROLE_ADMIN, ROLE_AGENT, ROLE_OEM +from app.core.security import hash_password +from app.models.user import User +from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate + +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 200 + + +class AdminUserError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +def _list_users_filters( + *, + username: str | None = None, + oem_id: int | None = None, + agent_id: int | None = None, + role_id: int | None = None, +) -> list: + conditions = [] + if username and username.strip(): + conditions.append(User.username.ilike(f"%{username.strip()}%")) + if oem_id is not None: + conditions.append(User.oem_id == oem_id) + if agent_id is not None: + conditions.append(User.agent_id == agent_id) + if role_id is not None: + conditions.append(User.role_id == role_id) + return conditions + + +async def list_users( + db: AsyncSession, + *, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + username: str | None = None, + oem_id: int | None = None, + agent_id: int | None = None, + role_id: int | None = None, +) -> tuple[list[User], int]: + page_size = min(max(page_size, 1), MAX_PAGE_SIZE) + page = max(page, 1) + offset = (page - 1) * page_size + conditions = _list_users_filters( + username=username, + oem_id=oem_id, + agent_id=agent_id, + role_id=role_id, + ) + + count_stmt = select(func.count()).select_from(User) + list_stmt = select(User).order_by(User.id.desc()) + if conditions: + count_stmt = count_stmt.where(*conditions) + list_stmt = list_stmt.where(*conditions) + + total = await db.scalar(count_stmt) or 0 + result = await db.scalars(list_stmt.offset(offset).limit(page_size)) + return list(result.all()), total + + +async def _validate_owner_ids( + db: AsyncSession, + *, + oem_id: int | None, + agent_id: int | None, +) -> None: + if oem_id is not None: + oem = await db.get(User, oem_id) + if oem is None or oem.role_id != ROLE_OEM: + raise AdminUserError("所选 OEM 不存在") + if agent_id is not None: + agent = await db.get(User, agent_id) + if agent is None or agent.role_id != ROLE_AGENT: + raise AdminUserError("所选代理不存在") + + +async def create_user(db: AsyncSession, body: AdminUserCreate) -> User: + existing = await db.scalar(select(User).where(User.username == body.username)) + if existing is not None: + raise AdminUserError("用户名已存在") + + if body.phone: + phone_taken = await db.scalar(select(User).where(User.phone == body.phone)) + if phone_taken is not None: + raise AdminUserError("手机号已被使用") + + await _validate_owner_ids(db, oem_id=body.oem_id, agent_id=body.agent_id) + + user = User( + username=body.username, + password_hash=hash_password(body.password), + phone=body.phone, + role_id=body.role_id, + oem_id=body.oem_id, + agent_id=body.agent_id, + vip_end_time=body.vip_end_time, + ) + db.add(user) + await db.flush() + await db.refresh(user) + return user + + +async def update_user( + db: AsyncSession, + user_id: int, + body: AdminUserUpdate, + *, + actor_id: int, +) -> User: + user = await db.get(User, user_id) + if user is None: + raise AdminUserError("用户不存在") + + if body.username is not None and body.username != user.username: + taken = await db.scalar(select(User).where(User.username == body.username)) + if taken is not None: + raise AdminUserError("用户名已存在") + user.username = body.username + + if body.phone is not None: + if body.phone != user.phone: + taken = await db.scalar(select(User).where(User.phone == body.phone)) + if taken is not None: + raise AdminUserError("手机号已被使用") + user.phone = body.phone + + if body.role_id is not None: + if user_id == actor_id and body.role_id != ROLE_ADMIN: + raise AdminUserError("不能修改自己的管理员角色") + user.role_id = body.role_id + + if body.oem_id is not None or body.agent_id is not None: + await _validate_owner_ids( + db, + oem_id=body.oem_id if body.oem_id is not None else user.oem_id, + agent_id=body.agent_id if body.agent_id is not None else user.agent_id, + ) + if body.oem_id is not None: + user.oem_id = body.oem_id + if body.agent_id is not None: + user.agent_id = body.agent_id + + if body.password: + user.password_hash = hash_password(body.password) + + if body.clear_vip_end_time: + user.vip_end_time = None + elif body.vip_end_time is not None: + user.vip_end_time = body.vip_end_time + + await db.flush() + await db.refresh(user) + return user + + +async def delete_user(db: AsyncSession, user_id: int, *, actor_id: int) -> None: + if user_id == actor_id: + raise AdminUserError("不能删除当前登录账号") + + user = await db.get(User, user_id) + if user is None: + raise AdminUserError("用户不存在") + + await db.delete(user)