fix(db,ci): 补全 26 个缺失权限码 seed 注册 + 检查脚本增强

- 新增迁移 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 个不一致
This commit is contained in:
iven
2026-05-13 14:30:27 +08:00
parent 935ca70dfa
commit c681049c82
4 changed files with 367 additions and 6 deletions

View File

@@ -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),
]
}
}

View File

@@ -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_entryversion 是 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(())
}
}

View File

@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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(())
}
}

View File

@@ -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"