From c681049c820fd0ad5855b611ec8e3b51d166e2d7 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 13 May 2026 14:30:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(db,ci):=20=E8=A1=A5=E5=85=A8=2026=20?= =?UTF-8?q?=E4=B8=AA=E7=BC=BA=E5=A4=B1=E6=9D=83=E9=99=90=E7=A0=81=20seed?= =?UTF-8?q?=20=E6=B3=A8=E5=86=8C=20+=20=E6=A3=80=E6=9F=A5=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增迁移 000144 全实体乐观锁 version 字段强制化 - 新增迁移 000145 注册 26 个后端已声明但 seed 缺失的权限码 (ai.analysis/prompt/suggestion/usage/provider, copilot.insights/risk/rules, health.ble-gateways/critical-alerts/devices/family-proxy/shifts 等) - check-permissions.sh: 增加 module.rs PermissionDescriptor 提取, 支持两段式权限码 (plugin.admin/tenant.manage) - CI 检查结果: Check 1 PASS, Check 2 PASS, 0 个不一致 --- crates/erp-server/migration/src/lib.rs | 4 + ..._000144_enforce_version_optimistic_lock.rs | 115 +++++++++ ...0260513_000145_seed_missing_permissions.rs | 237 ++++++++++++++++++ scripts/check-permissions.sh | 17 +- 4 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs create mode 100644 crates/erp-server/migration/src/m20260513_000145_seed_missing_permissions.rs diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index aef19de..67f85ff 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -145,6 +145,8 @@ mod m20260512_000140_create_copilot_risk_snapshots; mod m20260512_000141_create_copilot_chat_logs; mod m20260512_000142_seed_copilot_rules; mod m20260512_000143_seed_copilot_alert_rules; +mod m20260513_000144_enforce_version_optimistic_lock; +mod m20260513_000145_seed_missing_permissions; pub struct Migrator; @@ -297,6 +299,8 @@ impl MigratorTrait for Migrator { Box::new(m20260512_000141_create_copilot_chat_logs::Migration), Box::new(m20260512_000142_seed_copilot_rules::Migration), Box::new(m20260512_000143_seed_copilot_alert_rules::Migration), + Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration), + Box::new(m20260513_000145_seed_missing_permissions::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs b/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs new file mode 100644 index 0000000..9f067d8 --- /dev/null +++ b/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 1. 创建触发器函数:适用于 `version` 列(erp-health / erp-auth / erp-config 等所有模块) + db.execute_unprepared( + r#" +CREATE OR REPLACE FUNCTION enforce_version() RETURNS trigger AS $$ +BEGIN + IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN + RAISE EXCEPTION 'Optimistic lock conflict on %: expected version %, got %', + TG_TABLE_NAME, OLD.version + 1, NEW.version; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +"#, + ) + .await?; + + // 2. 创建触发器函数:适用于 `version_lock` 列(erp-ai 模块) + db.execute_unprepared( + r#" +CREATE OR REPLACE FUNCTION enforce_version_lock() RETURNS trigger AS $$ +BEGIN + IF NEW.version_lock IS DISTINCT FROM OLD.version_lock + 1 THEN + RAISE EXCEPTION 'Optimistic lock conflict on %: expected version_lock %, got %', + TG_TABLE_NAME, OLD.version_lock + 1, NEW.version_lock; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +"#, + ) + .await?; + + // 3. 自动发现所有含 version / version_lock 的表并绑定触发器 + // 排除 market_entry(version 是 String,非乐观锁) + db.execute_unprepared( + r#" +DO $$ +DECLARE + rec RECORD; + trig_name text; +BEGIN + FOR rec IN + SELECT table_name, column_name + FROM information_schema.columns + WHERE column_name IN ('version', 'version_lock') + AND table_schema = 'public' + AND table_name NOT IN ('market_entry', 'process_definitions', 'ai_prompt') + LOOP + IF rec.column_name = 'version' THEN + trig_name := 'trg_enforce_version'; + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I + FOR EACH ROW EXECUTE FUNCTION enforce_version()', + trig_name, rec.table_name + ); + ELSE + trig_name := 'trg_enforce_version_lock'; + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I + FOR EACH ROW EXECUTE FUNCTION enforce_version_lock()', + trig_name, rec.table_name + ); + END IF; + END LOOP; +END; +$$; +"#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 1. 删除所有乐观锁触发器 + db.execute_unprepared( + r#" +DO $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT event_object_table AS table_name, trigger_name + FROM information_schema.triggers + WHERE trigger_name IN ('trg_enforce_version', 'trg_enforce_version_lock') + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I', rec.trigger_name, rec.table_name); + END LOOP; +END; +$$; +"#, + ) + .await?; + + // 2. 删除触发器函数 + db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version() CASCADE") + .await?; + db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version_lock() CASCADE") + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260513_000145_seed_missing_permissions.rs b/crates/erp-server/migration/src/m20260513_000145_seed_missing_permissions.rs new file mode 100644 index 0000000..2153422 --- /dev/null +++ b/crates/erp-server/migration/src/m20260513_000145_seed_missing_permissions.rs @@ -0,0 +1,237 @@ +//! 补全缺失权限码注册 +//! +//! CI check-permissions.sh 发现 23 个后端 handler 已使用但 seed 迁移未注册的权限码。 +//! 本迁移统一补注册到 permissions 表,并绑定 admin 角色。 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let sys = "00000000-0000-0000-0000-000000000000"; + + // (code, name, resource, action) + let permissions: &[(&str, &str, &str, &str)] = &[ + // AI 模块 + ("ai.analysis.list", "查看 AI 分析", "ai", "analysis.list"), + ( + "ai.analysis.manage", + "管理 AI 分析", + "ai", + "analysis.manage", + ), + ("ai.prompt.list", "查看 AI 提示词", "ai", "prompt.list"), + ("ai.prompt.manage", "管理 AI 提示词", "ai", "prompt.manage"), + ( + "ai.provider.manage", + "管理 AI Provider", + "ai", + "provider.manage", + ), + ( + "ai.suggestion.list", + "查看 AI 建议", + "ai", + "suggestion.list", + ), + ( + "ai.suggestion.manage", + "管理 AI 建议", + "ai", + "suggestion.manage", + ), + ("ai.usage.list", "查看 AI 用量", "ai", "usage.list"), + // Copilot + ( + "copilot.insights.list", + "查看 Copilot 洞察", + "copilot", + "insights.list", + ), + ( + "copilot.insights.manage", + "管理 Copilot 洞察", + "copilot", + "insights.manage", + ), + ("copilot.risk.view", "查看风险快照", "copilot", "risk.view"), + ( + "copilot.rules.list", + "查看 Copilot 规则", + "copilot", + "rules.list", + ), + ( + "copilot.rules.manage", + "管理 Copilot 规则", + "copilot", + "rules.manage", + ), + // Health — IoT/设备 + ( + "health.ble-gateways.manage", + "管理 BLE 网关", + "health", + "ble-gateways.manage", + ), + ( + "health.critical-alerts.manage", + "管理危急值告警", + "health", + "critical-alerts.manage", + ), + ( + "health.critical-value-thresholds.manage", + "管理危急值阈值", + "health", + "critical-value-thresholds.manage", + ), + ( + "health.device-readings.manage", + "管理设备读数", + "health", + "device-readings.manage", + ), + ( + "health.devices.manage", + "管理患者设备", + "health", + "devices.manage", + ), + ( + "health.medication-reminders.manage", + "管理药物提醒", + "health", + "medication-reminders.manage", + ), + // Health — 其他 + ( + "health.family-proxy.list", + "查看家庭健康代理", + "health", + "family-proxy.list", + ), + ( + "health.family-proxy.manage", + "管理家庭健康代理", + "health", + "family-proxy.manage", + ), + ( + "health.oauth.manage", + "管理 OAuth 合作方", + "health", + "oauth.manage", + ), + ( + "health.shifts.manage", + "管理班次", + "health", + "shifts.manage", + ), + // Plugin / Tenant + ("plugin.admin", "插件管理", "plugin", "admin"), + ("plugin.list", "查看插件列表", "plugin", "list"), + ("tenant.manage", "租户管理", "tenant", "manage"), + ]; + + for &(code, name, resource, action) in permissions { + db.execute_unprepared(&format!( + r#" + INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, + created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, '{code}', '{name}', '{resource}', '{action}', '{name}', + NOW(), NOW(), '{sys}', '{sys}', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM permissions p + WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL + ) + "# + )).await?; + } + + // 绑定所有新权限到 admin 角色 + let codes: Vec<&str> = permissions.iter().map(|p| p.0).collect(); + let codes_sql = codes + .iter() + .map(|c| format!("'{c}'")) + .collect::>() + .join(", "); + + db.execute_unprepared(&format!( + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version) + SELECT r.id, p.id, t.id, r.id, r.id, 1 + FROM tenant t + JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL + JOIN permissions p ON p.tenant_id = t.id + AND p.code IN ({codes_sql}) + AND p.deleted_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.permission_id = p.id AND rp.role_id = r.id + ) + "# + )).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + let codes = [ + "ai.analysis.list", + "ai.analysis.manage", + "ai.prompt.list", + "ai.prompt.manage", + "ai.provider.manage", + "ai.suggestion.list", + "ai.suggestion.manage", + "ai.usage.list", + "copilot.insights.list", + "copilot.insights.manage", + "copilot.risk.view", + "copilot.rules.list", + "copilot.rules.manage", + "health.ble-gateways.manage", + "health.critical-alerts.manage", + "health.critical-value-thresholds.manage", + "health.device-readings.manage", + "health.devices.manage", + "health.family-proxy.list", + "health.family-proxy.manage", + "health.medication-reminders.manage", + "health.oauth.manage", + "health.shifts.manage", + "plugin.admin", + "plugin.list", + "tenant.manage", + ]; + let codes_sql = codes + .iter() + .map(|c| format!("'{c}'")) + .collect::>() + .join(", "); + + db.execute_unprepared(&format!( + "DELETE FROM role_permissions WHERE permission_id IN \ + (SELECT id FROM permissions WHERE code IN ({codes_sql}))" + )) + .await + .ok(); + + db.execute_unprepared(&format!( + "DELETE FROM permissions WHERE code IN ({codes_sql})" + )) + .await + .ok(); + + Ok(()) + } +} diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh index f25eb96..a458caa 100644 --- a/scripts/check-permissions.sh +++ b/scripts/check-permissions.sh @@ -28,22 +28,27 @@ echo " 权限注册完整性检查" echo "==========================================" # --- 提取后端 handler 权限码 --- +# 1) require_permission 调用 grep -roh 'require_permission.*"[^"]*"' crates/ --include="*.rs" \ | grep -oE '"[^"]*"' | tr -d '"' | sort -u > "$BACKEND_PERMS" +# 2) module.rs 中 PermissionDescriptor 声明的 code 字段 +grep -roh 'code: *"[^"]*"' crates/ --include="*.rs" \ + | grep -oE '"[^"]*\.[^"]*\.[^"]*"' | tr -d '"' | sort -u >> "$BACKEND_PERMS" +# 去重 +cat "$BACKEND_PERMS" | sort -u > "${BACKEND_PERMS}.tmp" && mv "${BACKEND_PERMS}.tmp" "$BACKEND_PERMS" # --- 提取前端 routeConfig 权限码 --- grep -oE '"[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*"' \ apps/web/src/routeConfig.ts | tr -d '"' | sort -u > "$FRONTEND_PERMS" # --- 提取 seed 迁移权限码 --- -grep -roh 'health\.[a-z_-]*\.[a-z_-]*' crates/erp-server/migration/src/ --include="*.rs" \ - | grep -vE 'fn |mod |use |struct |impl |async |let |pub |self|super|crate' \ - | sort -u > "$SEED_PERMS" -# 也提取非 health 前缀的 -grep -roh '(user|role|workflow|message|setting|plugin|department|organization|position|dictionary|menu|numbering|theme|language|tenant|ai)\.[a-z_-]*\.[a-z_-]*' \ +# 匹配三段式(health.patient.list)和两段式(plugin.admin)权限码 +grep -rohE '[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*(\.[a-z][-a-z0-9]*)?' \ crates/erp-server/migration/src/ --include="*.rs" \ | grep -vE 'fn |mod |use |struct |impl |async |let |pub |self|super|crate' \ - | sort -u >> "$SEED_PERMS" + | grep -E '^(user|role|workflow|message|setting|plugin|department|organization|position|dictionary|menu|numbering|theme|language|tenant|ai|copilot|health)' \ + | grep -v '\.(rs|sql|md|toml)$' \ + | sort -u > "$SEED_PERMS" # 提取 handler 中的非 health 权限码也加入 seed 对比 grep -roh 'require_permission.*"[^"]*"' crates/erp-auth/ crates/erp-config/ crates/erp-workflow/ crates/erp-message/ --include="*.rs" \ | grep -oE '"[^"]*"' | tr -d '"' | sort -u >> "$SEED_PERMS"