Skip to main content

sz_rust_cli/cmd/
admin.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3
4//! `admin` 命令组 — Admin 后台管理插件 CLI 工具
5//!
6//! 提供数据库迁移、路由/Capability 查看、初始化等命令,
7//! 对接 `sz-rust-addons-admin` 插件。
8
9use clap::{Args, Subcommand};
10use sz_rust_core::orm::Connection;
11use sz_rust_orm_facade::Value;
12use tabled::{Table, Tabled};
13
14use crate::error::CliError;
15
16/// Admin 后台管理插件命令组
17#[derive(Subcommand, Debug)]
18pub enum AdminCommand {
19    /// 执行 admin 插件数据库迁移(创建 8 张表 + 3 个索引)
20    Migrate(MigrateArgs),
21    /// 列出 admin 插件 21 个 API 端点
22    ListRoutes,
23    /// 列出 admin 插件 17 个 Capability
24    ListCapabilities,
25    /// 初始化 admin 插件(创建内置角色、权限项、超级管理员账户)
26    Init(InitArgs),
27}
28
29/// `admin migrate` 命令参数
30#[derive(Args, Debug)]
31pub struct MigrateArgs {
32    /// 数据库类型(默认 postgres)
33    #[arg(long, default_value = "postgres")]
34    db_type: String,
35    /// 数据库连接 URL(省略时为离线模式)
36    #[arg(long)]
37    url: Option<String>,
38    /// 打印迁移 SQL 内容
39    #[arg(long)]
40    show_sql: bool,
41}
42
43/// `admin init` 命令参数
44#[derive(Args, Debug)]
45pub struct InitArgs {
46    /// 数据库类型(默认 postgres)
47    #[arg(long, default_value = "postgres")]
48    db_type: String,
49    /// 数据库连接 URL(省略时为离线模式)
50    #[arg(long)]
51    url: Option<String>,
52    /// 超级管理员用户名(默认 admin)
53    #[arg(long, default_value = "admin")]
54    username: String,
55    /// 超级管理员密码(默认 admin123)
56    #[arg(long, default_value = "admin123")]
57    password: String,
58}
59
60#[derive(Tabled)]
61struct RouteRow {
62    method: &'static str,
63    path: &'static str,
64    description: &'static str,
65}
66
67#[derive(Tabled)]
68struct CapabilityRow {
69    name: &'static str,
70    description: &'static str,
71    tags: &'static str,
72}
73
74const ROUTES: &[(&str, &str, &str)] = &[
75    ("GET", "/api/admin/users", "用户列表"),
76    ("POST", "/api/admin/users", "创建用户"),
77    ("PUT", "/api/admin/users/{user_id}", "更新用户"),
78    ("DELETE", "/api/admin/users/{user_id}", "删除用户"),
79    ("PUT", "/api/admin/users/{user_id}/status", "更新用户状态"),
80    ("PUT", "/api/admin/users/{user_id}/roles", "分配用户角色"),
81    ("GET", "/api/admin/roles", "角色列表"),
82    ("POST", "/api/admin/roles", "创建角色"),
83    ("PUT", "/api/admin/roles/{role_id}", "更新角色"),
84    ("DELETE", "/api/admin/roles/{role_id}", "删除角色"),
85    (
86        "PUT",
87        "/api/admin/roles/{role_id}/permissions",
88        "分配角色权限",
89    ),
90    ("GET", "/api/admin/permissions/tree", "权限树"),
91    ("GET", "/api/admin/menus/tree", "菜单树"),
92    ("POST", "/api/admin/menus", "创建菜单"),
93    ("PUT", "/api/admin/menus/{menu_id}", "更新菜单"),
94    ("DELETE", "/api/admin/menus/{menu_id}", "删除菜单"),
95    ("GET", "/api/admin/configs", "配置列表"),
96    ("PUT", "/api/admin/configs/{config_key}", "更新配置"),
97    ("DELETE", "/api/admin/configs/{config_key}", "删除配置"),
98    ("GET", "/api/admin/operation-logs", "操作日志列表"),
99    ("GET", "/api/admin/dashboard", "仪表盘统计"),
100];
101
102const CAPABILITIES: &[(&str, &str, &str)] = &[
103    ("admin.user_list", "用户列表查询", "user,query"),
104    ("admin.user_create", "创建用户", "user,create"),
105    ("admin.user_update", "更新用户", "user,update"),
106    ("admin.user_delete", "删除用户", "user,delete"),
107    ("admin.role_list", "角色列表查询", "role,query"),
108    ("admin.role_create", "创建角色", "role,create"),
109    ("admin.role_update", "更新角色", "role,update"),
110    ("admin.role_delete", "删除角色", "role,delete"),
111    ("admin.permission_tree", "权限树查询", "permission,query"),
112    ("admin.menu_tree", "菜单树查询", "menu,query"),
113    ("admin.menu_create", "创建菜单", "menu,create"),
114    ("admin.menu_update", "更新菜单", "menu,update"),
115    ("admin.menu_delete", "删除菜单", "menu,delete"),
116    ("admin.config_list", "配置列表查询", "config,query"),
117    ("admin.config_update", "更新配置", "config,update"),
118    ("admin.log_list", "操作日志查询", "log,query"),
119    ("admin.dashboard", "仪表盘统计", "dashboard,query"),
120];
121
122// 打包内拷贝:include_str 不能逃出 crate 根(cargo publish 的 tarball 只含本包文件,
123// 跨包相对路径在发布验证时必然找不到——本地工作区因兄弟目录存在而从未暴露)。
124// 源头: packages/sz-rust-addons-admin/src/migrations/001_init_admin.sql(变更需两处同步)
125const MIGRATION_SQL: &str = include_str!("../../assets/migrations/001_init_admin.sql");
126
127/// 执行 admin 命令
128pub async fn execute(cmd: &AdminCommand) -> Result<i32, CliError> {
129    match cmd {
130        AdminCommand::Migrate(args) => execute_migrate(args).await,
131        AdminCommand::ListRoutes => execute_list_routes(),
132        AdminCommand::ListCapabilities => execute_list_capabilities(),
133        AdminCommand::Init(args) => execute_init(args).await,
134    }
135}
136
137async fn create_connection(url: &str, db_type: &str) -> Result<Box<dyn Connection>, CliError> {
138    use sz_orm_sqlx::any_driver::AnyPool;
139
140    let pool = AnyPool::connect(url)
141        .await
142        .map_err(|e| CliError::Migration(format!("{db_type} 连接失败: {e}")))?;
143    let conn = pool
144        .create()
145        .await
146        .map_err(|e| CliError::Migration(format!("获取连接失败: {e}")))?;
147    Ok(Box::new(conn))
148}
149
150async fn execute_migrate(args: &MigrateArgs) -> Result<i32, CliError> {
151    if args.show_sql || args.url.is_none() {
152        println!("=== sz-rust-addons-admin 迁移 SQL ===\n");
153        println!("{}", MIGRATION_SQL);
154        println!("=== 共 8 张表 + 3 个索引 ===");
155    }
156
157    if let Some(url) = &args.url {
158        println!("\n连接数据库 {} ...", args.db_type);
159        let mut conn = create_connection(url, &args.db_type).await?;
160
161        let sql_statements: Vec<&str> = MIGRATION_SQL
162            .lines()
163            .filter(|l| !l.is_empty() && !l.starts_with("--"))
164            .collect();
165        let joined = sql_statements.join("\n");
166        let statements: Vec<&str> = joined.split(';').filter(|s| !s.trim().is_empty()).collect();
167        println!("待执行 {} 条 SQL 语句", statements.len());
168
169        for (i, stmt) in statements.iter().enumerate() {
170            conn.execute(stmt)
171                .await
172                .map_err(|e| CliError::Migration(format!("第 {} 条 SQL 执行失败: {e}", i + 1)))?;
173            println!("  [{}] OK", i + 1);
174        }
175        println!("\n迁移完成:8 张表 + 3 个索引已创建");
176    } else if !args.show_sql {
177        println!("(离线模式,使用 --url <DATABASE_URL> 执行在线迁移)");
178    }
179
180    Ok(0)
181}
182
183fn execute_list_routes() -> Result<i32, CliError> {
184    let rows: Vec<RouteRow> = ROUTES
185        .iter()
186        .map(|(method, path, desc)| RouteRow {
187            method,
188            path,
189            description: desc,
190        })
191        .collect();
192    let table = Table::new(rows);
193    println!("=== Admin 插件 API 端点(共 {} 个)===\n", ROUTES.len());
194    println!("{table}");
195    Ok(0)
196}
197
198fn execute_list_capabilities() -> Result<i32, CliError> {
199    let rows: Vec<CapabilityRow> = CAPABILITIES
200        .iter()
201        .map(|(name, desc, tags)| CapabilityRow {
202            name,
203            description: desc,
204            tags,
205        })
206        .collect();
207    let table = Table::new(rows);
208    println!(
209        "=== Admin 插件 Capability(共 {} 个)===\n",
210        CAPABILITIES.len()
211    );
212    println!("{table}");
213    Ok(0)
214}
215
216async fn execute_init(args: &InitArgs) -> Result<i32, CliError> {
217    if args.url.is_none() {
218        println!("=== Admin 插件初始化(离线模式)===\n");
219        println!("将创建以下初始数据:");
220        println!("  1. 内置角色: super_admin(超级管理员)、tenant_admin(租户管理员)");
221        println!("  2. 内置权限项: admin:* 系列({} 个)", CAPABILITIES.len());
222        println!("  3. 超级管理员账户: {} (密码: ***)", args.username);
223        println!("\n使用 --url <DATABASE_URL> 执行在线初始化");
224        return Ok(0);
225    }
226
227    let url = args
228        .url
229        .as_deref()
230        .ok_or_else(|| CliError::Clap("admin init 在线模式必须提供 --url".to_string()))?;
231    println!("=== Admin 插件初始化 ===\n");
232    println!("连接数据库...");
233    let mut conn = create_connection(url, &args.db_type).await?;
234
235    println!("创建内置角色...");
236    for (code, name, desc) in [
237        ("super_admin", "超级管理员", "拥有所有权限,不可删除"),
238        ("tenant_admin", "租户管理员", "租户内管理权限,不可删除"),
239    ] {
240        let sql = "INSERT INTO roles (name, code, description, is_builtin, tenant_id) \
241              VALUES (?, ?, ?, TRUE, 0) \
242              ON CONFLICT (code, tenant_id) DO NOTHING";
243        conn.execute_with_params(
244            sql,
245            &[
246                Value::String(name.to_string()),
247                Value::String(code.to_string()),
248                Value::String(desc.to_string()),
249            ],
250        )
251        .await
252        .map_err(|e| CliError::Migration(format!("创建角色 {code} 失败: {e}")))?;
253        println!("  角色 {} ({}) 已创建", code, name);
254    }
255
256    println!("创建内置权限项...");
257    for (cap_name, cap_desc, _tags) in CAPABILITIES {
258        let module = cap_name.split('.').nth(1).unwrap_or("admin");
259        let sql = "INSERT INTO permissions (code, name, module, description, tenant_id) \
260              VALUES (?, ?, ?, ?, 0) \
261              ON CONFLICT (code, tenant_id) DO NOTHING";
262        conn.execute_with_params(
263            sql,
264            &[
265                Value::String(cap_name.to_string()),
266                Value::String(cap_desc.to_string()),
267                Value::String(module.to_string()),
268                Value::String(cap_desc.to_string()),
269            ],
270        )
271        .await
272        .map_err(|e| CliError::Migration(format!("创建权限 {cap_name} 失败: {e}")))?;
273    }
274    println!("  {} 个权限项已创建", CAPABILITIES.len());
275
276    println!("为 super_admin 分配所有权限...");
277    let sql = "INSERT INTO role_permissions (role_id, permission_code, tenant_id) \
278         SELECT r.id, p.code, 0 FROM roles r, permissions p \
279         WHERE r.code = 'super_admin' AND r.tenant_id = 0 \
280         AND p.tenant_id = 0 \
281         ON CONFLICT (role_id, permission_code, tenant_id) DO NOTHING";
282    conn.execute(sql)
283        .await
284        .map_err(|e| CliError::Migration(format!("分配权限失败: {e}")))?;
285    println!("  super_admin 已分配全部权限");
286
287    println!("创建超级管理员账户...");
288    let hashed = bcrypt::hash(&args.password, 10)
289        .map_err(|e| CliError::Migration(format!("密码哈希失败: {e}")))?;
290    conn.execute_with_params(
291        "INSERT INTO users (username, password, status, tenant_id) \
292         VALUES (?, ?, 'active', 0) \
293         ON CONFLICT (username, tenant_id) DO NOTHING",
294        &[Value::String(args.username.clone()), Value::String(hashed)],
295    )
296    .await
297    .map_err(|e| CliError::Migration(format!("创建管理员账户失败: {e}")))?;
298
299    conn.execute_with_params(
300        "INSERT INTO user_roles (user_id, role_id, tenant_id) \
301         SELECT u.id, r.id, 0 FROM users u, roles r \
302         WHERE u.username = ? AND u.tenant_id = 0 \
303         AND r.code = 'super_admin' AND r.tenant_id = 0 \
304         ON CONFLICT (user_id, role_id, tenant_id) DO NOTHING",
305        &[Value::String(args.username.clone())],
306    )
307    .await
308    .map_err(|e| CliError::Migration(format!("分配管理员角色失败: {e}")))?;
309    println!("  账户 {} 已创建并分配 super_admin 角色", args.username);
310
311    println!("\n初始化完成!");
312    println!("  超级管理员: {}", args.username);
313    println!("  请及时修改默认密码!");
314    Ok(0)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use clap::Parser;
321
322    #[derive(Parser)]
323    struct TestCli {
324        #[command(subcommand)]
325        admin: AdminCommand,
326    }
327
328    #[test]
329    fn test_parse_migrate() {
330        let cli = TestCli::parse_from(["sz-rust", "migrate", "--db-type", "postgres"]);
331        assert!(matches!(cli.admin, AdminCommand::Migrate { .. }));
332    }
333
334    #[test]
335    fn test_parse_migrate_with_url() {
336        let cli = TestCli::parse_from([
337            "sz-rust",
338            "migrate",
339            "--url",
340            "postgres://localhost/db",
341            "--show-sql",
342        ]);
343        match cli.admin {
344            AdminCommand::Migrate(args) => {
345                assert_eq!(args.url.as_deref(), Some("postgres://localhost/db"));
346                assert!(args.show_sql);
347            }
348            _ => panic!("expected Migrate"),
349        }
350    }
351
352    #[test]
353    fn test_parse_list_routes() {
354        let cli = TestCli::parse_from(["sz-rust", "list-routes"]);
355        assert!(matches!(cli.admin, AdminCommand::ListRoutes));
356    }
357
358    #[test]
359    fn test_parse_list_capabilities() {
360        let cli = TestCli::parse_from(["sz-rust", "list-capabilities"]);
361        assert!(matches!(cli.admin, AdminCommand::ListCapabilities));
362    }
363
364    #[test]
365    fn test_parse_init() {
366        let cli = TestCli::parse_from([
367            "sz-rust",
368            "init",
369            "--username",
370            "myadmin",
371            "--password",
372            "mypass",
373        ]);
374        match cli.admin {
375            AdminCommand::Init(args) => {
376                assert_eq!(args.username, "myadmin");
377                assert_eq!(args.password, "mypass");
378            }
379            _ => panic!("expected Init"),
380        }
381    }
382
383    #[test]
384    fn test_routes_count_21() {
385        assert_eq!(ROUTES.len(), 21);
386    }
387
388    #[test]
389    fn test_capabilities_count_17() {
390        assert_eq!(CAPABILITIES.len(), 17);
391    }
392
393    #[test]
394    fn test_all_capabilities_prefixed_admin() {
395        for (name, _, _) in CAPABILITIES {
396            assert!(
397                name.starts_with("admin."),
398                "capability {name} must start with admin."
399            );
400        }
401    }
402
403    #[tokio::test]
404    async fn test_execute_list_routes() {
405        let result = execute(&AdminCommand::ListRoutes).await;
406        assert!(result.is_ok());
407        assert_eq!(result.unwrap(), 0);
408    }
409
410    #[tokio::test]
411    async fn test_execute_list_capabilities() {
412        let result = execute(&AdminCommand::ListCapabilities).await;
413        assert!(result.is_ok());
414        assert_eq!(result.unwrap(), 0);
415    }
416
417    #[tokio::test]
418    async fn test_execute_migrate_offline() {
419        let args = MigrateArgs {
420            db_type: "postgres".to_string(),
421            url: None,
422            show_sql: true,
423        };
424        let result = execute(&AdminCommand::Migrate(args)).await;
425        assert!(result.is_ok());
426    }
427
428    #[tokio::test]
429    async fn test_execute_init_offline() {
430        let args = InitArgs {
431            db_type: "postgres".to_string(),
432            url: None,
433            username: "admin".to_string(),
434            password: "admin123".to_string(),
435        };
436        let result = execute(&AdminCommand::Init(args)).await;
437        assert!(result.is_ok());
438    }
439
440    #[tokio::test]
441    async fn test_execute_migrate_offline_no_show_sql() {
442        let args = MigrateArgs {
443            db_type: "postgres".to_string(),
444            url: None,
445            show_sql: false,
446        };
447        let result = execute(&AdminCommand::Migrate(args)).await;
448        assert!(result.is_ok());
449    }
450
451    #[test]
452    fn test_route_row_tabled() {
453        let row = RouteRow {
454            method: "GET",
455            path: "/api/admin/users",
456            description: "用户列表",
457        };
458        let table = Table::new(vec![row]);
459        let s = table.to_string();
460        assert!(s.contains("GET"));
461        assert!(s.contains("/api/admin/users"));
462    }
463
464    #[test]
465    fn test_capability_row_tabled() {
466        let row = CapabilityRow {
467            name: "admin.user_list",
468            description: "用户列表查询",
469            tags: "user,query",
470        };
471        let table = Table::new(vec![row]);
472        let s = table.to_string();
473        assert!(s.contains("admin.user_list"));
474    }
475
476    #[test]
477    fn test_migration_sql_not_empty() {
478        assert!(!MIGRATION_SQL.is_empty());
479        assert!(MIGRATION_SQL.contains("CREATE TABLE"));
480    }
481}