修复:校验 MySQL 菜单固定ID冲突
This commit is contained in:
@@ -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 相关缓存;本补丁不执行缓存命令。
|
||||
|
||||
@@ -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<guard>.*?)\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<columns>.*?)\s+from\s+sys_menu\s+'
|
||||
r'where\s+menu_id\s+in\s*\((?P<ids>[^)]+)\)\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()
|
||||
|
||||
Reference in New Issue
Block a user