fix(menu): 修复菜单管理编辑无法保存与新增 500 - 根因一:MenuService create/update 以 snake_case 列名作 Sequelize 写入键而实体属性为 camelCase,导致写入被静默忽略(更新假成功)或 NOT NULL 报错(新增 500),改为 camelCase 属性名;根因二:sys_menu 列默认值指向历史遗留重复序列 sys_menu_id_seq1 且未推进,新增菜单 id 冲突,生产库已一次性 setval 同步(max(id)=64,并核对 sys_role/sys_user 序列),db_seed.sql 全部 setval 改为 pg_get_serial_sequence() 形式兼容不同环境序列名;接口与浏览器端到端验证通过(新增→改名→持久化→清理)

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
wkandClaude Sonnet 4.6 noreply@anthropic.com authored and trae-solo-agent committed 2026-10-24 17:26:43 +08:00
1 parent 44fc9a1d54
commit 7ff2405634
5 files changed
+72 -17

No files matched your search

@@ -0,0 +1,8 @@
# Checklist
- [x] MenuService.create() 以 camelCase 属性名写入(parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible),父菜单 parentId=null 语义保留
- [x] MenuService.update() 以 camelCase 属性名写入,部分字段更新不会清空未提交字段
- [x] sys_menu 自增序列失步已修复(生产库 setval 到 max(id)=64;sys_role/sys_user 序列一并核对),db_seed.sql 的 setval 全部改为 pg_get_serial_sequence() 形式以兼容不同环境序列名
- [x] 后端 tsc --noEmit 通过
- [x] PUT /api/menus/:id 修改名称后重新 GET 菜单树已持久化;POST /api/menus 返回 200 且新菜单可见(测试数据已清理)
- [x] 浏览器菜单管理页:编辑名称保存成功、树实时刷新、刷新页面后名称保持;新增菜单可用
+31
View File
@@ -0,0 +1,31 @@
# 修复菜单管理「编辑无法保存」Spec
## Why
菜单管理页面点击「编辑」修改菜单名称后点击保存,接口返回「更新菜单成功」但数据未持久化(名称未变);新增菜单则直接返回 500。根因:`MenuService.create/update` 以 snake_case 数据库列名(`menu_name` 等)作为 Sequelize 的取值键,而实体属性是 camelCase(`menuName` 等),Sequelize 无法匹配从而静默忽略(update)或触发 NOT NULL 报错(create)。
## What Changes
- 修正 `server/src/service/menu.service.ts` 中 `create()` 与 `update()`:将写入 Sequelize 的键改为实体 camelCase 属性名(`parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible`),字段映射由实体 `field` 配置负责。
- 前端与接口协议不变(前端已发送 camelCase body,`menu.controller.ts`、`menu.dto.ts`、`client/src/api/menu.ts` 均无需改动)。
- **BREAKING**: 无(仅为内部映射修正,接口行为从「假成功/500」变为「真实持久化」)。
## Impact
- Affected specs: 系统管理域(sys_menu RBAC)
- Affected code: `server/src/service/menu.service.ts`(仅 create/update 两处取值键);连带修复后「新增菜单」「编辑菜单」均恢复可用
## ADDED Requirements
(无新增能力)
## MODIFIED Requirements
### Requirement: 菜单新增与编辑持久化
菜单管理的「新增」与「编辑」操作 SHALL 将表单值真实写入 `sys_menu` 表。
#### Scenario: 编辑菜单名称后保存
- **WHEN** 用户在菜单管理中点击某菜单「编辑」,修改菜单名称并点击确定
- **THEN** 接口返回成功,重新拉取菜单树显示新名称,且刷新页面后名称保持不变
#### Scenario: 新增菜单
- **WHEN** 用户在菜单管理中点击「新增」,填写必填项并确定
- **THEN** 接口返回 200 并持久化,菜单树中出现新菜单(而非 500)
## REMOVED Requirements
(无)
+16
View File
@@ -0,0 +1,16 @@
# Tasks
- [x] Task 1: 修复 MenuService 写入键映射
- [x] 1.1 阅读 menu.service.ts,确认 create()/update() 中 snake_case 键的位置
- [x] 1.2 将 create() 与 update() 写入 Sequelize 的键改为实体 camelCase 属性名,并确保父子菜单 parentId=null 与 visible 处理逻辑不变
- [x] 1.3 后端 `npx tsc --noEmit` 通过
- [x] Task 2: 修复 sys_menu 自增序列失步(新增菜单 500 的根因)
- [x] 2.1 定位根因:列默认值指向 sys_menu_id_seq1 且停留在 2(显式 id 插入不推进序列),与已存在 id 冲突
- [x] 2.2 生产库一次性同步序列(setval 到 max(id)=64),并顺带核对 sys_role/sys_user 序列
- [x] 2.3 db_seed.sql 全部 setval 改为 pg_get_serial_sequence() 形式,兼容不同环境序列名
- [x] Task 3: 验证
- [x] 3.1 接口自测:PUT /api/menus/:id 修改菜单名称后重新 GET 树,名称已持久化
- [x] 3.2 接口自测:POST /api/menus 新增菜单返回 200 且树中出现新菜单(自测后清理测试数据)
- [x] 3.3 浏览器验证:菜单管理页编辑名称保存后树实时刷新、刷新页面后保持
# Task Dependencies
- [Task 2][Task 3] depend on [Task 1]
+7 -7
View File
@@ -43,7 +43,7 @@ ON CONFLICT (id) DO UPDATE SET
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER'); DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
-- 重置 sys_role 自增序列 -- 重置 sys_role 自增序列
SELECT setval('sys_role_id_seq', 2, true); SELECT setval(pg_get_serial_sequence('sys_role', 'id'), GREATEST((SELECT MAX(id) FROM sys_role), 2), true);
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2) -- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
@@ -185,7 +185,7 @@ ON CONFLICT (id) DO UPDATE SET
visible = EXCLUDED.visible; visible = EXCLUDED.visible;
-- 重置 sys_menu 自增序列 -- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 50, true); SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 50), true);
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行) -- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
@@ -252,7 +252,7 @@ ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status; status = EXCLUDED.status;
-- 重置 sys_user 自增序列 -- 重置 sys_user 自增序列
SELECT setval('sys_user_id_seq', 1, true); SELECT setval(pg_get_serial_sequence('sys_user', 'id'), GREATEST((SELECT MAX(id) FROM sys_user), 1), true);
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 5. 用户-角色绑定:admin → ADMIN -- 5. 用户-角色绑定:admin → ADMIN
@@ -328,7 +328,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING; ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 61) -- 重置 sys_menu 自增序列(覆盖到 61)
SELECT setval('sys_menu_id_seq', 61, true); SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 61), true);
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4) -- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4)
@@ -386,7 +386,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING; ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列 -- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 57, true); SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 57), true);
-- ------------------------------------------------------------ -- ------------------------------------------------------------
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等) -- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
@@ -521,7 +521,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING; ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 62) -- 重置 sys_menu 自增序列(覆盖到 62)
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 62), true); SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 62), true);
-- 智电网关同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟) -- 智电网关同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
@@ -574,7 +574,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING; ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 64) -- 重置 sys_menu 自增序列(覆盖到 64)
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 64), true); SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 64), true);
COMMIT; COMMIT;
+10 -10
View File
@@ -43,13 +43,13 @@ export class MenuService {
} }
const menu = await this.menuModel.create({ const menu = await this.menuModel.create({
parent_id: parentId, parentId,
menu_name: menuName, menuName,
menu_path: menuPath, menuPath,
component, component,
icon, icon,
sort_order: sortOrder, sortOrder,
menu_type: menuType, menuType,
permission, permission,
visible, visible,
}); });
@@ -73,13 +73,13 @@ export class MenuService {
} }
const updateData: any = {}; const updateData: any = {};
if (data.parentId !== undefined) updateData.parent_id = data.parentId; if (data.parentId !== undefined) updateData.parentId = data.parentId;
if (data.menuName !== undefined) updateData.menu_name = data.menuName; if (data.menuName !== undefined) updateData.menuName = data.menuName;
if (data.menuPath !== undefined) updateData.menu_path = data.menuPath; if (data.menuPath !== undefined) updateData.menuPath = data.menuPath;
if (data.component !== undefined) updateData.component = data.component; if (data.component !== undefined) updateData.component = data.component;
if (data.icon !== undefined) updateData.icon = data.icon; if (data.icon !== undefined) updateData.icon = data.icon;
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder; if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder;
if (data.menuType !== undefined) updateData.menu_type = data.menuType; if (data.menuType !== undefined) updateData.menuType = data.menuType;
if (data.permission !== undefined) updateData.permission = data.permission; if (data.permission !== undefined) updateData.permission = data.permission;
if (data.visible !== undefined) updateData.visible = data.visible; if (data.visible !== undefined) updateData.visible = data.visible;