sz-rust-cli 1.4.0

SZ-Rust 命令行工具:项目脚手架、数据库迁移、调度器管理
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 SZ-Rust Team

//! `admin` 命令组 — Admin 后台管理插件 CLI 工具
//!
//! 提供数据库迁移、路由/Capability 查看、初始化等命令,
//! 对接 `sz-rust-addons-admin` 插件。

use clap::{Args, Subcommand};
use sz_rust_core::orm::Connection;
use sz_rust_orm_facade::Value;
use tabled::{Table, Tabled};

use crate::error::CliError;

/// Admin 后台管理插件命令组
#[derive(Subcommand, Debug)]
pub enum AdminCommand {
    /// 执行 admin 插件数据库迁移(创建 8 张表 + 3 个索引)
    Migrate(MigrateArgs),
    /// 列出 admin 插件 21 个 API 端点
    ListRoutes,
    /// 列出 admin 插件 17 个 Capability
    ListCapabilities,
    /// 初始化 admin 插件(创建内置角色、权限项、超级管理员账户)
    Init(InitArgs),
}

/// `admin migrate` 命令参数
#[derive(Args, Debug)]
pub struct MigrateArgs {
    /// 数据库类型(默认 postgres)
    #[arg(long, default_value = "postgres")]
    db_type: String,
    /// 数据库连接 URL(省略时为离线模式)
    #[arg(long)]
    url: Option<String>,
    /// 打印迁移 SQL 内容
    #[arg(long)]
    show_sql: bool,
}

/// `admin init` 命令参数
#[derive(Args, Debug)]
pub struct InitArgs {
    /// 数据库类型(默认 postgres)
    #[arg(long, default_value = "postgres")]
    db_type: String,
    /// 数据库连接 URL(省略时为离线模式)
    #[arg(long)]
    url: Option<String>,
    /// 超级管理员用户名(默认 admin)
    #[arg(long, default_value = "admin")]
    username: String,
    /// 超级管理员密码(默认 admin123)
    #[arg(long, default_value = "admin123")]
    password: String,
}

#[derive(Tabled)]
struct RouteRow {
    method: &'static str,
    path: &'static str,
    description: &'static str,
}

#[derive(Tabled)]
struct CapabilityRow {
    name: &'static str,
    description: &'static str,
    tags: &'static str,
}

const ROUTES: &[(&str, &str, &str)] = &[
    ("GET", "/api/admin/users", "用户列表"),
    ("POST", "/api/admin/users", "创建用户"),
    ("PUT", "/api/admin/users/{user_id}", "更新用户"),
    ("DELETE", "/api/admin/users/{user_id}", "删除用户"),
    ("PUT", "/api/admin/users/{user_id}/status", "更新用户状态"),
    ("PUT", "/api/admin/users/{user_id}/roles", "分配用户角色"),
    ("GET", "/api/admin/roles", "角色列表"),
    ("POST", "/api/admin/roles", "创建角色"),
    ("PUT", "/api/admin/roles/{role_id}", "更新角色"),
    ("DELETE", "/api/admin/roles/{role_id}", "删除角色"),
    (
        "PUT",
        "/api/admin/roles/{role_id}/permissions",
        "分配角色权限",
    ),
    ("GET", "/api/admin/permissions/tree", "权限树"),
    ("GET", "/api/admin/menus/tree", "菜单树"),
    ("POST", "/api/admin/menus", "创建菜单"),
    ("PUT", "/api/admin/menus/{menu_id}", "更新菜单"),
    ("DELETE", "/api/admin/menus/{menu_id}", "删除菜单"),
    ("GET", "/api/admin/configs", "配置列表"),
    ("PUT", "/api/admin/configs/{config_key}", "更新配置"),
    ("DELETE", "/api/admin/configs/{config_key}", "删除配置"),
    ("GET", "/api/admin/operation-logs", "操作日志列表"),
    ("GET", "/api/admin/dashboard", "仪表盘统计"),
];

