775 lines
33 KiB
Markdown
775 lines
33 KiB
Markdown
# 业务菜单一级展示 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 业务契约和生产构建均通过。
|