diff --git a/shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql b/shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql new file mode 100644 index 0000000..c9b2ea1 --- /dev/null +++ b/shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql @@ -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; diff --git a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py index 9503d6d..71a02bf 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -1,6 +1,7 @@ import re import unittest from collections import Counter +from collections.abc import Callable from pathlib import Path SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' @@ -1152,6 +1153,257 @@ class SqlContractTest(unittest.TestCase): self.assertEqual(sorted(drop_positions), drop_positions) 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: self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') raw = PATCH_PATH.read_text(encoding='utf-8').lower()