From 1cb8cf534bc10445302b31fcf459e30cabe9c49f Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 6 May 2026 13:45:40 +0800 Subject: [PATCH] fix(user): 修复权限保存的竞态/越权/软删兼容问题 (REQ-USR-002) --- backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java | 5 ++++- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java | 28 +++++++++++++++------------- frontend/src/pages/usr/UserDetail.tsx | 55 ++++++++++++++++++++++++++++++++++++++++--------------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java index cdcd3b6..9952d09 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java @@ -13,6 +13,9 @@ public interface UserPermissionMapper extends BaseMapper { @Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}") int deleteByUserId(@Param("userId") Integer userId); - @Select("SELECT iCategoryId FROM tUserPermission WHERE iUserId = #{userId} ORDER BY iIncrement ASC") + @Select("SELECT up.iCategoryId FROM tUserPermission up " + + "INNER JOIN tPermissionCategory pc ON pc.iIncrement = up.iCategoryId " + + "WHERE up.iUserId = #{userId} AND pc.bDeleted = 0 " + + "ORDER BY up.iIncrement ASC") List selectCategoryIdsByUserId(@Param("userId") Integer userId); } diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java index 48d4e1d..cfd7f5a 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java @@ -191,19 +191,21 @@ public class UserServiceImpl implements UserService { throw new BizException(40020, "用户号或用户名已存在"); } - userPermissionMapper.deleteByUserId(id); - if (ids != null && !ids.isEmpty()) { - String createdBy = SecurityContextHelper.currentUserNo(); - LocalDateTime now = LocalDateTime.now(); - for (Integer cid : ids) { - UserPermission rel = new UserPermission(); - rel.setSBrandsId(tenant.getBrandsId()); - rel.setSSubsidiaryId(tenant.getSubsidiaryId()); - rel.setTCreateDate(now); - rel.setIUserId(id); - rel.setICategoryId(cid); - rel.setSCreatedBy(createdBy); - userPermissionMapper.insert(rel); + if (ids != null) { + userPermissionMapper.deleteByUserId(id); + if (!ids.isEmpty()) { + String createdBy = SecurityContextHelper.currentUserNo(); + LocalDateTime now = LocalDateTime.now(); + for (Integer cid : ids) { + UserPermission rel = new UserPermission(); + rel.setSBrandsId(tenant.getBrandsId()); + rel.setSSubsidiaryId(tenant.getSubsidiaryId()); + rel.setTCreateDate(now); + rel.setIUserId(id); + rel.setICategoryId(cid); + rel.setSCreatedBy(createdBy); + userPermissionMapper.insert(rel); + } } } return id; diff --git a/frontend/src/pages/usr/UserDetail.tsx b/frontend/src/pages/usr/UserDetail.tsx index 8244076..bdd1aeb 100644 --- a/frontend/src/pages/usr/UserDetail.tsx +++ b/frontend/src/pages/usr/UserDetail.tsx @@ -76,7 +76,11 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { const [detail, setDetail] = useState(snapshot); const currentSnapshot = detail ?? snapshot; const [permissionCategories, setPermissionCategories] = useState([]); - const [permissionCategoryIds, setPermissionCategoryIds] = useState([]); + // null = 详情尚未加载(仅在新建模式下立即视为 [])。 + // 编辑模式必须等到非 null 后才允许保存,否则会把"未知"误当成"清空"。 + const [permissionCategoryIds, setPermissionCategoryIds] = useState( + initialMode === "new" ? [] : null + ); const buildForm = (snap?: UserListVO): FormState => mode === "new" || !snap @@ -123,13 +127,18 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { setPermissionCategoryIds(u.permissionCategoryIds ?? []); }) .catch(() => { - // interceptor already showed message + // interceptor already showed message; 保持 null 以便"修改/保存"维持 disabled。 }); }, [mode, userId]); useEffect(() => { setForm(buildForm(currentSnapshot)); - setPermissionCategoryIds(mode === "new" ? [] : currentSnapshot?.permissionCategoryIds ?? []); + if (mode === "new") { + setPermissionCategoryIds([]); + } else if (currentSnapshot?.permissionCategoryIds !== undefined) { + // detail() 返回过来的快照里带 ids 才采纳;列表态快照没有该字段时保持 null(仍未加载)。 + setPermissionCategoryIds(currentSnapshot.permissionCategoryIds); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentSnapshot, mode]); @@ -137,7 +146,8 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { setForm((s) => ({ ...s, [k]: v })); const disabled = mode === "view"; - const checkedCount = permissionCategoryIds.length; + const checkedCount = permissionCategoryIds?.length ?? 0; + const permsLoaded = permissionCategoryIds !== null; const startEdit = () => setMode("edit"); const startNew = () => setMode("new"); @@ -147,7 +157,9 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { dispatch(setActiveTab("userlist")); } else { setForm(buildForm(currentSnapshot)); - setPermissionCategoryIds(currentSnapshot?.permissionCategoryIds ?? []); + if (currentSnapshot?.permissionCategoryIds !== undefined) { + setPermissionCategoryIds(currentSnapshot.permissionCategoryIds); + } setMode("view"); } }; @@ -164,8 +176,13 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { sUserType: form.sUserType, sLanguage: form.sLanguage, bCanModifyDocs: form.bCanModifyDocs, - permissionCategoryIds, }; + if (mode === "new") { + dto.permissionCategoryIds = permissionCategoryIds ?? []; + } else if (permissionCategoryIds !== null) { + // 编辑模式:未加载完前不下发该字段,后端按"不动权限"处理。 + dto.permissionCategoryIds = permissionCategoryIds; + } setSubmitting(true); try { if (mode === "new") { @@ -217,14 +234,18 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { } onClick={startEdit} - disabled={mode !== "view" || !userId} + disabled={mode !== "view" || !userId || !permsLoaded} > 修改 } disabled={mode !== "view"} danger> 删除 - } onClick={save} disabled={mode === "view" || submitting}> + } + onClick={save} + disabled={mode === "view" || submitting || (mode === "edit" && !permsLoaded)} + > 保存 } onClick={cancel} disabled={mode === "view"}> @@ -440,13 +461,14 @@ export default function UserDetail({ userId, mode: initialMode }: Props) { {permTab === "groups" ? ( - setPermissionCategoryIds((s) => - v ? Array.from(new Set([...s, id])) : s.filter((x) => x !== id) - ) + setPermissionCategoryIds((s) => { + const base = s ?? []; + return v ? Array.from(new Set([...base, id])) : base.filter((x) => x !== id); + }) } - disabled={disabled} + disabled={disabled || !permsLoaded} /> ) : ( !filter || c.sCategoryName.includes(filter) || c.sCategoryCode.includes(filter) ); - const allChecked = categories.length > 0 && categories.every((c) => selected.has(c.iIncrement)); + // 表头全选只反映/操作"当前可见行",避免过滤态下越权授权或误清。 + const allChecked = + visibleCategories.length > 0 && + visibleCategories.every((c) => selected.has(c.iIncrement)); return ( { - categories.forEach((c) => setPermission(c.iIncrement, v)); + visibleCategories.forEach((c) => setPermission(c.iIncrement, v)); }} disabled={disabled} /> -- libgit2 0.22.2