const CAPABILITIES: &[(&str, &str, &str)] = &[
    ("admin.user_list", "用户列表查询", "user,query"),
    ("admin.user_create", "创建用户", "user,create"),
    ("admin.user_update", "更新用户", "user,update"),
    ("admin.user_delete", "删除用户", "user,delete"),
    ("admin.role_list", "角色列表查询", "role,query"),
    ("admin.role_create", "创建角色", "role,create"),
    ("admin.role_update", "更新角色", "role,update"),
    ("admin.role_delete", "删除角色", "role,delete"),
    ("admin.permission_tree", "权限树查询", "permission,query"),
    ("admin.menu_tree", "菜单树查询", "menu,query"),
    ("admin.menu_create", "创建菜单", "menu,create"),
    ("admin.menu_update", "更新菜单", "menu,update"),
    ("admin.menu_delete", "删除菜单", "menu,delete"),
    ("admin.config_list", "配置列表查询", "config,query"),
    ("admin.config_update", "更新配置", "config,update"),
    ("admin.log_list", "操作日志查询", "log,query"),
    ("admin.dashboard", "仪表盘统计", "dashboard,query"),
];

// 打包内拷贝:include_str 不能逃出 crate 根(cargo publish 的 tarball 只含本包文件,
// 跨包相对路径在发布验证时必然找不到——本地工作区因兄弟目录存在而从未暴露)。
// 源头: packages/sz-rust-addons-admin/src/migrations/001_init_admin.sql(变更需两处同步)
const MIGRATION_SQL: &str = include_str!("../../assets/migrations/001_init_admin.sql");

/// 执行 admin 命令
pub async fn execute(cmd: &AdminCommand) -> Result<i32, CliError> {
    match cmd {
        AdminCommand::Migrate(args) => execute_migrate(args).await,
        AdminCommand::ListRoutes => execute_list_routes(),
        AdminCommand::ListCapabilities => execute_list_capabilities(),
        AdminCommand::Init(args) => execute_init(args).await,
    }
}

async fn create_connection(url: &str, db_type: &str) -> Result<Box<dyn Connection>, CliError> {
    use sz_orm_sqlx::any_driver::AnyPool;

    let pool = AnyPool::connect(url)
        .await
        .map_err(|e| CliError::Migration(format!("{db_type} 连接失败: {e}")))?;
    let conn = pool
        .create()
        .await
        .map_err(|e| CliError::Migration(format!("获取连接失败: {e}")))?;
    Ok(Box::new(conn))
}

async fn execute_migrate(args: &MigrateArgs) -> Result<i32, CliError> {
    if args.show_sql || args.url.is_none() {
        println!("=== sz-rust-addons-admin 迁移 SQL ===\n");
        println!("{}", MIGRATION_SQL);
        println!("=== 共 8 张表 + 3 个索引 ===");
    }

    if let Some(url) = &args.url {
        println!("\n连接数据库 {} ...", args.db_type);
        let mut conn = create_connection(url, &args.db_type).await?;

        let sql_statements: Vec<&str> = MIGRATION_SQL
            .lines()
            .filter(|l| !l.is_empty() && !l.starts_with("--"))
            .collect();
        let joined = sql_statements.join("\n");
        let statements: Vec<&str> = joined.split(';').filter(|s| !s.trim().is_empty()).collect();
        println!("待执行 {} 条 SQL 语句", statements.len());

        for (i, stmt) in statements.iter().enumerate() {
            conn.execute(stmt)
                .await
                .map_err(|e| CliError::Migration(format!("第 {} 条 SQL 执行失败: {e}", i + 1)))?;
            println!("  [{}] OK", i + 1);
        }
        println!("\n迁移完成:8 张表 + 3 个索引已创建");
    } else if !args.show_sql {
        println!("(离线模式,使用 --url <DATABASE_URL> 执行在线迁移)");
    }

    Ok(0)
}

fn execute_list_routes() -> Result<i32, CliError> {
    let rows: Vec<RouteRow> = ROUTES
        .iter()
        .map(|(method, path, desc)| RouteRow {
            method,
            path,
            description: desc,
        })
        .collect();
    let table = Table::new(rows);
    println!("=== Admin 插件 API 端点(共 {} 个)===\n", ROUTES.len());
    println!("{table}");
    Ok(0)
}

fn execute_list_capabilities() -> Result<i32, CliError> {
    let rows: Vec<CapabilityRow> = CAPABILITIES
        .iter()
        .map(|(name, desc, tags)| CapabilityRow {
            name,
            description: desc,
            tags,
        })
        .collect();
    let table = Table::new(rows);
    println!(
        "=== Admin 插件 Capability(共 {} 个)===\n",
        CAPABILITIES.len()
    );
    println!("{table}");
    Ok(0)
}

