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 2750bcd..4eb4082 100644 --- a/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql +++ b/shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql @@ -496,6 +496,144 @@ from ( drop temporary table pallet_workflow_legacy_seed_guard; +-- 在任何永久 DML 前预检 64 个业务菜单固定 ID。ID 不存在时允许继续;已存在时, +-- 只有全部稳定字段与期望 seed 一致才允许重跑。冲突会触发临时表主键错误并立即中止。 +drop temporary table if exists pallet_workflow_menu_seed_expected; +create temporary table pallet_workflow_menu_seed_expected ( + menu_id bigint(20) not null, + menu_name varchar(50) not null, + parent_id bigint(20) not null, + order_num int 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), + primary key (menu_id) +); + +insert into pallet_workflow_menu_seed_expected ( + menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon +) values +(121, '基础资料维护', 1, '10', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#'), +(122, '业务管理', 1, '11', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#'), +(123, '报表查询', 1, '12', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#'), +(502, '菌种资料', 121, '1', 'strain', 'master/strain/index', '', '', 1, 0, 'C', '0', '0', 'master:strain:list', '#'), +(503, '客户资料', 121, '2', 'customer', 'master/customer/index', '', '', 1, 0, 'C', '0', '0', 'master:customer:list', '#'), +(504, '客户现场', 121, '3', 'site', 'master/site/index', '', '', 1, 0, 'C', '0', '0', 'master:site:list', '#'), +(505, '产线资料', 121, '4', 'line', 'master/line/index', '', '', 1, 0, 'C', '0', '0', 'master:line:list', '#'), +(506, '仓库资料', 121, '5', 'warehouse', 'master/warehouse/index', '', '', 1, 0, 'C', '0', '0', 'master:warehouse:list', '#'), +(507, '托盘资料', 121, '6', 'pallet', 'master/pallet/index', '', '', 1, 0, 'C', '0', '0', 'master:pallet:list', '#'), +(508, '仓库入库', 122, '1', 'inbound', 'business/inbound/index', '', '', 1, 0, 'C', '0', '0', 'business:inbound:list', '#'), +(509, '工厂装填', 122, '2', 'filling', 'business/filling/index', '', '', 1, 0, 'C', '0', '0', 'business:filling:list', '#'), +(510, '发货出库', 122, '3', 'shipment', 'business/shipment/index', '', '', 1, 0, 'C', '0', '0', 'business:shipment:list', '#'), +(511, '客户现场使用', 122, '4', 'usage', 'business/usage/index', '', '', 1, 0, 'C', '0', '0', 'business:usage:list', '#'), +(512, '退回仓库', 122, '5', 'return', 'business/return/index', '', '', 1, 0, 'C', '0', '0', 'business:return:list', '#'), +(513, '托盘库存', 123, '1', 'palletInventory', 'report/palletInventory/index', '', '', 1, 0, 'C', '0', '0', 'report:palletinventory:list', '#'), +(514, '装填记录', 123, '2', 'reportFilling', 'report/filling/index', '', '', 1, 0, 'C', '0', '0', 'report:filling:list', '#'), +(515, '发货出库', 123, '3', 'reportShipment', 'report/shipment/index', '', '', 1, 0, 'C', '0', '0', 'report:shipment:list', '#'), +(516, '产线消耗', 123, '4', 'consumption', 'report/consumption/index', '', '', 1, 0, 'C', '0', '0', 'report:consumption:list', '#'), +(517, '退回仓库', 123, '5', 'reportReturn', 'report/return/index', '', '', 1, 0, 'C', '0', '0', 'report:return:list', '#'), +(518, '托盘追溯', 123, '6', 'palletTrace', 'report/palletTrace/index', '', '', 1, 0, 'C', '0', '0', 'report:pallettrace:list', '#'), +(1065, '菌种查询', 502, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:query', '#'), +(1066, '菌种新增', 502, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:add', '#'), +(1067, '菌种修改', 502, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:edit', '#'), +(1068, '菌种删除', 502, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:strain:remove', '#'), +(1069, '客户查询', 503, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:query', '#'), +(1070, '客户新增', 503, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:add', '#'), +(1071, '客户修改', 503, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:edit', '#'), +(1072, '客户删除', 503, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:customer:remove', '#'), +(1073, '现场查询', 504, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:query', '#'), +(1074, '现场新增', 504, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:add', '#'), +(1075, '现场修改', 504, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:edit', '#'), +(1076, '现场删除', 504, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:remove', '#'), +(1077, '分配用户', 504, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:site:assign', '#'), +(1078, '产线查询', 505, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:query', '#'), +(1079, '产线新增', 505, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:add', '#'), +(1080, '产线修改', 505, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:edit', '#'), +(1081, '产线删除', 505, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:line:remove', '#'), +(1082, '仓库查询', 506, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:query', '#'), +(1083, '仓库新增', 506, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:add', '#'), +(1084, '仓库修改', 506, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:edit', '#'), +(1085, '仓库删除', 506, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:remove', '#'), +(1086, '分配用户', 506, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:warehouse:assign', '#'), +(1087, '托盘查询', 507, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:query', '#'), +(1088, '托盘新增', 507, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:add', '#'), +(1089, '托盘修改', 507, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:edit', '#'), +(1090, '托盘删除', 507, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:remove', '#'), +(1091, '托盘打印', 507, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:print', '#'), +(1092, '入库查询', 508, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:query', '#'), +(1093, '入库新增', 508, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:add', '#'), +(1094, '入库修改', 508, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:edit', '#'), +(1095, '入库删除', 508, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:remove', '#'), +(1096, '入库确认', 508, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:confirm', '#'), +(1097, '装填查询', 509, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:filling:query', '#'), +(1098, '装填新增', 509, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:filling:add', '#'), +(1099, '发货查询', 510, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:query', '#'), +(1100, '发货新增', 510, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:add', '#'), +(1101, '发货修改', 510, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:edit', '#'), +(1102, '发货删除', 510, '4', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:remove', '#'), +(1103, '发货确认', 510, '5', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:confirm', '#'), +(1104, '使用查询', 511, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:query', '#'), +(1105, '使用新增', 511, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:add', '#'), +(1106, '退回查询', 512, '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:query', '#'), +(1107, '退回申请', 512, '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:request', '#'), +(1108, '退回确认', 512, '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:confirm', '#'); + +drop temporary table if exists pallet_workflow_menu_seed_guard; +create temporary table pallet_workflow_menu_seed_guard ( + guard_token varchar(64) not null, + primary key (guard_token) +); + +insert into pallet_workflow_menu_seed_guard (guard_token) +select 'business-menu-seed-conflict' as guard_token +union all +select 'business-menu-seed-conflict' as guard_token +where exists ( + select 1 + from sys_menu as actual + join pallet_workflow_menu_seed_expected as expected + on actual.menu_id = expected.menu_id + where not ( + actual.menu_name <=> expected.menu_name + and actual.parent_id <=> expected.parent_id + and actual.order_num <=> expected.order_num + and actual.path <=> expected.path + and actual.component <=> expected.component + and actual.query <=> expected.query + 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 + ) +); + +drop temporary table pallet_workflow_menu_seed_guard; +drop temporary table pallet_workflow_menu_seed_expected; + +-- 保存当前会话模式,再无空项、无重复地启用 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)); + +-- 临时期望表和 guard 已在事务外清理,避免临时表 DDL 影响永久 DML 的事务边界。 +start transaction; + -- 先解除所有待清理菜单的历史角色关联。 delete from sys_role_menu where menu_id in (4, 99, 118, 119, 1061, 1062, 1063, 1064); @@ -522,14 +660,6 @@ where dict_type = 'ai_provider_type'; delete from sys_menu where menu_id = 99; --- 保存当前会话模式,再无空项、无重复地启用 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。 @@ -618,7 +748,9 @@ on duplicate key update null ); --- seed 成功后立即恢复原会话模式;后续仅执行只读人工核对,无需继续保持严格模式。 +commit; + +-- 永久 DML 提交成功后立即恢复原会话模式;后续仅执行只读人工核对,无需继续保持严格模式。 set session sql_mode = @pallet_workflow_original_sql_mode; -- 人工核对业务菜单固定 ID;查询结果必须为 64 行,且各字段应与本补丁 seed 一致。 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 24b2b12..c18dcbf 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -1241,6 +1241,154 @@ class SqlContractTest(unittest.TestCase): with self.subTest(mutation=name): self.assertFalse(_mysql_menu_seed_has_safe_sql_mode_order(mutation)) + def test_patch_preflights_new_menu_ids_before_atomic_permanent_dml(self) -> None: + raw = PATCH_PATH.read_text(encoding='utf-8') + statements = _sql_statements(raw) + normalized = [_normalized_sql_statement(statement) for statement in statements] + + expected_table = 'pallet_workflow_menu_seed_expected' + conflict_guard = 'pallet_workflow_menu_seed_guard' + + def statement_indexes(pattern: str) -> list[int]: + return [index for index, statement in enumerate(normalized) if re.fullmatch(pattern, statement, flags=re.S)] + + expected_initial_drops = statement_indexes(rf'drop temporary table if exists {expected_table}') + expected_creates = statement_indexes(rf'create temporary table {expected_table}\s*\(.*\)') + expected_inserts = statement_indexes(rf'insert into {expected_table}\s*\(.*') + guard_initial_drops = statement_indexes(rf'drop temporary table if exists {conflict_guard}') + guard_creates = statement_indexes(rf'create temporary table {conflict_guard}\s*\(.*\)') + guard_inserts = statement_indexes(rf'insert into {conflict_guard}\s*\(.*') + guard_final_drops = statement_indexes(rf'drop temporary table {conflict_guard}') + expected_final_drops = statement_indexes(rf'drop temporary table {expected_table}') + + markers = { + 'expected table initial cleanup': expected_initial_drops, + 'expected table creation': expected_creates, + 'expected seed insertion': expected_inserts, + 'conflict guard initial cleanup': guard_initial_drops, + 'conflict guard creation': guard_creates, + 'conflict guard check': guard_inserts, + 'conflict guard success cleanup': guard_final_drops, + 'expected table success cleanup': expected_final_drops, + } + for name, indexes in markers.items(): + with self.subTest(marker=name): + self.assertEqual(1, len(indexes), f'missing or duplicate {name}') + + legacy_guard_cleanup = normalized.index('drop temporary table pallet_workflow_legacy_seed_guard') + permanent_deletes = [ + index + for index, statement in enumerate(normalized) + if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement) + ] + self.assertTrue(permanent_deletes) + first_permanent_delete = permanent_deletes[0] + + ordered_markers = ( + legacy_guard_cleanup, + expected_initial_drops[0], + expected_creates[0], + expected_inserts[0], + guard_initial_drops[0], + guard_creates[0], + guard_inserts[0], + guard_final_drops[0], + expected_final_drops[0], + first_permanent_delete, + ) + self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers) + + expected_insert = statements[expected_inserts[0]].replace('`', '').lower() + expected_insert_match = re.fullmatch( + rf'\s*insert\s+into\s+{expected_table}\s*\((?P[^)]+)\)\s+values\s+.*', + expected_insert, + flags=re.S, + ) + self.assertIsNotNone(expected_insert_match) + expected_columns = tuple(column.strip() for column in expected_insert_match.group('columns').split(',')) + self.assertEqual(('menu_id', *MYSQL_MENU_STABLE_COLUMNS), expected_columns) + + expected_rows = _insert_rows(statements[expected_inserts[0]], expected_table) + self.assertEqual(len(BUSINESS_MENU_IDS), len(expected_rows)) + expected_rows_by_id = {int(row[0]): tuple(row) for row in expected_rows} + self.assertEqual(BUSINESS_MENU_IDS, set(expected_rows_by_id)) + seed_rows_by_id = { + menu_id: tuple(row[: len(expected_columns)]) for menu_id, row in _business_menu_rows(raw).items() + } + self.assertEqual(seed_rows_by_id, expected_rows_by_id) + + guard_create = normalized[guard_creates[0]] + self.assertRegex(guard_create, r'primary key\s*\(\s*guard_token\s*\)') + guard_insert = normalized[guard_inserts[0]] + self.assertEqual(2, guard_insert.count("select 'business-menu-seed-conflict'")) + self.assertIn( + "union all select 'business-menu-seed-conflict' as guard_token where exists (", + guard_insert, + ) + self.assertNotIn(' on duplicate key update ', guard_insert) + expected_comparisons = ' and '.join( + f'actual.{column} <=> expected.{column}' for column in MYSQL_MENU_STABLE_COLUMNS + ) + guard_insert = re.sub(r'\(\s+', '(', guard_insert) + guard_insert = re.sub(r'\s+\)', ')', guard_insert) + self.assertIn( + f'from sys_menu as actual join {expected_table} as expected ' + f'on actual.menu_id = expected.menu_id where not ({expected_comparisons})', + guard_insert, + ) + + preflight_sql = '; '.join(normalized[expected_creates[0] : first_permanent_delete]) + self.assertNotRegex( + preflight_sql, + r'\b(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_menu\b', + ) + + def test_patch_wraps_permanent_seed_dml_in_explicit_transaction(self) -> None: + statements = _sql_statements(PATCH_PATH.read_text(encoding='utf-8')) + normalized = [_normalized_sql_statement(statement) for statement in statements] + start_transactions = [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(start_transactions), 'permanent seed DML must start one explicit transaction') + self.assertEqual(1, len(commits), 'permanent seed DML must commit one explicit transaction') + start_transaction = start_transactions[0] + commit = commits[0] + + permanent_deletes = [ + index + for index, statement in enumerate(normalized) + if re.match(r'delete from sys_(?:role_menu|menu|dict_data|dict_type)\b', statement) + ] + self.assertTrue(permanent_deletes) + menu_seed_inserts = [ + index for index, statement in enumerate(normalized) if statement.startswith('insert into sys_menu values ') + ] + self.assertEqual(1, len(menu_seed_inserts)) + menu_seed_insert = menu_seed_inserts[0] + + ordered_markers = ( + normalized.index(MYSQL_MENU_SQL_MODE_SAVE), + normalized.index(MYSQL_MENU_SQL_MODE_ENFORCE), + start_transaction, + permanent_deletes[0], + permanent_deletes[-1], + menu_seed_insert, + commit, + normalized.index(MYSQL_MENU_SQL_MODE_RESTORE), + ) + self.assertEqual(tuple(sorted(ordered_markers)), ordered_markers) + + permanent_mutations = [*permanent_deletes, menu_seed_insert] + self.assertTrue(all(start_transaction < index < commit for index in permanent_mutations)) + self.assertTrue( + all( + index < start_transaction + for index, statement in enumerate(normalized) + if re.match(r'(?:create|drop) temporary table\b', statement) + ) + ) + transaction_sql = '; '.join(normalized[start_transaction + 1 : commit]) + self.assertNotRegex(transaction_sql, r'\b(?:create|drop|alter|truncate)\b') + 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()