迁移:新增业务菜单层级补丁
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user