async fn execute_init(args: &InitArgs) -> Result<i32, CliError> {
    if args.url.is_none() {
        println!("=== Admin 插件初始化(离线模式)===\n");
        println!("将创建以下初始数据:");
        println!("  1. 内置角色: super_admin(超级管理员)、tenant_admin(租户管理员)");
        println!("  2. 内置权限项: admin:* 系列({} 个)", CAPABILITIES.len());
        println!("  3. 超级管理员账户: {} (密码: ***)", args.username);
        println!("\n使用 --url <DATABASE_URL> 执行在线初始化");
        return Ok(0);
    }

    let url = args
        .url
        .as_deref()
        .ok_or_else(|| CliError::Clap("admin init 在线模式必须提供 --url".to_string()))?;
    println!("=== Admin 插件初始化 ===\n");
    println!("连接数据库...");
    let mut conn = create_connection(url, &args.db_type).await?;

    println!("创建内置角色...");
    for (code, name, desc) in [
        ("super_admin", "超级管理员", "拥有所有权限,不可删除"),
        ("tenant_admin", "租户管理员", "租户内管理权限,不可删除"),
    ] {
        let sql = "INSERT INTO roles (name, code, description, is_builtin, tenant_id) \
              VALUES (?, ?, ?, TRUE, 0) \
              ON CONFLICT (code, tenant_id) DO NOTHING";
        conn.execute_with_params(
            sql,
            &[
                Value::String(name.to_string()),
                Value::String(code.to_string()),
                Value::String(desc.to_string()),
            ],
        )
        .await
        .map_err(|e| CliError::Migration(format!("创建角色 {code} 失败: {e}")))?;
        println!("  角色 {} ({}) 已创建", code, name);
    }

    println!("创建内置权限项...");
    for (cap_name, cap_desc, _tags) in CAPABILITIES {
        let module = cap_name.split('.').nth(1).unwrap_or("admin");
        let sql = "INSERT INTO permissions (code, name, module, description, tenant_id) \
              VALUES (?, ?, ?, ?, 0) \
              ON CONFLICT (code, tenant_id) DO NOTHING";
        conn.execute_with_params(
            sql,
            &[
                Value::String(cap_name.to_string()),
                Value::String(cap_desc.to_string()),
                Value::String(module.to_string()),
                Value::String(cap_desc.to_string()),
            ],
        )
        .await
        .map_err(|e| CliError::Migration(format!("创建权限 {cap_name} 失败: {e}")))?;
    }
    println!("  {} 个权限项已创建", CAPABILITIES.len());

    println!("为 super_admin 分配所有权限...");
    let sql = "INSERT INTO role_permissions (role_id, permission_code, tenant_id) \
         SELECT r.id, p.code, 0 FROM roles r, permissions p \
         WHERE r.code = 'super_admin' AND r.tenant_id = 0 \
         AND p.tenant_id = 0 \
         ON CONFLICT (role_id, permission_code, tenant_id) DO NOTHING";
    conn.execute(sql)
        .await
        .map_err(|e| CliError::Migration(format!("分配权限失败: {e}")))?;
    println!("  super_admin 已分配全部权限");

    println!("创建超级管理员账户...");
    let hashed = bcrypt::hash(&args.password, 10)
        .map_err(|e| CliError::Migration(format!("密码哈希失败: {e}")))?;
    conn.execute_with_params(
        "INSERT INTO users (username, password, status, tenant_id) \
         VALUES (?, ?, 'active', 0) \
         ON CONFLICT (username, tenant_id) DO NOTHING",
        &[Value::String(args.username.clone()), Value::String(hashed)],
    )
    .await
    .map_err(|e| CliError::Migration(format!("创建管理员账户失败: {e}")))?;

    conn.execute_with_params(
        "INSERT INTO user_roles (user_id, role_id, tenant_id) \
         SELECT u.id, r.id, 0 FROM users u, roles r \
         WHERE u.username = ? AND u.tenant_id = 0 \
         AND r.code = 'super_admin' AND r.tenant_id = 0 \
         ON CONFLICT (user_id, role_id, tenant_id) DO NOTHING",
        &[Value::String(args.username.clone())],
    )
    .await
    .map_err(|e| CliError::Migration(format!("分配管理员角色失败: {e}")))?;
    println!("  账户 {} 已创建并分配 super_admin 角色", args.username);

    println!("\n初始化完成!");
    println!("  超级管理员: {}", args.username);
    println!("  请及时修改默认密码!");
    Ok(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[derive(Parser)]
    struct TestCli {
        #[command(subcommand)]
        admin: AdminCommand,
    }

    #[test]
    fn test_parse_migrate() {
        let cli = TestCli::parse_from(["sz-rust", "migrate", "--db-type", "postgres"]);
        assert!(matches!(cli.admin, AdminCommand::Migrate { .. }));
    }

    #[test]
    fn test_parse_migrate_with_url() {
        let cli = TestCli::parse_from([
            "sz-rust",
            "migrate",
            "--url",
            "postgres://localhost/db",
            "--show-sql",
        ]);
        match cli.admin {
            AdminCommand::Migrate(args) => {
                assert_eq!(args.url.as_deref(), Some("postgres://localhost/db"));
                assert!(args.show_sql);
            }
            _ => panic!("expected Migrate"),
        }
    }

    #[test]
    fn test_parse_list_routes() {
        let cli = TestCli::parse_from(["sz-rust", "list-routes"]);
        assert!(matches!(cli.admin, AdminCommand::ListRoutes));
    }

    #[test]
    fn test_parse_list_capabilities() {
        let cli = TestCli::parse_from(["sz-rust", "list-capabilities"]);
        assert!(matches!(cli.admin, AdminCommand::ListCapabilities));
    }

    #[test]
    fn test_parse_init() {
        let cli = TestCli::parse_from([
            "sz-rust",
            "init",
            "--username",
            "myadmin",
            "--password",
            "mypass",
        ]);
        match cli.admin {
            AdminCommand::Init(args) => {
                assert_eq!(args.username, "myadmin");
                assert_eq!(args.password, "mypass");
            }
            _ => panic!("expected Init"),
        }
    }

    #[test]
    fn test_routes_count_21() {
        assert_eq!(ROUTES.len(), 21);
    }

    #[test]
    fn test_capabilities_count_17() {
        assert_eq!(CAPABILITIES.len(), 17);
    }

    #[test]
    fn test_all_capabilities_prefixed_admin() {
        for (name, _, _) in CAPABILITIES {
            assert!(
                name.starts_with("admin."),
                "capability {name} must start with admin."
            );
        }
    }

    #[tokio::test]
    async fn test_execute_list_routes() {
        let result = execute(&AdminCommand::ListRoutes).await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 0);
    }

    #[tokio::test]
    async fn test_execute_list_capabilities() {
        let result = execute(&AdminCommand::ListCapabilities).await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 0);
    }

    #[tokio::test]
    async fn test_execute_migrate_offline() {
        let args = MigrateArgs {
            db_type: "postgres".to_string(),
            url: None,
            show_sql: true,
        };
        let result = execute(&AdminCommand::Migrate(args)).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_execute_init_offline() {
        let args = InitArgs {
            db_type: "postgres".to_string(),
            url: None,
            username: "admin".to_string(),
            password: "admin123".to_string(),
        };
        let result = execute(&AdminCommand::Init(args)).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_execute_migrate_offline_no_show_sql() {
        let args = MigrateArgs {
            db_type: "postgres".to_string(),
            url: None,
            show_sql: false,
        };
        let result = execute(&AdminCommand::Migrate(args)).await;
        assert!(result.is_ok());
    }

    #[test]
    fn test_route_row_tabled() {
        let row = RouteRow {
            method: "GET",
            path: "/api/admin/users",
            description: "用户列表",
        };
        let table = Table::new(vec![row]);
        let s = table.to_string();
        assert!(s.contains("GET"));
        assert!(s.contains("/api/admin/users"));
    }

    #[test]
    fn test_capability_row_tabled() {
        let row = CapabilityRow {
            name: "admin.user_list",
            description: "用户列表查询",
            tags: "user,query",
        };
        let table = Table::new(vec![row]);
        let s = table.to_string();
        assert!(s.contains("admin.user_list"));
    }

    #[test]
    fn test_migration_sql_not_empty() {
        assert!(!MIGRATION_SQL.is_empty());
        assert!(MIGRATION_SQL.contains("CREATE TABLE"));
    }
}