From 9a9c57578d18d474b118d7c3449710d106adb5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 27 Jul 2026 20:19:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=9A=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E8=8F=9C=E5=8D=95=E4=B8=80=E7=BA=A7=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-27-promote-business-menus.md | 774 ++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-promote-business-menus.md diff --git a/docs/superpowers/plans/2026-07-27-promote-business-menus.md b/docs/superpowers/plans/2026-07-27-promote-business-menus.md new file mode 100644 index 0000000..89dcea0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-promote-business-menus.md @@ -0,0 +1,774 @@ +# 业务菜单一级展示 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** 将“基础资料维护”“业务管理”“报表查询”提升为独立一级菜单,并让六个目标一级目录按 121、122、123、1、2、3 的顺序展示。 + +**Architecture:** Web 菜单继续由后端 /getRouters 根据 sys_menu 动态生成,不增加前端拍平或重排代码。新建库通过 MySQL/PostgreSQL 初始化 SQL 得到目标层级,已有 MySQL 库通过独立、幂等、fail-closed 的 20260727 增量补丁迁移;已执行的 20260724 历史补丁保持不变。 + +**Tech Stack:** MySQL 8、PostgreSQL、Python 3.10 unittest、Ruff、Vue 3、Vite 6、Node 20。 + +--- + +## 文件结构与保护边界 + +- 修改 shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py:区分初始化器目标状态与 20260724 补丁历史状态,并新增 20260727 补丁契约。 +- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:新建 MySQL 数据库的六个一级菜单层级与排序。 +- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:新建 PostgreSQL 数据库的六个一级菜单层级与排序。 +- 创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql:迁移现有 MySQL 数据库。 +- 不修改 shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql。 +- 不修改 Web 前端源码、APP、API、权限标识、角色菜单关联、业务子菜单或数据库表结构。 +- 当前工作区已有 APP、后端/前端环境文件和已暂存 .idea/git_toolbox_prj.xml;每次暂存和提交都必须按路径执行,禁止 git add -A。 + +## Task 1:用契约固定初始化器的目标层级,并保留历史补丁语义 + +**Files:** + +- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql +- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql +- Preserve: shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql + +- [ ] **Step 1:先修改契约测试,表达目标状态和历史状态** + +在测试文件顶部保留旧补丁路径,并新增新补丁路径: + + PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql' + MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql' + +把 BUSINESS_MENU_SPECS 的前三行改为初始化器目标值,其余 61 行原样保留: + + BUSINESS_MENU_SPECS = """ + 121|0|基础资料维护|businessMaster||1|| + 122|0|业务管理|businessOperation||2|| + 123|0|报表查询|businessReport||3|| + 502|121|菌种资料|strain||1|master:strain:list|master/strain/index + +在 EXPECTED_BUSINESS_MENU_FIELDS 和 BUSINESS_MENU_IDS 定义之后加入: + + LEGACY_PATCH_BUSINESS_MENU_FIELDS = { + **EXPECTED_BUSINESS_MENU_FIELDS, + 121: (1, '基础资料维护', 'businessMaster', '', 10, '', None), + 122: (1, '业务管理', 'businessOperation', '', 11, '', None), + 123: (1, '报表查询', 'businessReport', '', 12, '', None), + } + EXPECTED_TOP_LEVEL_MENU_POSITIONS = { + 121: ('基础资料维护', 0, 1), + 122: ('业务管理', 0, 2), + 123: ('报表查询', 0, 3), + 1: ('系统管理', 0, 4), + 2: ('系统监控', 0, 5), + 3: ('系统工具', 0, 6), + } + MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS) + +不要把 1、2、3 加入 BUSINESS_MENU_IDS;该集合还被“新业务菜单不默认授权角色”的现有测试使用。 + +把菜单提取和字段断言改为以下实现: + + def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]: + rows = _insert_rows(sql, 'sys_menu') + selected = [ + row + for row in rows + if row and str(row[0]).isdigit() and int(row[0]) in menu_ids + ] + counts = { + menu_id: sum(int(row[0]) == menu_id for row in selected) + for menu_id in menu_ids + } + if any(count != 1 for count in counts.values()): + raise AssertionError(f'menu row counts differ: {counts}') + return {int(row[0]): row for row in selected} + + + def _business_menu_rows(sql: str) -> dict[int, list]: + return _menu_rows(sql, BUSINESS_MENU_IDS) + + + def _assert_business_menu_fields( + test_case: unittest.TestCase, + sql: str, + expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS, + ) -> None: + rows = _business_menu_rows(sql) + invalid_widths = { + menu_id: len(row) + for menu_id, row in rows.items() + if len(row) != SYS_MENU_VALUE_COUNT + } + test_case.assertEqual({}, invalid_widths) + actual = { + menu_id: ( + int(row[2]), + row[1], + row[4], + row[7], + int(row[3]), + row[13], + row[5], + ) + for menu_id, row in rows.items() + } + test_case.assertEqual(expected_fields, actual) + +在 test_initializers_define_fixed_business_menus_and_permissions 中把三个根目录断言改成: + + self.assertEqual( + ('基础资料维护', '0', '1', 'businessMaster', 'M', ''), + tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)), + ) + self.assertEqual( + ('业务管理', '0', '2', 'businessOperation', 'M', ''), + tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)), + ) + self.assertEqual( + ('报表查询', '0', '3', 'businessReport', 'M', ''), + tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)), + ) + +紧接该测试新增六个一级目录的顺序契约: + + def test_initializers_define_expected_top_level_menu_order(self) -> None: + for filename in INITIALIZERS: + with self.subTest(filename=filename): + sql = (SQL_ROOT / filename).read_text(encoding='utf-8') + rows = _menu_rows(sql, MENU_PROMOTION_IDS) + actual = { + menu_id: (row[1], int(row[2]), int(row[3])) + for menu_id, row in rows.items() + } + self.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual) + +在 test_patch_is_documented_idempotent_and_contains_full_contract 中,将旧补丁字段断言改成: + + _assert_business_menu_fields( + self, + raw, + LEGACY_PATCH_BUSINESS_MENU_FIELDS, + ) + +同一测试中,旧补丁与当前 MySQL 初始化器的比较只比较不含 parent_id、order_num 的稳定字段: + + compared_indexes = (0, 1, *range(4, 15)) + self.assertEqual( + { + menu_id: tuple(row[index] for index in compared_indexes) + for menu_id, row in mysql_rows.items() + }, + { + menu_id: tuple(row[index] for index in compared_indexes) + for menu_id, row in patch_rows.items() + }, + ) + +- [ ] **Step 2:运行初始化器契约,确认 RED** + +工作目录:shierfeng-fastapi-backend + + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \ + tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \ + tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \ + -v + +预期:FAIL。MySQL/PostgreSQL 初始化器仍将 121/122/123 放在 parent_id=1、order_num=10/11/12,1/2/3 仍使用 order_num=1/2/3。 + +- [ ] **Step 3:对两份初始化 SQL 做六行最小修改** + +MySQL 的六行目标内容: + + insert into sys_menu values('1', '系统管理', '0', '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录'); + insert into sys_menu values('2', '系统监控', '0', '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录'); + insert into sys_menu values('3', '系统工具', '0', '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录'); + + insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '基础资料维护目录'); + insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '业务管理目录'); + insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '报表查询目录'); + +把 MySQL 注释“托盘业务目录”同步改为“托盘业务一级目录”。 + +PostgreSQL 的六行目标内容: + + insert into sys_menu values(1, '系统管理', 0, '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录'); + insert into sys_menu values(2, '系统监控', 0, '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录'); + insert into sys_menu values(3, '系统工具', 0, '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录'); + + (121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''), + (122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''), + (123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''), + +把 PostgreSQL 注释“初始化-托盘业务菜单和权限”同步改为“初始化-托盘业务一级菜单和权限”。 + +- [ ] **Step 4:运行 GREEN 和历史补丁兼容回归** + +工作目录:shierfeng-fastapi-backend + + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \ + tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \ + tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \ + tests.module_business.test_sql_contract.SqlContractTest.test_mysql_and_postgresql_business_menu_fields_are_identical \ + tests.module_business.test_sql_contract.SqlContractTest.test_patch_is_documented_idempotent_and_contains_full_contract \ + -v + +预期:4 项均通过;旧 20260724 补丁仍按历史层级通过契约,不发生文件改动。 + +- [ ] **Step 5:提交初始化器和对应契约** + +从仓库根目录执行: + + git add -- \ + shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \ + shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \ + shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql + git commit --only -m '调整:提升业务菜单为一级目录' -- \ + shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \ + shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \ + shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql + +提交后确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区。 + +## Task 2:新增幂等、fail-closed 的 MySQL 前向补丁 + +**Files:** + +- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +- Create: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql + +- [ ] **Step 1:先写新补丁的失败契约** + +在 SqlContractTest 中新增: + + def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None: + self.assertTrue( + MENU_PROMOTION_PATCH_PATH.is_file(), + f'missing patch: {MENU_PROMOTION_PATCH_PATH}', + ) + raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8') + self.assertIn('备份', raw) + self.assertIn('未自动执行', raw) + self.assertIn('可幂等重跑', raw) + self.assertIn('禁止使用 mysql --force', raw) + + statements = _sql_statements(raw) + normalized = [_normalized_sql_statement(statement) for statement in statements] + expected_table = 'promote_business_menus_expected' + guard_table = 'promote_business_menus_guard' + + expected_insert_indexes = [ + index + for index, statement in enumerate(normalized) + if statement.startswith(f'insert into {expected_table} ') + ] + self.assertEqual(1, len(expected_insert_indexes)) + expected_rows = _insert_rows( + statements[expected_insert_indexes[0]], + expected_table, + ) + self.assertEqual(6, len(expected_rows)) + actual_expected_rows = { + int(row[0]): tuple(row[1:]) + for row in expected_rows + } + self.assertEqual( + { + 1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'), + 2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'), + 3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'), + 121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '10', '0', '1'), + 122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '11', '0', '2'), + 123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '12', '0', '3'), + }, + actual_expected_rows, + ) + + starts = [ + index + for index, statement in enumerate(normalized) + if statement == 'start transaction' + ] + commits = [ + index + for index, statement in enumerate(normalized) + if statement == 'commit' + ] + self.assertEqual(1, len(starts)) + self.assertEqual(1, len(commits)) + start_transaction = starts[0] + commit = commits[0] + + row_locks = [ + index + for index, statement in enumerate(normalized) + if re.fullmatch( + r'select menu_id from sys_menu ' + r'where menu_id in \(1, 2, 3, 121, 122, 123\) ' + r'order by menu_id for update', + statement, + ) + ] + self.assertEqual(1, len(row_locks)) + + guard_indexes = [ + index + for index, statement in enumerate(normalized) + if statement.startswith(f'insert into {guard_table} ') + ] + self.assertEqual(2, len(guard_indexes)) + precondition_guard = normalized[guard_indexes[0]] + postcondition_guard = normalized[guard_indexes[1]] + self.assertIn('as precondition_guard_rows', precondition_guard) + self.assertIn('as postcondition_guard_rows', postcondition_guard) + self.assertEqual( + 2, + precondition_guard.count( + "'promote-business-menus-precondition-conflict'" + ), + ) + self.assertEqual( + 2, + postcondition_guard.count( + "'promote-business-menus-postcondition-conflict'" + ), + ) + + identity_columns = ( + ('menu_name', 'menu_name'), + ('path', 'path'), + ('component', 'component'), + ('query', 'query_value'), + ('route_name', 'route_name'), + ('is_frame', 'is_frame'), + ('is_cache', 'is_cache'), + ('menu_type', 'menu_type'), + ('visible', 'visible'), + ('status', 'status'), + ('perms', 'perms'), + ('icon', 'icon'), + ) + for actual_column, expected_column in identity_columns: + comparison = ( + f'actual.{actual_column} <=> expected.{expected_column}' + ) + self.assertIn(comparison, precondition_guard) + self.assertIn(comparison, postcondition_guard) + + compact_precondition = re.sub(r'\s+', '', precondition_guard) + self.assertIn('count(actual.menu_id)=6', compact_precondition) + self.assertIn( + 'sum((actual.parent_id<=>expected.old_parent_id)' + 'and(actual.order_num<=>expected.old_order_num))=6', + compact_precondition, + ) + self.assertIn( + 'sum((actual.parent_id<=>expected.new_parent_id)' + 'and(actual.order_num<=>expected.new_order_num))=6', + compact_precondition, + ) + compact_postcondition = re.sub(r'\s+', '', postcondition_guard) + self.assertIn('count(actual.menu_id)=6', compact_postcondition) + self.assertIn( + 'sum((actual.parent_id<=>expected.new_parent_id)' + 'and(actual.order_num<=>expected.new_order_num))=6', + compact_postcondition, + ) + + expected_update = ( + 'update sys_menu set parent_id = case ' + 'when menu_id in (121, 122, 123) then 0 ' + 'else parent_id end, order_num = case menu_id ' + 'when 121 then 1 when 122 then 2 when 123 then 3 ' + 'when 1 then 4 when 2 then 5 when 3 then 6 ' + 'else order_num end ' + 'where menu_id in (1, 2, 3, 121, 122, 123)' + ) + update_indexes = [ + index + for index, statement in enumerate(normalized) + if statement == expected_update + ] + self.assertEqual(1, len(update_indexes)) + update_index = update_indexes[0] + + permanent_mutations = [ + statement + for statement in normalized + if re.match( + r'(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_', + statement, + ) + ] + self.assertEqual([expected_update], permanent_mutations) + self.assertEqual( + ( + start_transaction, + row_locks[0], + guard_indexes[0], + update_index, + guard_indexes[1], + commit, + ), + tuple( + sorted( + ( + start_transaction, + row_locks[0], + guard_indexes[0], + update_index, + guard_indexes[1], + commit, + ) + ) + ), + ) + + expected_verification = ( + 'select menu_id, menu_name, parent_id, order_num, path, component, ' + 'menu_type, visible, status, perms from sys_menu ' + 'where menu_id in (1, 2, 3, 121, 122, 123) ' + 'order by order_num, menu_id' + ) + self.assertEqual(expected_verification, normalized[-1]) + +- [ ] **Step 2:运行新补丁契约,确认 RED** + +工作目录:shierfeng-fastapi-backend + + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \ + tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \ + -v + +预期:FAIL,错误指出缺少 20260727_promote_business_menus_mysql.sql。 + +- [ ] **Step 3:创建最小 MySQL 前向补丁** + +创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql,内容如下: + + -- 将托盘业务目录 121/122/123 提升为一级菜单,并将系统菜单 1/2/3 后移。 + -- 本补丁只更新 sys_menu.parent_id 和 sys_menu.order_num。 + -- 本文件未自动执行;执行前必须备份目标数据库。 + -- + -- 必须在独立连接中使用标准 mysql batch 执行,并确保客户端遇错立即退出。 + -- 禁止使用 mysql --force;guard 的 Duplicate entry 是故意设计的 fail-closed 错误, + -- 不能忽略错误继续执行到 commit。 + -- + -- 允许的写前状态只有两种: + -- 1. 20260724 旧状态; + -- 2. 本补丁目标状态,因此可幂等重跑。 + -- 缺行、固定 ID 身份冲突或新旧状态混杂都会在永久更新前中止。 + + drop temporary table if exists promote_business_menus_expected; + create temporary table promote_business_menus_expected ( + menu_id bigint not null, + menu_name varchar(50) not null, + path varchar(200), + component varchar(255), + query_value varchar(255), + route_name varchar(50), + is_frame int, + is_cache int, + menu_type char(1), + visible char(1), + status char(1), + perms varchar(100), + icon varchar(100), + old_parent_id bigint not null, + old_order_num int not null, + new_parent_id bigint not null, + new_order_num int not null, + primary key (menu_id) + ) engine=innodb; + + insert into promote_business_menus_expected ( + menu_id, menu_name, path, component, query_value, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + old_parent_id, old_order_num, new_parent_id, new_order_num + ) values + (1, '系统管理', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 0, 1, 0, 4), + (2, '系统监控', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 0, 2, 0, 5), + (3, '系统工具', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 0, 3, 0, 6), + (121, '基础资料维护', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 10, 0, 1), + (122, '业务管理', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 11, 0, 2), + (123, '报表查询', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 12, 0, 3); + + drop temporary table if exists promote_business_menus_guard; + create temporary table promote_business_menus_guard ( + guard_token varchar(64) not null, + primary key (guard_token) + ) engine=innodb; + + start transaction; + + -- 锁定目标行,防止身份校验与更新之间被并发修改。 + select menu_id + from sys_menu + where menu_id in (1, 2, 3, 121, 122, 123) + order by menu_id + for update; + + -- 写前身份和整体状态校验。失败时重复 token 触发主键冲突并终止 batch。 + insert into promote_business_menus_guard (guard_token) + select guard_token + from ( + select 'promote-business-menus-precondition-conflict' as guard_token + union all + select if( + count(actual.menu_id) = 6 + and sum( + (actual.menu_name <=> expected.menu_name) + and (actual.path <=> expected.path) + and (actual.component <=> expected.component) + and (actual.query <=> expected.query_value) + and (actual.route_name <=> expected.route_name) + and (actual.is_frame <=> expected.is_frame) + and (actual.is_cache <=> expected.is_cache) + and (actual.menu_type <=> expected.menu_type) + and (actual.visible <=> expected.visible) + and (actual.status <=> expected.status) + and (actual.perms <=> expected.perms) + and (actual.icon <=> expected.icon) + ) = 6 + and ( + sum( + (actual.parent_id <=> expected.old_parent_id) + and (actual.order_num <=> expected.old_order_num) + ) = 6 + or sum( + (actual.parent_id <=> expected.new_parent_id) + and (actual.order_num <=> expected.new_order_num) + ) = 6 + ), + 'promote-business-menus-precondition-verified', + 'promote-business-menus-precondition-conflict' + ) as guard_token + from promote_business_menus_expected as expected + left join sys_menu as actual + on actual.menu_id = expected.menu_id + ) as precondition_guard_rows; + + -- 永久变更只涉及 parent_id 和 order_num。 + update sys_menu + set parent_id = case + when menu_id in (121, 122, 123) then 0 + else parent_id + end, + order_num = case menu_id + when 121 then 1 + when 122 then 2 + when 123 then 3 + when 1 then 4 + when 2 then 5 + when 3 then 6 + else order_num + end + where menu_id in (1, 2, 3, 121, 122, 123); + + -- 提交前再次校验目标状态,捕获未覆盖行或触发器造成的意外变化。 + insert into promote_business_menus_guard (guard_token) + select guard_token + from ( + select 'promote-business-menus-postcondition-conflict' as guard_token + union all + select if( + count(actual.menu_id) = 6 + and sum( + (actual.menu_name <=> expected.menu_name) + and (actual.path <=> expected.path) + and (actual.component <=> expected.component) + and (actual.query <=> expected.query_value) + and (actual.route_name <=> expected.route_name) + and (actual.is_frame <=> expected.is_frame) + and (actual.is_cache <=> expected.is_cache) + and (actual.menu_type <=> expected.menu_type) + and (actual.visible <=> expected.visible) + and (actual.status <=> expected.status) + and (actual.perms <=> expected.perms) + and (actual.icon <=> expected.icon) + ) = 6 + and sum( + (actual.parent_id <=> expected.new_parent_id) + and (actual.order_num <=> expected.new_order_num) + ) = 6, + 'promote-business-menus-postcondition-verified', + 'promote-business-menus-postcondition-conflict' + ) as guard_token + from promote_business_menus_expected as expected + left join sys_menu as actual + on actual.menu_id = expected.menu_id + ) as postcondition_guard_rows; + + commit; + + drop temporary table promote_business_menus_guard; + drop temporary table promote_business_menus_expected; + + -- 固定 ID 事后核对:顺序应为 121、122、123、1、2、3。 + select menu_id, menu_name, parent_id, order_num, path, component, + menu_type, visible, status, perms + from sys_menu + where menu_id in (1, 2, 3, 121, 122, 123) + order by order_num, menu_id; + +- [ ] **Step 4:运行新补丁契约与完整 SQL 契约** + +工作目录:shierfeng-fastapi-backend + + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \ + tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \ + -v + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \ + tests.module_business.test_sql_contract \ + -v + conda run -n ShiErFeng python -m ruff format --check \ + tests/module_business/test_sql_contract.py + conda run -n ShiErFeng python -m ruff check \ + tests/module_business/test_sql_contract.py + +预期:新补丁定向契约通过,完整 SQL 契约全部通过,Ruff format/check 均为成功退出。 + +- [ ] **Step 5:提交新补丁和对应契约** + +从仓库根目录执行: + + git add -- \ + shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \ + shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql + git commit --only -m '迁移:新增业务菜单层级补丁' -- \ + shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \ + shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql + +提交后再次确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区,APP 和环境文件指纹未变化。 + +## Task 3:完成代码级回归验证 + +**Files:** + +- Verify only: shierfeng-fastapi-backend +- Verify only: shierfeng-fastapi-frontend + +- [ ] **Step 1:运行后端业务测试和 SQL 契约** + +工作目录:shierfeng-fastapi-backend + + PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest discover \ + -s tests/module_business -p 'test_*.py' -v + +预期:全部业务测试通过,不生成新的 Python 字节码。 + +- [ ] **Step 2:运行后端格式和静态检查** + +工作目录:shierfeng-fastapi-backend + + conda run -n ShiErFeng python -m ruff format --check \ + module_business tests/module_business + conda run -n ShiErFeng python -m ruff check \ + module_business tests/module_business + +预期:两条命令均成功退出,无格式或 lint 错误。 + +- [ ] **Step 3:运行 Web 契约与生产构建** + +工作目录:shierfeng-fastapi-frontend + + /Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \ + --test scripts/verify-business-contract.mjs + /Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \ + /Users/jiaolongyan/.nvm/versions/node/v20.20.2/lib/node_modules/npm/bin/npm-cli.js \ + run build:prod + +预期:Node 业务契约全部通过,Vite 生产构建成功。构建会读取用户已有的前端环境文件,但本任务不得修改或提交这些文件。 + +- [ ] **Step 4:检查 diff、提交边界和受保护改动** + +从仓库根目录执行: + + git diff --check + git status --short --branch + git diff --cached --name-status + +预期:git diff --check 无输出;本任务代码已按路径提交;.idea/git_toolbox_prj.xml 仍保持用户原有暂存状态;APP、环境文件、缓存、日志和构建产物未被纳入提交。 + +## Task 4:经再次确认后迁移本地 MySQL 并验收菜单 + +**Files:** + +- Read configuration: shierfeng-fastapi-backend/.env.dev +- Execute: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql +- Backup output pattern: /private/tmp/shierfeng-fastapi-before-promote-business-menus-YYYYMMDD-HHMMSS.sql + +- [ ] **Step 1:停止并取得数据库写入确认** + +向用户列出:目标数据库来自 .env.dev、将创建新的全库备份、将执行的补丁文件、只更新 sys_menu 六行的 parent_id/order_num、禁止 mysql --force。没有本次明确确认时不得执行后续步骤。 + +- [ ] **Step 2:确认后创建新的数据库备份并执行补丁** + +工作目录:shierfeng-fastapi-backend。以下命令不打印密码,且 set -e 保证备份或补丁遇错立即退出: + + set -euo pipefail + task_db_type=$(sed -nE "s/^[[:space:]]*DB_TYPE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev) + task_db_host=$(sed -nE "s/^[[:space:]]*DB_HOST[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev) + task_db_port=$(sed -nE "s/^[[:space:]]*DB_PORT[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p" .env.dev) + task_db_user=$(sed -nE "s/^[[:space:]]*DB_USERNAME[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev) + task_db_password=$(sed -nE "s/^[[:space:]]*DB_PASSWORD[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev) + task_db_name=$(sed -nE "s/^[[:space:]]*DB_DATABASE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev) + test "$task_db_type" = 'mysql' + test -n "$task_db_host" + test -n "$task_db_port" + test -n "$task_db_user" + test -n "$task_db_password" + test -n "$task_db_name" + task_backup_path="/private/tmp/shierfeng-fastapi-before-promote-business-menus-$(date +%Y%m%d-%H%M%S).sql" + MYSQL_PWD="$task_db_password" mysqldump \ + --host="$task_db_host" \ + --port="$task_db_port" \ + --user="$task_db_user" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --no-tablespaces \ + "$task_db_name" > "$task_backup_path" + test -s "$task_backup_path" + shasum -a 256 "$task_backup_path" + MYSQL_PWD="$task_db_password" mysql \ + --host="$task_db_host" \ + --port="$task_db_port" \ + --user="$task_db_user" \ + --database="$task_db_name" \ + < sql/patches/20260727_promote_business_menus_mysql.sql + unset task_db_password + +预期:生成新的非空备份并输出其 SHA-256;mysql 使用独立连接、未带 --force;补丁最后输出六个目标菜单,parent_id 均为 0,order_num 依次为 1、2、3、4、5、6。 + +- [ ] **Step 3:只读核对数据库菜单和不变量** + +继续使用 .env.dev 解析出的连接参数,在新的 mysql 连接执行: + + select menu_id, menu_name, parent_id, order_num, path, component, + menu_type, visible, status, perms + from sys_menu + where menu_id in (1, 2, 3, 121, 122, 123) + order by order_num, menu_id; + + select parent_id, count(*) as child_count + from sys_menu + where parent_id in (121, 122, 123) + group by parent_id + order by parent_id; + + select count(*) as business_permission_count + from sys_menu + where perms regexp '^(master|business|report):'; + +预期:六个根目录顺序为 121、122、123、1、2、3;121/122/123 的直属子菜单数为 6、5、6;业务权限菜单总数为 61。 + +- [ ] **Step 4:重新登录并核对 /getRouters 与 Web 侧边栏** + +保持现有前后端启动方式不变,退出 Web 登录后重新登录,使现有登录缓存刷新。确认 /getRouters 返回的根节点依次包含“基础资料维护”“业务管理”“报表查询”“系统管理”“系统监控”“系统工具”,三个业务目录不再出现在“系统管理”的 children 中;侧边栏顺序与返回结果一致。不得执行 Redis FLUSHALL,也不修改 APP。 + +## 最终验收 + +- 新建 MySQL/PostgreSQL 数据库直接得到目标一级菜单层级。 +- 已有 MySQL 数据库只能从完整旧状态或完整目标状态执行 20260727 补丁;缺行、ID 复用、身份漂移和新旧状态混杂均在永久更新前失败。 +- 永久 DML 只更新六个 sys_menu 行的 parent_id/order_num,并在提交前复核目标状态。 +- 20260724 历史补丁、子菜单、API、权限标识、角色关联、Web 动态路由机制与 APP 均保持不变。 +- 后端业务测试、SQL 契约、Ruff、Web 业务契约和生产构建均通过。