From 9b5f479f0dfcd7a1802bbc49a6b3512aa2e1db06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Mon, 27 Jul 2026 14:24:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20MySQL=20=E8=8F=9C=E5=8D=95=E5=9B=BA=E5=AE=9AID=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260724_pallet_workflow_mysql.sql | 51 +++++- .../module_business/test_sql_contract.py | 146 +++++++++++++++++- 2 files changed, 191 insertions(+), 6 deletions(-) diff --git a/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql b/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql index cc26429..4fe36ee 100644 --- a/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql +++ b/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql @@ -419,8 +419,18 @@ where dict_id = 12 delete from sys_menu where menu_id = 99; --- INSERT IGNORE 使明确 ID 的菜单 seed 可重复应用而不会生成重复记录。 -insert ignore into sys_menu values +-- 保存当前会话模式,再无空项、无重复地启用 STRICT_ALL_TABLES,确保菜单冲突按错误中止。 +set @pallet_workflow_original_sql_mode = @@session.sql_mode; +set session sql_mode = concat_ws(',', + nullif(@pallet_workflow_original_sql_mode, ''), + if(find_in_set('STRICT_ALL_TABLES', @pallet_workflow_original_sql_mode) = 0, + 'STRICT_ALL_TABLES', + null)); + +-- 固定 ID 已存在时,只有全部稳定字段一致才允许幂等空操作。 +-- 任一稳定字段冲突都会令 menu_name 为 NULL,触发 menu_name NOT NULL 约束并中止整条语句。 +-- 必须人工解决冲突;本补丁不会静默覆盖未知 ID。 +insert into sys_menu values (121, '基础资料维护', 1, '10', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, ''), (122, '业务管理', 1, '11', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, ''), (123, '报表查询', 1, '12', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, ''), @@ -484,6 +494,41 @@ insert ignore into sys_menu values (1105, '使用新增', 511, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:add', '#', 'admin', sysdate(), '', null, ''), (1106, '退回查询', 512, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:query', '#', 'admin', sysdate(), '', null, ''), (1107, '退回申请', 512, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:request', '#', 'admin', sysdate(), '', null, ''), -(1108, '退回确认', 512, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:confirm', '#', 'admin', sysdate(), '', null, ''); +(1108, '退回确认', 512, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:confirm', '#', 'admin', sysdate(), '', null, '') +on duplicate key update + menu_name = if( + menu_name <=> values(menu_name) + and parent_id <=> values(parent_id) + and order_num <=> values(order_num) + and path <=> values(path) + and component <=> values(component) + and query <=> values(query) + and route_name <=> values(route_name) + and is_frame <=> values(is_frame) + and is_cache <=> values(is_cache) + and menu_type <=> values(menu_type) + and visible <=> values(visible) + and status <=> values(status) + and perms <=> values(perms) + and icon <=> values(icon), + menu_name, + null + ); + +-- seed 成功后立即恢复原会话模式;后续仅执行只读人工核对,无需继续保持严格模式。 +set session sql_mode = @pallet_workflow_original_sql_mode; + +-- 人工核对业务菜单固定 ID;查询结果必须为 64 行,且各字段应与本补丁 seed 一致。 +select menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon +from sys_menu +where menu_id in ( + 121, 122, 123, + 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, + 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1072, 1073, 1074, 1075, + 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, + 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, + 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108 +); -- 应用完成后仅人工清理 /getRouters、用户信息以及 sys_dict:ai_provider_type 相关缓存;本补丁不执行缓存命令。 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 358a283..d094249 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -409,6 +409,29 @@ EXPECTED_BUTTONS = { ) } BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109)) +MYSQL_MENU_STABLE_COLUMNS = ( + 'menu_name', + 'parent_id', + 'order_num', + 'path', + 'component', + 'query', + 'route_name', + 'is_frame', + 'is_cache', + 'menu_type', + 'visible', + 'status', + 'perms', + 'icon', +) +MYSQL_MENU_SQL_MODE_SAVE = 'set @pallet_workflow_original_sql_mode = @@session.sql_mode' +MYSQL_MENU_SQL_MODE_ENFORCE = ( + "set session sql_mode = concat_ws(',', nullif(@pallet_workflow_original_sql_mode, ''), " + "if(find_in_set('strict_all_tables', @pallet_workflow_original_sql_mode) = 0, " + "'strict_all_tables', null))" +) +MYSQL_MENU_SQL_MODE_RESTORE = 'set session sql_mode = @pallet_workflow_original_sql_mode' def _strip_sql_comments(sql: str) -> str: @@ -481,6 +504,36 @@ def _sql_statements(sql: str) -> list[str]: return [statement.strip() for statement in _strip_sql_comments(sql).split(';') if statement.strip()] +def _normalized_sql_statement(statement: str) -> str: + return re.sub(r'\s+', ' ', statement.replace('`', '')).strip().lower() + + +def _mysql_menu_seed_has_safe_sql_mode_order(statements: list[str]) -> bool: + normalized = [_normalized_sql_statement(statement) for statement in statements] + predicates = ( + lambda statement: statement == MYSQL_MENU_SQL_MODE_SAVE, + lambda statement: statement == MYSQL_MENU_SQL_MODE_ENFORCE, + lambda statement: ( + statement.startswith('insert into sys_menu values ') and ' on duplicate key update ' in statement + ), + lambda statement: statement == MYSQL_MENU_SQL_MODE_RESTORE, + lambda statement: ( + re.fullmatch( + r'select\s+.+\s+from\s+sys_menu\s+where\s+menu_id\s+in\s*\([^)]+\)', + statement, + ) + is not None + ), + ) + positions = [] + for predicate in predicates: + matches = [index for index, statement in enumerate(normalized) if predicate(statement)] + if len(matches) != 1: + return False + positions.append(matches[0]) + return positions == sorted(positions) and positions[-1] == len(statements) - 1 + + def _insert_rows(sql: str, table: str) -> list[list[str | None]]: rows = [] pattern = re.compile( @@ -730,7 +783,7 @@ class SqlContractTest(unittest.TestCase): def test_mysql_and_postgresql_business_menu_fields_are_identical(self) -> None: mysql_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8')) postgres_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[1]).read_text(encoding='utf-8')) - compared_indexes = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) + compared_indexes = tuple(range(15)) mysql_contract = { menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in mysql_rows.items() } @@ -863,12 +916,13 @@ class SqlContractTest(unittest.TestCase): self.assertEqual(EXPECTED_CHECKS[table], checks) patch_rows = _business_menu_rows(raw) mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) - compared_indexes = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) + compared_indexes = tuple(range(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()}, ) - self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b|\binsert\s+ignore\s+into\s+sys_menu\b') + self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b') + self.assertNotRegex(lower, r'\binsert\s+ignore\b') self.assertNotRegex(_strip_sql_comments(lower), r'\binsert(?:\s+ignore)?\s+into\s+`?sys_role_menu`?\b') self.assertIn('/getRouters', raw) self.assertIn('用户信息', raw) @@ -878,6 +932,92 @@ class SqlContractTest(unittest.TestCase): self.assertNotIn('sys_dict:ai_provider_type', executable) self.assertNotRegex(lower, r'\bbigserial\b|\balter\s+sequence\b|\bcomment\s+on\b') + def test_patch_menu_seed_fails_closed_on_fixed_id_conflicts(self) -> None: + raw = PATCH_PATH.read_text(encoding='utf-8') + executable = _strip_sql_comments(raw).replace('`', '').lower() + self.assertNotRegex(executable, r'\binsert\s+ignore\b') + + statements = _sql_statements(raw) + self.assertTrue(_mysql_menu_seed_has_safe_sql_mode_order(statements)) + menu_insert_statements = [ + statement.replace('`', '').lower() + for statement in statements + if re.match(r'\s*insert(?:\s+ignore)?\s+into\s+`?sys_menu`?\b', statement, flags=re.I) + ] + self.assertEqual(1, len(menu_insert_statements)) + menu_insert_statement = menu_insert_statements[0] + menu_insert = re.fullmatch( + r'\s*insert\s+into\s+sys_menu\s+values\s*.*?' + r'\bon\s+duplicate\s+key\s+update\s+' + r'menu_name\s*=\s*if\s*\(\s*(?P.*?)\s*,\s*menu_name\s*,\s*null\s*\)\s*', + menu_insert_statement, + flags=re.S, + ) + self.assertIsNotNone(menu_insert, 'sys_menu seed must use a fail-closed upsert') + self.assertEqual(len(BUSINESS_MENU_IDS), len(_insert_rows(menu_insert_statement, 'sys_menu'))) + expected_guard = ' and '.join(f'{column} <=> values({column})' for column in MYSQL_MENU_STABLE_COLUMNS) + actual_guard = re.sub(r'\s+', ' ', menu_insert.group('guard')).strip() + self.assertEqual(expected_guard, actual_guard) + + self.assertIn('任一稳定字段冲突', raw) + self.assertIn('menu_name NOT NULL', raw) + self.assertIn('中止整条语句', raw) + self.assertIn('人工解决冲突', raw) + self.assertIn('不会静默覆盖未知 ID', raw) + + verification_statement = statements[-1].replace('`', '').lower() + verification = re.fullmatch( + r'\s*select\s+(?P.*?)\s+from\s+sys_menu\s+' + r'where\s+menu_id\s+in\s*\((?P[^)]+)\)\s*', + verification_statement, + flags=re.S, + ) + self.assertIsNotNone(verification, 'patch must end with a manual business menu verification query') + verification_columns = tuple(column.strip() for column in verification.group('columns').split(',')) + self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), verification_columns) + verification_ids = [int(value.strip()) for value in verification.group('ids').split(',')] + self.assertEqual(len(BUSINESS_MENU_IDS), len(verification_ids)) + self.assertEqual(BUSINESS_MENU_IDS, set(verification_ids)) + self.assertRegex(raw, r'查询结果必须为\s*64') + + normalized = [_normalized_sql_statement(statement) for statement in statements] + marker_indexes = { + 'save': normalized.index(MYSQL_MENU_SQL_MODE_SAVE), + 'enforce': normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE), + 'insert': next( + index + for index, statement in enumerate(normalized) + if statement.startswith('insert into sys_menu values ') + ), + 'restore': normalized.index(MYSQL_MENU_SQL_MODE_RESTORE), + 'verification': len(statements) - 1, + } + mutations = { + f'missing_{name}': [ + statement for index, statement in enumerate(statements) if index != marker_indexes[name] + ] + for name in ('save', 'enforce', 'restore') + } + strict_after_insert = statements.copy() + strict_after_insert[marker_indexes['enforce']], strict_after_insert[marker_indexes['insert']] = ( + strict_after_insert[marker_indexes['insert']], + strict_after_insert[marker_indexes['enforce']], + ) + mutations['strict_after_insert'] = strict_after_insert + restore_after_verification = statements.copy() + ( + restore_after_verification[marker_indexes['restore']], + restore_after_verification[marker_indexes['verification']], + ) = ( + restore_after_verification[marker_indexes['verification']], + restore_after_verification[marker_indexes['restore']], + ) + mutations['restore_after_verification'] = restore_after_verification + + for name, mutation in mutations.items(): + with self.subTest(mutation=name): + self.assertFalse(_mysql_menu_seed_has_safe_sql_mode_order(mutation)) + def test_patch_cleanup_is_explicit_and_ordered(self) -> None: self.assertTrue(PATCH_PATH.is_file(), f'missing patch: {PATCH_PATH}') sql = _strip_sql_comments(PATCH_PATH.read_text(encoding='utf-8')).replace('`', '').lower()