修复主管点击成员管理没有反应的BUG

This commit is contained in:
db_huang 2026-11-04 16:49:15 +08:00
parent 00239b0431
commit 7f851593fb
3 changed files with 97 additions and 35 deletions

View File

@ -249,7 +249,7 @@ DINGTALK_STATES: dict[str, float] = {}
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"} ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
ROLE_PERMISSIONS = { ROLE_PERMISSIONS = {
"super_admin": ["assistant", "library", "create", "board", "review", "refund-records", "usage", "users", "logs", "settings", "refund-management"], "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"], "operator": ["assistant", "library", "create", "board", "review", "refund-records"],
} }
@ -565,6 +565,12 @@ def require_super_admin(user: AdminUser = Depends(current_user)) -> AdminUser:
return user 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") @app.get("/api/health")
def health(): def health():
return {"success": True, "message": "ok"} 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)) 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") @app.patch("/api/admin/users/{user_id}/payment-account")
def authorize_payment_account(user_id: int, payload: PaymentAccountAuthorization, def authorize_payment_account(user_id: int, payload: PaymentAccountAuthorization,
current: AdminUser = Depends(current_user), db: Session = Depends(get_db)): 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}") @app.get("/api/admin/users/{user_id}")
def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): def user_detail(user_id: int, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)):
user = db.get(AdminUser, user_id) user = _managed_user(db, current, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
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}} 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) @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)): if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在") 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.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
@ -5226,15 +5261,23 @@ def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_a
@app.post("/api/admin/users/dingtalk/lookup") @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) 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)} return {"success": True, "data": _dingtalk_user_preview(profile)}
@app.post("/api/admin/users/dingtalk", status_code=status.HTTP_201_CREATED) @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) profile = _dingtalk_lookup_user(payload.identifier, db)
preview = _dingtalk_user_preview(profile) 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"] user_id = preview["user_id"]
union_id = preview["union_id"] union_id = preview["union_id"]
if not user_id and not 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) conditions.append(AdminUser.username == username)
user = db.scalar(select(AdminUser).where(or_(*conditions))) user = db.scalar(select(AdminUser).where(or_(*conditions)))
role_code = payload.role_code or preview["suggested_role"] 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: 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="该钉钉身份已绑定其他后台账号") 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: 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}") @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)): def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)):
user = db.get(AdminUser, user_id) user = _managed_user(db, current, user_id, for_update=True)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
changes = payload.model_dump(exclude_unset=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")): 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="不能停用或降级当前登录账号") 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": 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}") @app.delete("/api/admin/users/{user_id}")
def delete_user(user_id: int, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): def delete_user(user_id: int, current: AdminUser = Depends(require_user_manager), db: Session = Depends(get_db)):
user = db.get(AdminUser, user_id) user = _managed_user(db, current, user_id, for_update=True)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.id == current.id: if user.id == current.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
if user.role_code == "super_admin": if user.role_code == "super_admin":

View File

@ -30,8 +30,22 @@ def test_manager_department_scope_and_revocation(setup):
db.commit() db.commit()
account_id = owner.refund_payment_account_id account_id = owner.refund_payment_account_id
main.app.dependency_overrides[main.current_user] = lambda: manager 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"] rows = client.get("/api/admin/users").json()["data"]
assert {row["id"] for row in rows} == {owner.id, child.id} 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): for target in (owner, child):
assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 200 assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 200
for target in (outsider, manager): for target in (outsider, manager):

View File

@ -2343,6 +2343,7 @@ function AssistantPageFinal() {
function UserTable({ users = [], onChanged, currentUser }) { function UserTable({ users = [], onChanged, currentUser }) {
const rows = Array.isArray(users) ? users : []; const rows = Array.isArray(users) ? users : [];
const isSuperAdmin = currentUser?.role_code === "super_admin"; const isSuperAdmin = currentUser?.role_code === "super_admin";
const canManageMembers = ["super_admin", "dept_manager"].includes(currentUser?.role_code);
const [authorization, setAuthorization] = useState(null); const [authorization, setAuthorization] = useState(null);
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [accountId, setAccountId] = useState(""); const [accountId, setAccountId] = useState("");
@ -2493,10 +2494,10 @@ function UserTable({ users = [], onChanged, currentUser }) {
return ( return (
<> <>
<div className="user-table-actions"> <div className="user-table-actions">
{isSuperAdmin ? <button className="primary-button" onClick={() => open(null)}> {canManageMembers ? <button className="primary-button" onClick={() => open(null)}>
<Plus size={16} /> <Plus size={16} />
{"\u65b0\u589e\u7528\u6237"} {"\u65b0\u589e\u7528\u6237"}
</button> : <span>可为本部门及下级部门的普通运营设置付款账户授权。</span>} </button> : null}
</div> </div>
{authorization && ( {authorization && (
<div className="user-modal-backdrop"> <div className="user-modal-backdrop">
@ -2629,21 +2630,21 @@ function UserTable({ users = [], onChanged, currentUser }) {
/> />
</label> </label>
)} )}
<label> {isSuperAdmin ? (
{"\u89d2\u8272"} <label>
<select {"\u89d2\u8272"}
value={form.role_code} <select
onChange={(event) => value={form.role_code}
setForm({ ...form, role_code: event.target.value }) onChange={(event) =>
} setForm({ ...form, role_code: event.target.value })
> }
<option value="operator">{"\u666e\u901a\u8fd0\u8425"}</option> >
<option value="dept_manager">部门主管</option> <option value="operator">{"\u666e\u901a\u8fd0\u8425"}</option>
<option value="super_admin"> <option value="dept_manager">部门主管</option>
{"\u8d85\u7ea7\u7ba1\u7406\u5458"} <option value="super_admin">{"\u8d85\u7ea7\u7ba1\u7406\u5458"}</option>
</option> </select>
</select> </label>
</label> ) : <input type="hidden" value="operator" />}
{editing !== "new" && ( {editing !== "new" && (
<label className="checkbox-row"> <label className="checkbox-row">
<input <input
@ -2708,7 +2709,7 @@ function UserTable({ users = [], onChanged, currentUser }) {
</td> </td>
<td> <td>
{user.role_code === "operator" && <button className="table-action" onClick={() => openAuthorization(user)}>付款账户授权</button>} {user.role_code === "operator" && <button className="table-action" onClick={() => openAuthorization(user)}>付款账户授权</button>}
{isSuperAdmin && <><button className="table-action" onClick={() => open(user)}> {canManageMembers && <><button className="table-action" onClick={() => open(user)}>
{"\u7f16\u8f91"} {"\u7f16\u8f91"}
</button> </button>
<button <button