From 7f851593fbedc9cc3528046b24f7c3e2a5c4f76f Mon Sep 17 00:00:00 2001 From: db_huang <18006480240@163.com> Date: Wed, 4 Nov 2026 16:49:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E7=AE=A1=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E6=88=90=E5=91=98=E7=AE=A1=E7=90=86=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=8F=8D=E5=BA=94=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 81 +++++++++++++++---- .../test_payment_account_authorization.py | 14 ++++ frontend/src/main.jsx | 37 ++++----- 3 files changed, 97 insertions(+), 35 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b612dc7..400fa56 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -249,7 +249,7 @@ DINGTALK_STATES: dict[str, float] = {} ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"} ROLE_PERMISSIONS = { "super_admin": ["assistant", "library", "create", "board", "review", "refund-records", "usage", "users", "logs", "settings", "refund-management"], - "dept_manager": ["assistant", "library", "create", "board", "review", "refund-records"], + "dept_manager": ["assistant", "library", "create", "board", "review", "refund-records", "users"], "operator": ["assistant", "library", "create", "board", "review", "refund-records"], } @@ -565,6 +565,12 @@ def require_super_admin(user: AdminUser = Depends(current_user)) -> AdminUser: return user +def require_user_manager(user: AdminUser = Depends(current_user)) -> AdminUser: + if user.role_code not in {"super_admin", "dept_manager"}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅部门主管或超级管理员可管理成员") + return user + + @app.get("/api/health") def health(): return {"success": True, "message": "ok"} @@ -5186,6 +5192,27 @@ def _authorization_department_scope(manager: AdminUser): AdminUser.dingtalk_dept_path.contains(f",{manager.dingtalk_dept_id},", autoescape=True)) +def _department_is_in_manager_scope(manager: AdminUser, dept_id: str | int | None, dept_path: str | None) -> bool: + manager_dept_id = str(manager.dingtalk_dept_id or "").strip() + if not manager_dept_id: + return False + return str(dept_id or "").strip() == manager_dept_id or f",{manager_dept_id}," in str(dept_path or "") + + +def _managed_user(db: Session, current: AdminUser, user_id: int, *, for_update: bool = False) -> AdminUser: + statement = select(AdminUser).where(AdminUser.id == user_id) + if current.role_code == "dept_manager": + statement = statement.where(AdminUser.role_code == "operator", _authorization_department_scope(current)) + if for_update: + statement = statement.with_for_update() + user = db.scalar(statement) + if user: + return user + if current.role_code == "dept_manager" and db.get(AdminUser, user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能管理本部门及下级部门的普通运营") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + @app.patch("/api/admin/users/{user_id}/payment-account") def authorize_payment_account(user_id: int, payload: PaymentAccountAuthorization, current: AdminUser = Depends(current_user), db: Session = Depends(get_db)): @@ -5207,18 +5234,26 @@ def authorize_payment_account(user_id: int, payload: PaymentAccountAuthorization @app.get("/api/admin/users/{user_id}") -def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): - user = db.get(AdminUser, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") +def user_detail(user_id: int, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): + user = _managed_user(db, current, user_id) return {"success": True, "data": public_user(user) | {"is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None}} @app.post("/api/admin/users", status_code=status.HTTP_201_CREATED) -def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): +def create_user(payload: AdminUserCreate, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在") - user = AdminUser(username=payload.username, password_hash=hash_password(payload.password), display_name=payload.display_name, role_code=payload.role_code) + if current.role_code == "dept_manager" and payload.role_code != "operator": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="部门主管只能新增普通运营") + user = AdminUser( + username=payload.username, + password_hash=hash_password(payload.password), + display_name=payload.display_name, + role_code=payload.role_code, + dingtalk_dept_id=current.dingtalk_dept_id if current.role_code == "dept_manager" else None, + dingtalk_dept_name=current.dingtalk_dept_name if current.role_code == "dept_manager" else None, + dingtalk_dept_path=current.dingtalk_dept_path if current.role_code == "dept_manager" else None, + ) db.add(user) db.commit() db.refresh(user) @@ -5226,15 +5261,23 @@ def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_a @app.post("/api/admin/users/dingtalk/lookup") -def lookup_dingtalk_user(payload: AdminDingTalkUserLookup, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): +def lookup_dingtalk_user(payload: AdminDingTalkUserLookup, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): profile = _dingtalk_lookup_user(payload.identifier, db) + if current.role_code == "dept_manager" and not _department_is_in_manager_scope( + current, profile.get("_primary_dept_id"), profile.get("_dept_path") + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能添加本部门及下级部门的钉钉员工") return {"success": True, "data": _dingtalk_user_preview(profile)} @app.post("/api/admin/users/dingtalk", status_code=status.HTTP_201_CREATED) -def create_dingtalk_user(payload: AdminDingTalkUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): +def create_dingtalk_user(payload: AdminDingTalkUserCreate, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): profile = _dingtalk_lookup_user(payload.identifier, db) preview = _dingtalk_user_preview(profile) + if current.role_code == "dept_manager" and not _department_is_in_manager_scope( + current, profile.get("_primary_dept_id"), profile.get("_dept_path") + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能添加本部门及下级部门的钉钉员工") user_id = preview["user_id"] union_id = preview["union_id"] if not user_id and not union_id: @@ -5248,6 +5291,12 @@ def create_dingtalk_user(payload: AdminDingTalkUserCreate, _: AdminUser = Depend conditions.append(AdminUser.username == username) user = db.scalar(select(AdminUser).where(or_(*conditions))) role_code = payload.role_code or preview["suggested_role"] + if current.role_code == "dept_manager": + if user and user.role_code != "operator": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="部门主管不能修改主管或超级管理员账号") + if role_code != "operator": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="部门主管只能新增普通运营") + role_code = "operator" if user and user.dingtalk_user_id and user_id and user.dingtalk_user_id != user_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该钉钉身份已绑定其他后台账号") if user and user.dingtalk_union_id and union_id and user.dingtalk_union_id != union_id: @@ -5286,11 +5335,11 @@ def create_dingtalk_user(payload: AdminDingTalkUserCreate, _: AdminUser = Depend @app.patch("/api/admin/users/{user_id}") -def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): - user = db.get(AdminUser, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") +def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): + user = _managed_user(db, current, user_id, for_update=True) changes = payload.model_dump(exclude_unset=True) + if current.role_code == "dept_manager" and changes.get("role_code") not in (None, "operator"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="部门主管不能提升成员角色") if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") not in (None, "super_admin")): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号") if changes.get("role_code") in {"operator", "dept_manager"} and user.role_code == "super_admin": @@ -5307,10 +5356,8 @@ def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Dep @app.delete("/api/admin/users/{user_id}") -def delete_user(user_id: int, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): - user = db.get(AdminUser, user_id) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") +def delete_user(user_id: int, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)): + user = _managed_user(db, current, user_id, for_update=True) if user.id == current.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号") if user.role_code == "super_admin": diff --git a/backend/tests/test_payment_account_authorization.py b/backend/tests/test_payment_account_authorization.py index f8f47cd..29f77ae 100644 --- a/backend/tests/test_payment_account_authorization.py +++ b/backend/tests/test_payment_account_authorization.py @@ -30,8 +30,22 @@ def test_manager_department_scope_and_revocation(setup): db.commit() account_id = owner.refund_payment_account_id main.app.dependency_overrides[main.current_user] = lambda: manager + assert "users" in main.public_user(manager)["permissions"] rows = client.get("/api/admin/users").json()["data"] assert {row["id"] for row in rows} == {owner.id, child.id} + assert client.get(f"/api/admin/users/{owner.id}").status_code == 200 + assert client.get(f"/api/admin/users/{outsider.id}").status_code == 403 + assert client.patch(f"/api/admin/users/{owner.id}", json={"display_name": "本部门成员"}).status_code == 200 + assert client.patch(f"/api/admin/users/{owner.id}", json={"role_code": "dept_manager"}).status_code == 403 + created = client.post("/api/admin/users", json={ + "username": "manager-created", "password": "password123", "display_name": "主管新增", "role_code": "operator", + }) + assert created.status_code == 201 + created_id = created.json()["data"]["user"]["id"] + created_user = db.get(AdminUser, created_id) + assert str(created_user.dingtalk_dept_id) == str(manager.dingtalk_dept_id) + assert client.delete(f"/api/admin/users/{created_id}").status_code == 200 + assert client.delete(f"/api/admin/users/{outsider.id}").status_code == 403 for target in (owner, child): assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 200 for target in (outsider, manager): diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index fe63542..79e726e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2343,6 +2343,7 @@ function AssistantPageFinal() { function UserTable({ users = [], onChanged, currentUser }) { const rows = Array.isArray(users) ? users : []; const isSuperAdmin = currentUser?.role_code === "super_admin"; + const canManageMembers = ["super_admin", "dept_manager"].includes(currentUser?.role_code); const [authorization, setAuthorization] = useState(null); const [accounts, setAccounts] = useState([]); const [accountId, setAccountId] = useState(""); @@ -2493,10 +2494,10 @@ function UserTable({ users = [], onChanged, currentUser }) { return ( <>