迁移:新增业务菜单层级补丁

This commit is contained in:
焦龙言
2026-07-27 21:02:48 +08:00
parent db5755586d
commit a1613f8780
2 changed files with 408 additions and 0 deletions
@@ -0,0 +1,156 @@
-- 本文件是人工执行的 MySQL 前向补丁,未自动执行。
-- 执行前备份数据库,并在维护窗口使用独立连接运行标准 mysql batch。
-- 旧状态和目标状态均可通过写前校验,因此本补丁可幂等重跑。
-- 标准 mysql batch 遇错后必须退出并关闭连接,使未提交事务回滚;禁止使用 mysql --force。
-- 在事务外准备六个固定菜单的稳定身份及完整旧/新层级状态。
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 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, 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;
-- 锁定六个固定 ID,防止身份预检与永久更新之间被并发修改。
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(
(binary actual.menu_name <=> binary expected.menu_name)
and (binary actual.path <=> binary expected.path)
and (binary actual.component <=> binary expected.component)
and (binary actual.query <=> binary expected.query)
and (binary actual.route_name <=> binary expected.route_name)
and (actual.is_frame <=> expected.is_frame)
and (actual.is_cache <=> expected.is_cache)
and (binary actual.menu_type <=> binary expected.menu_type)
and (binary actual.visible <=> binary expected.visible)
and (binary actual.status <=> binary expected.status)
and (binary actual.perms <=> binary expected.perms)
and (binary actual.icon <=> binary 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;
-- 唯一永久 DML:只更新六个固定菜单的父级和显示顺序。
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(
(binary actual.menu_name <=> binary expected.menu_name)
and (binary actual.path <=> binary expected.path)
and (binary actual.component <=> binary expected.component)
and (binary actual.query <=> binary expected.query)
and (binary actual.route_name <=> binary expected.route_name)
and (actual.is_frame <=> expected.is_frame)
and (actual.is_cache <=> expected.is_cache)
and (binary actual.menu_type <=> binary expected.menu_type)
and (binary actual.visible <=> binary expected.visible)
and (binary actual.status <=> binary expected.status)
and (binary actual.perms <=> binary expected.perms)
and (binary actual.icon <=> binary 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;
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;
@@ -1,6 +1,7 @@
import re import re
import unittest import unittest
from collections import Counter from collections import Counter
from collections.abc import Callable
from pathlib import Path from pathlib import Path
SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql'
@@ -1152,6 +1153,257 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(sorted(drop_positions), drop_positions) self.assertEqual(sorted(drop_positions), drop_positions)
self.assertLess(drop_positions[-1], sql.index('drop table if exists sys_user')) self.assertLess(drop_positions[-1], sql.index('drop table if exists sys_user'))
def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None: # noqa: PLR0915
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')
executable = _strip_sql_comments(raw).replace('`', '')
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)]
for required_note in ('未自动执行', '执行前备份', '可幂等重跑', '禁止使用 mysql --force', '独立连接'):
self.assertIn(required_note, raw)
self.assertNotIn('--force', executable.lower())
self.assertNotIn('continue-on-error', raw.lower())
expected_rows = [
('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',
),
]
self.assertEqual(expected_rows, [tuple(row) for row in _insert_rows(raw, 'promote_business_menus_expected')])
def unique_index(predicate: Callable[[str], bool], label: str) -> int:
matches = [index for index, statement in enumerate(normalized) if predicate(statement)]
self.assertEqual(1, len(matches), label)
return matches[0]
expected_columns = (
'menu_id',
'menu_name',
'path',
'component',
'query',
'route_name',
'is_frame',
'is_cache',
'menu_type',
'visible',
'status',
'perms',
'icon',
'old_parent_id',
'old_order_num',
'new_parent_id',
'new_order_num',
)
expected_create_index = unique_index(
lambda statement: statement.startswith('create temporary table promote_business_menus_expected '),
'expected table creation',
)
expected_insert_index = unique_index(
lambda statement: statement.startswith('insert into promote_business_menus_expected '),
'expected row insertion',
)
expected_create = normalized[expected_create_index]
for column in expected_columns:
self.assertRegex(expected_create, rf'\b{column}\b')
guard_create_index = unique_index(
lambda statement: statement.startswith('create temporary table promote_business_menus_guard '),
'guard table creation',
)
self.assertIn('primary key (guard_token)', normalized[guard_create_index])
start_indexes = [index for index, statement in enumerate(normalized) if statement == 'start transaction']
commit_indexes = [index for index, statement in enumerate(normalized) if statement == 'commit']
self.assertEqual(1, len(start_indexes))
self.assertEqual(1, len(commit_indexes))
start_index = start_indexes[0]
commit_index = commit_indexes[0]
initial_cleanup_indexes = (
unique_index(
lambda statement: statement == 'drop temporary table if exists promote_business_menus_expected',
'expected table initial cleanup',
),
unique_index(
lambda statement: statement == 'drop temporary table if exists promote_business_menus_guard',
'guard table initial cleanup',
),
)
self.assertTrue(
all(
index < start_index
for index in (
*initial_cleanup_indexes,
expected_create_index,
expected_insert_index,
guard_create_index,
)
)
)
lock_statement = (
'select menu_id from sys_menu where menu_id in (1, 2, 3, 121, 122, 123) order by menu_id for update'
)
lock_index = unique_index(lambda statement: statement == lock_statement, 'six-row locking read')
guard_indexes = [
index
for index, statement in enumerate(normalized)
if statement.startswith('insert into promote_business_menus_guard ')
]
self.assertEqual(2, len(guard_indexes))
pre_guard_index, post_guard_index = guard_indexes
pre_guard = normalized[pre_guard_index]
post_guard = normalized[post_guard_index]
for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')):
conflict_token = f"'promote-business-menus-{phase}-conflict'"
self.assertEqual(2, guard.count(conflict_token))
self.assertIn(f"'promote-business-menus-{phase}-verified'", guard)
self.assertIn('count(actual.menu_id) = 6', guard)
for field in (
'menu_name',
'path',
'component',
'query',
'route_name',
'menu_type',
'visible',
'status',
'perms',
'icon',
):
self.assertIn(f'binary actual.{field} <=> binary expected.{field}', guard)
for field in ('is_frame', 'is_cache'):
self.assertIn(f'actual.{field} <=> expected.{field}', guard)
old_state = (
r'sum\(\s*\(actual\.parent_id <=> expected\.old_parent_id\)\s*'
r'and\s*\(actual\.order_num <=> expected\.old_order_num\)\s*\)\s*=\s*6'
)
new_state = (
r'sum\(\s*\(actual\.parent_id <=> expected\.new_parent_id\)\s*'
r'and\s*\(actual\.order_num <=> expected\.new_order_num\)\s*\)\s*=\s*6'
)
self.assertRegex(pre_guard, old_state)
self.assertRegex(pre_guard, new_state)
self.assertNotIn('old_parent_id', post_guard)
self.assertNotIn('old_order_num', post_guard)
self.assertRegex(post_guard, new_state)
expected_update = _normalized_sql_statement(
"""
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_index = unique_index(lambda statement: statement == expected_update, 'exact permanent update')
permanent_dml = []
for statement in normalized:
if not re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement):
continue
if statement.startswith('insert into promote_business_menus_expected '):
continue
if statement.startswith('insert into promote_business_menus_guard '):
continue
permanent_dml.append(statement)
self.assertEqual([expected_update], permanent_dml)
self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b')
self.assertLess(start_index, lock_index)
self.assertLess(lock_index, pre_guard_index)
self.assertLess(pre_guard_index, update_index)
self.assertLess(update_index, post_guard_index)
self.assertLess(post_guard_index, commit_index)
guard_drop_index = unique_index(
lambda statement: statement == 'drop temporary table promote_business_menus_guard',
'guard table final cleanup',
)
expected_drop_index = unique_index(
lambda statement: statement == 'drop temporary table promote_business_menus_expected',
'expected table final cleanup',
)
self.assertLess(commit_index, guard_drop_index)
self.assertLess(commit_index, expected_drop_index)
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])
self.assertLess(guard_drop_index, len(normalized) - 1)
self.assertLess(expected_drop_index, len(normalized) - 1)
def test_patch_exists_and_preserves_ai_business_tables(self) -> None: def test_patch_exists_and_preserves_ai_business_tables(self) -> None:
self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}')
raw = PATCH_PATH.read_text(encoding='utf-8').lower() raw = PATCH_PATH.read_text(encoding='utf-8').lower()