Commit 1cb8cf534bc10445302b31fcf459e30cabe9c49f

Authored by zichun
1 parent fe5a6531

fix(user): 修复权限保存的竞态/越权/软删兼容问题 (REQ-USR-002)

源自 Codex 对 wire-frontend-backend 分支的对抗性评审 (2× high / 1× medium),三条 finding 均已复核属实。

- [HIGH] 详情未加载就保存会把 permissionCategoryIds 当成 [] → 静默清空:
  - 前端 ids 三态化 (number[] | null),列表态 snapshot 不带 ids 时保持 null
  - 编辑模式下 ids 仍为 null 时 disable 修改/保存按钮,DTO 中省略该字段
  - 后端 update() 把 delete+reinsert 包到 if (ids != null),null 视为"不动权限"
- [HIGH] 过滤态下表头全选越权授权:allChecked 与 onChange 改为基于 visibleCategories
- [MEDIUM] selectCategoryIdsByUserId 加 INNER JOIN tPermissionCategory 过滤 bDeleted=0,
  避免软删分类导致 40023 阻塞无关字段编辑
backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java
... ... @@ -13,6 +13,9 @@ public interface UserPermissionMapper extends BaseMapper<UserPermission> {
13 13 @Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}")
14 14 int deleteByUserId(@Param("userId") Integer userId);
15 15  
16   - @Select("SELECT iCategoryId FROM tUserPermission WHERE iUserId = #{userId} ORDER BY iIncrement ASC")
  16 + @Select("SELECT up.iCategoryId FROM tUserPermission up "
  17 + + "INNER JOIN tPermissionCategory pc ON pc.iIncrement = up.iCategoryId "
  18 + + "WHERE up.iUserId = #{userId} AND pc.bDeleted = 0 "
  19 + + "ORDER BY up.iIncrement ASC")
17 20 List<Integer> selectCategoryIdsByUserId(@Param("userId") Integer userId);
18 21 }
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java
... ... @@ -191,19 +191,21 @@ public class UserServiceImpl implements UserService {
191 191 throw new BizException(40020, "用户号或用户名已存在");
192 192 }
193 193  
194   - userPermissionMapper.deleteByUserId(id);
195   - if (ids != null && !ids.isEmpty()) {
196   - String createdBy = SecurityContextHelper.currentUserNo();
197   - LocalDateTime now = LocalDateTime.now();
198   - for (Integer cid : ids) {
199   - UserPermission rel = new UserPermission();
200   - rel.setSBrandsId(tenant.getBrandsId());
201   - rel.setSSubsidiaryId(tenant.getSubsidiaryId());
202   - rel.setTCreateDate(now);
203   - rel.setIUserId(id);
204   - rel.setICategoryId(cid);
205   - rel.setSCreatedBy(createdBy);
206   - userPermissionMapper.insert(rel);
  194 + if (ids != null) {
  195 + userPermissionMapper.deleteByUserId(id);
  196 + if (!ids.isEmpty()) {
  197 + String createdBy = SecurityContextHelper.currentUserNo();
  198 + LocalDateTime now = LocalDateTime.now();
  199 + for (Integer cid : ids) {
  200 + UserPermission rel = new UserPermission();
  201 + rel.setSBrandsId(tenant.getBrandsId());
  202 + rel.setSSubsidiaryId(tenant.getSubsidiaryId());
  203 + rel.setTCreateDate(now);
  204 + rel.setIUserId(id);
  205 + rel.setICategoryId(cid);
  206 + rel.setSCreatedBy(createdBy);
  207 + userPermissionMapper.insert(rel);
  208 + }
207 209 }
208 210 }
209 211 return id;
... ...
frontend/src/pages/usr/UserDetail.tsx
... ... @@ -76,7 +76,11 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
76 76 const [detail, setDetail] = useState<UserListVO | undefined>(snapshot);
77 77 const currentSnapshot = detail ?? snapshot;
78 78 const [permissionCategories, setPermissionCategories] = useState<PermissionCategoryVO[]>([]);
79   - const [permissionCategoryIds, setPermissionCategoryIds] = useState<number[]>([]);
  79 + // null = 详情尚未加载(仅在新建模式下立即视为 [])。
  80 + // 编辑模式必须等到非 null 后才允许保存,否则会把"未知"误当成"清空"。
  81 + const [permissionCategoryIds, setPermissionCategoryIds] = useState<number[] | null>(
  82 + initialMode === "new" ? [] : null
  83 + );
80 84  
81 85 const buildForm = (snap?: UserListVO): FormState =>
82 86 mode === "new" || !snap
... ... @@ -123,13 +127,18 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
123 127 setPermissionCategoryIds(u.permissionCategoryIds ?? []);
124 128 })
125 129 .catch(() => {
126   - // interceptor already showed message
  130 + // interceptor already showed message; 保持 null 以便"修改/保存"维持 disabled。
127 131 });
128 132 }, [mode, userId]);
129 133  
130 134 useEffect(() => {
131 135 setForm(buildForm(currentSnapshot));
132   - setPermissionCategoryIds(mode === "new" ? [] : currentSnapshot?.permissionCategoryIds ?? []);
  136 + if (mode === "new") {
  137 + setPermissionCategoryIds([]);
  138 + } else if (currentSnapshot?.permissionCategoryIds !== undefined) {
  139 + // detail() 返回过来的快照里带 ids 才采纳;列表态快照没有该字段时保持 null(仍未加载)。
  140 + setPermissionCategoryIds(currentSnapshot.permissionCategoryIds);
  141 + }
133 142 // eslint-disable-next-line react-hooks/exhaustive-deps
134 143 }, [currentSnapshot, mode]);
135 144  
... ... @@ -137,7 +146,8 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
137 146 setForm((s) => ({ ...s, [k]: v }));
138 147  
139 148 const disabled = mode === "view";
140   - const checkedCount = permissionCategoryIds.length;
  149 + const checkedCount = permissionCategoryIds?.length ?? 0;
  150 + const permsLoaded = permissionCategoryIds !== null;
