Files
crm_project/server/app/api/sys_settings.py
T
hankin 423baff73b v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
2026-03-16 07:31:37 +00:00

432 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
系统设置与权限域路由 —— /api/settings
核心亮点:
1. 部门树递归组装
2. 角色 CRUD + JSONB menu_keys
3. 员工管理 + bcrypt 密码哈希
4. 全接口强制管理员权限校验
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.core.security import hash_password
from app.db.database import get_db
from app.models.sys import SysDepartment, SysRole, SysUser
from app.schemas.auth import CurrentUserPayload
from app.schemas.sys import (
DeptNode,
RoleCreate,
RoleResponse,
RoleUpdate,
UserCreate,
UserListResponse,
UserResetPassword,
UserResponse,
UserUpdate,
)
from app.schemas.response import ok
router = APIRouter(prefix="/settings", tags=["系统设置"])
# ── 管理员权限守卫 ────────────────────────────────────────
def _require_admin(user: CurrentUserPayload) -> None:
"""全接口强制校验:必须 data_scope == 'all'"""
if user.data_scope != "all":
raise ForbiddenException("系统设置仅限管理员操作")
# ── 工具:部门树递归组装 ─────────────────────────────────
def _build_dept_tree(
items: list[SysDepartment], parent_id: uuid.UUID | None = None
) -> list[DeptNode]:
nodes: list[DeptNode] = []
for item in items:
if item.parent_id == parent_id:
children = _build_dept_tree(items, item.id)
nodes.append(
DeptNode(
id=item.id,
parent_id=item.parent_id,
name=item.name,
sort_order=item.sort_order,
status=item.status,
children=children,
)
)
nodes.sort(key=lambda n: n.sort_order)
return nodes
# ── 工具:收集部门及所有子部门 ID(递归) ────────────────
def _collect_dept_ids(
items: list[SysDepartment], root_id: uuid.UUID
) -> list[uuid.UUID]:
"""从扁平列表中递归收集某部门及其所有后代 ID"""
result = [root_id]
for item in items:
if item.parent_id == root_id:
result.extend(_collect_dept_ids(items, item.id))
return result
# ── 工具:User ORM → Response ────────────────────────────
def _user_to_resp(u: SysUser) -> UserResponse:
return UserResponse(
id=u.id,
username=u.username,
real_name=u.real_name,
phone=u.phone,
email=u.email,
dept_id=u.dept_id,
dept_name=u.department.name if u.department else None,
role_id=u.role_id,
role_name=u.role.role_name if u.role else None,
data_scope=u.role.data_scope if u.role else None,
status=u.status,
last_login_at=u.last_login_at,
created_at=u.created_at,
)
# ================================================================
# 1. GET /api/settings/departments/tree —— 部门树
# ================================================================
@router.get("/departments/tree", summary="获取组织架构树")
async def get_dept_tree(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
stmt = (
select(SysDepartment)
.where(SysDepartment.is_deleted.is_(False))
.order_by(SysDepartment.sort_order)
)
depts = list((await db.execute(stmt)).scalars().all())
tree = _build_dept_tree(depts, parent_id=None)
return ok(data=[n.model_dump(mode="json") for n in tree])
# ================================================================
# 2. GET /api/settings/roles —— 角色列表
# ================================================================
@router.get("/roles", summary="角色列表")
async def list_roles(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
stmt = (
select(SysRole)
.where(SysRole.is_deleted.is_(False))
.order_by(SysRole.created_at)
)
roles = (await db.execute(stmt)).scalars().all()
return ok(
data=[
RoleResponse(
id=r.id,
role_name=r.role_name,
data_scope=r.data_scope,
menu_keys=r.menu_keys or [],
description=r.description,
status=r.status,
created_at=r.created_at,
).model_dump(mode="json")
for r in roles
]
)
# ================================================================
# 3. POST /api/settings/roles —— 新增角色
# ================================================================
@router.post("/roles", summary="新增角色")
async def create_role(
body: RoleCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
# 校验名称唯一
exists = (
await db.execute(
select(SysRole.id).where(
SysRole.role_name == body.role_name,
SysRole.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"角色名称 '{body.role_name}' 已存在")
role = SysRole(
role_name=body.role_name,
data_scope=body.data_scope,
menu_keys=body.menu_keys,
description=body.description,
status=body.status,
)
db.add(role)
await db.commit()
await db.refresh(role)
return ok(
data=RoleResponse(
id=role.id,
role_name=role.role_name,
data_scope=role.data_scope,
menu_keys=role.menu_keys or [],
description=role.description,
status=role.status,
created_at=role.created_at,
).model_dump(mode="json"),
message="角色创建成功",
)
# ================================================================
# 4. PUT /api/settings/roles/{id} —— 修改角色
# ================================================================
@router.put("/roles/{role_id}", summary="修改角色(含 JSONB menu_keys")
async def update_role(
role_id: uuid.UUID,
body: RoleUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
role = (
await db.execute(
select(SysRole).where(
SysRole.id == role_id, SysRole.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if role is None:
raise NotFoundException("角色不存在")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
# 名称唯一性校验(如果改了名)
if "role_name" in update_data and update_data["role_name"] != role.role_name:
dup = (
await db.execute(
select(SysRole.id).where(
SysRole.role_name == update_data["role_name"],
SysRole.id != role_id,
SysRole.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if dup:
raise BizException(message=f"角色名称 '{update_data['role_name']}' 已存在")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(SysRole).where(SysRole.id == role_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(SysRole).where(SysRole.id == role_id))
).scalar_one()
return ok(
data=RoleResponse(
id=refreshed.id,
role_name=refreshed.role_name,
data_scope=refreshed.data_scope,
menu_keys=refreshed.menu_keys or [],
description=refreshed.description,
status=refreshed.status,
created_at=refreshed.created_at,
).model_dump(mode="json"),
message="角色信息已更新",
)
# ================================================================
# 5. GET /api/settings/users —— 员工分页列表
# ================================================================
@router.get("/users", summary="员工分页列表(支持部门树递归过滤)")
async def list_users(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
dept_id: uuid.UUID | None = Query(None, description="部门 ID(含所有子部门)"),
keyword: str | None = Query(None, description="姓名/手机号模糊搜索"),
status: int | None = Query(None, ge=0, le=1),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
where = [SysUser.is_deleted.is_(False)]
# 按部门过滤(递归收集子部门 ID
if dept_id:
all_depts = list(
(
await db.execute(
select(SysDepartment).where(SysDepartment.is_deleted.is_(False))
)
).scalars().all()
)
target_ids = _collect_dept_ids(all_depts, dept_id)
where.append(SysUser.dept_id.in_(target_ids))
if keyword:
where.append(
SysUser.real_name.ilike(f"%{keyword}%")
| SysUser.phone.ilike(f"%{keyword}%")
)
if status is not None:
where.append(SysUser.status == status)
total = (
await db.execute(select(func.count()).select_from(SysUser).where(*where))
).scalar() or 0
stmt = (
select(SysUser)
.where(*where)
.order_by(SysUser.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
users = (await db.execute(stmt)).scalars().all()
return ok(
data=UserListResponse(
total=total,
items=[_user_to_resp(u) for u in users],
page=page,
size=size,
).model_dump(mode="json")
)
# ================================================================
# 6. POST /api/settings/users —— 开通账号
# ================================================================
@router.post("/users", summary="开通员工账号(bcrypt 密码哈希)")
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
# 用户名唯一
exists = (
await db.execute(
select(SysUser.id).where(
SysUser.username == body.username,
SysUser.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"用户名 '{body.username}' 已被占用")
user = SysUser(
username=body.username,
password_hash=hash_password(body.password),
real_name=body.real_name,
phone=body.phone,
email=body.email,
dept_id=body.dept_id,
role_id=body.role_id,
status=body.status,
)
db.add(user)
await db.commit()
await db.refresh(user)
return ok(data=_user_to_resp(user).model_dump(mode="json"), message="账号创建成功")
# ================================================================
# 7. PUT /api/settings/users/{id} —— 编辑员工信息
# ================================================================
@router.put("/users/{user_id}", summary="编辑员工信息(不含密码)")
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
user = (
await db.execute(
select(SysUser).where(
SysUser.id == user_id, SysUser.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if user is None:
raise NotFoundException("用户不存在")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(SysUser).where(SysUser.id == user_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(SysUser).where(SysUser.id == user_id))
).scalar_one()
return ok(data=_user_to_resp(refreshed).model_dump(mode="json"), message="员工信息已更新")
# ================================================================
# 8. PUT /api/settings/users/{id}/reset-password —— 强制重置密码
# ================================================================
@router.put("/users/{user_id}/reset-password", summary="强制重置密码(bcrypt")
async def reset_password(
user_id: uuid.UUID,
body: UserResetPassword,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
user = (
await db.execute(
select(SysUser).where(
SysUser.id == user_id, SysUser.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if user is None:
raise NotFoundException("用户不存在")
await db.execute(
update(SysUser)
.where(SysUser.id == user_id)
.values(
password_hash=hash_password(body.new_password),
updated_at=datetime.utcnow(),
)
)
await db.commit()
return ok(message="密码已重置")