Files
ShiErFeng/docs/superpowers/plans/2026-07-27-promote-business-menus.md
T

33 KiB
Raw Blame History

业务菜单一级展示 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||<NULL>
122|0|业务管理|businessOperation||2||<NULL>
123|0|报表查询|businessReport||3||<NULL>
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 业务契约和生产构建均通过。