141 151  
142 152 const startEdit = () => setMode("edit");
143 153 const startNew = () => setMode("new");
... ... @@ -147,7 +157,9 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
147 157 dispatch(setActiveTab("userlist"));
148 158 } else {
149 159 setForm(buildForm(currentSnapshot));
150   - setPermissionCategoryIds(currentSnapshot?.permissionCategoryIds ?? []);
  160 + if (currentSnapshot?.permissionCategoryIds !== undefined) {
  161 + setPermissionCategoryIds(currentSnapshot.permissionCategoryIds);
  162 + }
151 163 setMode("view");
152 164 }
153 165 };
... ... @@ -164,8 +176,13 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
164 176 sUserType: form.sUserType,
165 177 sLanguage: form.sLanguage,
166 178 bCanModifyDocs: form.bCanModifyDocs,
167   - permissionCategoryIds,
168 179 };
  180 + if (mode === "new") {
  181 + dto.permissionCategoryIds = permissionCategoryIds ?? [];
  182 + } else if (permissionCategoryIds !== null) {
  183 + // 编辑模式:未加载完前不下发该字段,后端按"不动权限"处理。
  184 + dto.permissionCategoryIds = permissionCategoryIds;
  185 + }
169 186 setSubmitting(true);
170 187 try {
171 188 if (mode === "new") {
... ... @@ -217,14 +234,18 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
217 234 <ToolbarBtnDark
218 235 icon={<EditOutlined />}
219 236 onClick={startEdit}
220   - disabled={mode !== "view" || !userId}
  237 + disabled={mode !== "view" || !userId || !permsLoaded}
221 238 >
222 239 修改
223 240 </ToolbarBtnDark>
224 241 <ToolbarBtnDark icon={<DeleteOutlined />} disabled={mode !== "view"} danger>
225 242 删除
226 243 </ToolbarBtnDark>
227   - <ToolbarBtnDark icon={<SaveOutlined />} onClick={save} disabled={mode === "view" || submitting}>
  244 + <ToolbarBtnDark
  245 + icon={<SaveOutlined />}
  246 + onClick={save}
  247 + disabled={mode === "view" || submitting || (mode === "edit" && !permsLoaded)}
  248 + >
228 249 保存
229 250 </ToolbarBtnDark>
230 251 <ToolbarBtnDark icon={<CloseOutlined />} onClick={cancel} disabled={mode === "view"}>
... ... @@ -440,13 +461,14 @@ export default function UserDetail({ userId, mode: initialMode }: Props) {
440 461 {permTab === "groups" ? (
441 462 <PermissionGrid
442 463 categories={permissionCategories}
443   - selectedIds={permissionCategoryIds}
  464 + selectedIds={permissionCategoryIds ?? []}
444 465 setPermission={(id, v) =>
445   - setPermissionCategoryIds((s) =>
446   - v ? Array.from(new Set([...s, id])) : s.filter((x) => x !== id)
447   - )
  466 + setPermissionCategoryIds((s) => {
  467 + const base = s ?? [];
  468 + return v ? Array.from(new Set([...base, id])) : base.filter((x) => x !== id);
  469 + })
448 470 }
449   - disabled={disabled}
  471 + disabled={disabled || !permsLoaded}
450 472 />
451 473 ) : (
452 474 <ScopeTab
... ... @@ -494,7 +516,10 @@ function PermissionGrid({ categories, selectedIds, setPermission, disabled }: Pe
494 516 const visibleCategories = categories.filter(
495 517 (c) => !filter || c.sCategoryName.includes(filter) || c.sCategoryCode.includes(filter)
496 518 );
497   - const allChecked = categories.length > 0 && categories.every((c) => selected.has(c.iIncrement));
  519 + // 表头全选只反映/操作"当前可见行",避免过滤态下越权授权或误清。
  520 + const allChecked =
  521 + visibleCategories.length > 0 &&
  522 + visibleCategories.every((c) => selected.has(c.iIncrement));
498 523  
499 524 return (
500 525 <table
... ... @@ -521,7 +546,7 @@ function PermissionGrid({ categories, selectedIds, setPermission, disabled }: Pe
521 546 <PrimCheckbox
522 547 checked={allChecked}
523 548 onChange={(v) => {
524   - categories.forEach((c) => setPermission(c.iIncrement, v));
  549 + visibleCategories.forEach((c) => setPermission(c.iIncrement, v));
525 550 }}
526 551 disabled={disabled}
527 552 />
... ...