feat: 新增管理后台前端项目及安全加固
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

refactor(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流

fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型

feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入

chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点

docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明

test: 完善配置解析单元测试
- 新增环境变量插值测试用例
This commit is contained in:
iven
2026-03-31 00:11:33 +08:00
parent 6821df5f44
commit eb956d0dce
129 changed files with 11913 additions and 863 deletions

7
.cargo/config.toml Normal file
View File

@@ -0,0 +1,7 @@
# Reduce parallel compilation jobs to prevent compiler OOM.
# The desktop crate + its dependencies (tauri, sqlx, fantoccini, etc.)
# consume significant memory during borrow checking / type inference.
#
# If builds still OOM, try lowering further (e.g. 2 or 1).
[build]
jobs = 2

463
65-90p Normal file
View File

@@ -0,0 +1,463 @@
00000000: 2f2f 2120 5a43 4c41 5720 5361 6153 20e6 //! ZCLAW SaaS .
00000010: 9c8d e58a a1e5 85a5 e58f a30d 0a0d 0a75 ...............u
00000020: 7365 2061 7875 6d3a 3a65 7874 7261 6374 se axum::extract
00000030: 3a3a 5374 6174 653b 0d0a 7573 6520 746f ::State;..use to
00000040: 7765 725f 6874 7470 3a3a 7469 6d65 6f75 wer_http::timeou
00000050: 743a 3a54 696d 656f 7574 4c61 7965 723b t::TimeoutLayer;
00000060: 0d0a 7573 6520 7472 6163 696e 673a 3a69 ..use tracing::i
00000070: 6e66 6f3b 0d0a 7573 6520 7a63 6c61 775f nfo;..use zclaw_
00000080: 7361 6173 3a3a 7b63 6f6e 6669 673a 3a53 saas::{config::S
00000090: 6161 5343 6f6e 6669 672c 2064 623a 3a69 aaSConfig, db::i
000000a0: 6e69 745f 6462 2c20 7374 6174 653a 3a41 nit_db, state::A
000000b0: 7070 5374 6174 657d 3b0d 0a75 7365 207a ppState};..use z
000000c0: 636c 6177 5f73 6161 733a 3a77 6f72 6b65 claw_saas::worke
000000d0: 7273 3a3a 576f 726b 6572 4469 7370 6174 rs::WorkerDispat
000000e0: 6368 6572 3b0d 0a75 7365 207a 636c 6177 cher;..use zclaw
000000f0: 5f73 6161 733a 3a77 6f72 6b65 7273 3a3a _saas::workers::
00000100: 6c6f 675f 6f70 6572 6174 696f 6e3a 3a4c log_operation::L
00000110: 6f67 4f70 6572 6174 696f 6e57 6f72 6b65 ogOperationWorke
00000120: 723b 0d0a 7573 6520 7a63 6c61 775f 7361 r;..use zclaw_sa
00000130: 6173 3a3a 776f 726b 6572 733a 3a63 6c65 as::workers::cle
00000140: 616e 7570 5f72 6566 7265 7368 5f74 6f6b anup_refresh_tok
00000150: 656e 733a 3a43 6c65 616e 7570 5265 6672 ens::CleanupRefr
00000160: 6573 6854 6f6b 656e 7357 6f72 6b65 723b eshTokensWorker;
00000170: 0d0a 7573 6520 7a63 6c61 775f 7361 6173 ..use zclaw_saas
00000180: 3a3a 776f 726b 6572 733a 3a63 6c65 616e ::workers::clean
00000190: 7570 5f72 6174 655f 6c69 6d69 743a 3a43 up_rate_limit::C
000001a0: 6c65 616e 7570 5261 7465 4c69 6d69 7457 leanupRateLimitW
000001b0: 6f72 6b65 723b 0d0a 7573 6520 7a63 6c61 orker;..use zcla
000001c0: 775f 7361 6173 3a3a 776f 726b 6572 733a w_saas::workers:
000001d0: 3a72 6563 6f72 645f 7573 6167 653a 3a52 :record_usage::R
000001e0: 6563 6f72 6455 7361 6765 576f 726b 6572 ecordUsageWorker
000001f0: 3b0d 0a75 7365 207a 636c 6177 5f73 6161 ;..use zclaw_saa
00000200: 733a 3a77 6f72 6b65 7273 3a3a 7570 6461 s::workers::upda
00000210: 7465 5f6c 6173 745f 7573 6564 3a3a 5570 te_last_used::Up
00000220: 6461 7465 4c61 7374 5573 6564 576f 726b dateLastUsedWork
00000230: 6572 3b0d 0a0d 0a23 5b74 6f6b 696f 3a3a er;....#[tokio::
00000240: 6d61 696e 5d0d 0a61 7379 6e63 2066 6e20 main]..async fn
00000250: 6d61 696e 2829 202d 3e20 616e 7968 6f77 main() -> anyhow
00000260: 3a3a 5265 7375 6c74 3c28 293e 207b 0d0a ::Result<()> {..
00000270: 2020 2020 7472 6163 696e 675f 7375 6273 tracing_subs
00000280: 6372 6962 6572 3a3a 666d 7428 290d 0a20 criber::fmt()..
00000290: 2020 2020 2020 202e 7769 7468 5f65 6e76 .with_env
000002a0: 5f66 696c 7465 7228 0d0a 2020 2020 2020 _filter(..
000002b0: 2020 2020 2020 7472 6163 696e 675f 7375 tracing_su
000002c0: 6273 6372 6962 6572 3a3a 456e 7646 696c bscriber::EnvFil
000002d0: 7465 723a 3a74 7279 5f66 726f 6d5f 6465 ter::try_from_de
000002e0: 6661 756c 745f 656e 7628 290d 0a20 2020 fault_env()..
000002f0: 2020 2020 2020 2020 2020 2020 202e 756e .un
00000300: 7772 6170 5f6f 725f 656c 7365 287c 5f7c wrap_or_else(|_|
00000310: 2022 7a63 6c61 775f 7361 6173 3d64 6562 "zclaw_saas=deb
00000320: 7567 2c74 6f77 6572 5f68 7474 703d 6465 ug,tower_http=de
00000330: 6275 6722 2e69 6e74 6f28 2929 2c0d 0a20 bug".into()),..
00000340: 2020 2020 2020 2029 0d0a 2020 2020 2020 )..
00000350: 2020 2e69 6e69 7428 293b 0d0a 0d0a 2020 .init();....
00000360: 2020 6c65 7420 636f 6e66 6967 203d 2053 let config = S
00000370: 6161 5343 6f6e 6669 673a 3a6c 6f61 6428 aaSConfig::load(
00000380: 293f 3b0d 0a20 2020 2069 6e66 6f21 2822 )?;.. info!("
00000390: 5361 6153 2063 6f6e 6669 6720 6c6f 6164 SaaS config load
000003a0: 6564 3a20 7b7d 3a7b 7d22 2c20 636f 6e66 ed: {}:{}", conf
000003b0: 6967 2e73 6572 7665 722e 686f 7374 2c20 ig.server.host,
000003c0: 636f 6e66 6967 2e73 6572 7665 722e 706f config.server.po
000003d0: 7274 293b 0d0a 0d0a 2020 2020 6c65 7420 rt);.... let
000003e0: 6462 203d 2069 6e69 745f 6462 2826 636f db = init_db(&co
000003f0: 6e66 6967 2e64 6174 6162 6173 652e 7572 nfig.database.ur
00000400: 6c29 2e61 7761 6974 3f3b 0d0a 2020 2020 l).await?;..
00000410: 696e 666f 2128 2244 6174 6162 6173 6520 info!("Database
00000420: 696e 6974 6961 6c69 7a65 6422 293b 0d0a initialized");..
00000430: 0d0a 2020 2020 2f2f 20e5 889d e5a7 8be5 .. // .......
00000440: 8c96 2057 6f72 6b65 7220 e8b0 83e5 baa6 .. Worker ......
00000450: e599 a820 2b20 e6b3 a8e5 868c e689 80e6 ... + ..........
00000460: 9c89 2057 6f72 6b65 720d 0a20 2020 206c .. Worker.. l
00000470: 6574 206d 7574 2064 6973 7061 7463 6865 et mut dispatche
00000480: 7220 3d20 576f 726b 6572 4469 7370 6174 r = WorkerDispat
00000490: 6368 6572 3a3a 6e65 7728 6462 2e63 6c6f cher::new(db.clo
000004a0: 6e65 2829 293b 0d0a 2020 2020 6469 7370 ne());.. disp
000004b0: 6174 6368 6572 2e72 6567 6973 7465 7228 atcher.register(
000004c0: 4c6f 674f 7065 7261 7469 6f6e 576f 726b LogOperationWork
000004d0: 6572 293b 0d0a 2020 2020 6469 7370 6174 er);.. dispat
000004e0: 6368 6572 2e72 6567 6973 7465 7228 436c cher.register(Cl
000004f0: 6561 6e75 7052 6566 7265 7368 546f 6b65 eanupRefreshToke
00000500: 6e73 576f 726b 6572 293b 0d0a 2020 2020 nsWorker);..
00000510: 6469 7370 6174 6368 6572 2e72 6567 6973 dispatcher.regis
00000520: 7465 7228 436c 6561 6e75 7052 6174 654c ter(CleanupRateL
00000530: 696d 6974 576f 726b 6572 293b 0d0a 2020 imitWorker);..
00000540: 2020 6469 7370 6174 6368 6572 2e72 6567 dispatcher.reg
00000550: 6973 7465 7228 5265 636f 7264 5573 6167 ister(RecordUsag
00000560: 6557 6f72 6b65 7229 3b0d 0a20 2020 2064 eWorker);.. d
00000570: 6973 7061 7463 6865 722e 7265 6769 7374 ispatcher.regist
00000580: 6572 2855 7064 6174 654c 6173 7455 7365 er(UpdateLastUse
00000590: 6457 6f72 6b65 7229 3b0d 0a20 2020 2069 dWorker);.. i
000005a0: 6e66 6f21 2822 576f 726b 6572 2064 6973 nfo!("Worker dis
000005b0: 7061 7463 6865 7220 696e 6974 6961 6c69 patcher initiali
000005c0: 7a65 6420 2835 2077 6f72 6b65 7273 2072 zed (5 workers r
000005d0: 6567 6973 7465 7265 6429 2229 3b0d 0a0d egistered)");...
000005e0: 0a20 2020 206c 6574 2073 7461 7465 203d . let state =
000005f0: 2041 7070 5374 6174 653a 3a6e 6577 2864 AppState::new(d
00000600: 622e 636c 6f6e 6528 292c 2063 6f6e 6669 b.clone(), confi
00000610: 672e 636c 6f6e 6528 292c 2064 6973 7061 g.clone(), dispa
00000620: 7463 6865 7229 3f3b 0d0a 0d0a 2020 2020 tcher)?;....
00000630: 2f2f 20e5 90af e58a a8e5 a3b0 e698 8ee5 // .............
00000640: bc8f 2053 6368 6564 756c 6572 efbc 88e4 .. Scheduler....
00000650: bb8e 2054 4f4d 4c20 e985 8de7 bdae e8af .. TOML ........
00000660: bbe5 8f96 e5ae 9ae6 97b6 e4bb bbe5 8aa1 ................
00000670: efbc 890d 0a20 2020 206c 6574 2073 6368 ..... let sch
00000680: 6564 756c 6572 5f63 6f6e 6669 6720 3d20 eduler_config =
00000690: 2663 6f6e 6669 672e 7363 6865 6475 6c65 &config.schedule
000006a0: 723b 0d0a 2020 2020 7a63 6c61 775f 7361 r;.. zclaw_sa
000006b0: 6173 3a3a 7363 6865 6475 6c65 723a 3a73 as::scheduler::s
000006c0: 7461 7274 5f73 6368 6564 756c 6572 2873 tart_scheduler(s
000006d0: 6368 6564 756c 6572 5f63 6f6e 6669 672c cheduler_config,
000006e0: 2064 622e 636c 6f6e 6528 292c 2073 7461 db.clone(), sta
000006f0: 7465 2e77 6f72 6b65 725f 6469 7370 6174 te.worker_dispat
00000700: 6368 6572 2e63 6c6f 6e65 5f72 6566 2829 cher.clone_ref()
00000710: 293b 0d0a 2020 2020 696e 666f 2128 2253 );.. info!("S
00000720: 6368 6564 756c 6572 2073 7461 7274 6564 cheduler started
00000730: 2077 6974 6820 7b7d 206a 6f62 7322 2c20 with {} jobs",
00000740: 7363 6865 6475 6c65 725f 636f 6e66 6967 scheduler_config
00000750: 2e6a 6f62 732e 6c65 6e28 2929 3b0d 0a0d .jobs.len());...
00000760: 0a20 2020 202f 2f20 e590 afe5 8aa8 e586 . // ........
00000770: 85e7 bdae 2044 4220 e6b8 85e7 9086 e4bb .... DB ........
00000780: bbe5 8aa1 efbc 88e8 aebe e5a4 87e6 b885 ................
00000790: e790 86e7 ad89 e4b8 8de9 809a e8bf 8720 ...............
000007a0: 576f 726b 6572 20e7 9a84 e4bb bbe5 8aa1 Worker .........
000007b0: efbc 890d 0a20 2020 207a 636c 6177 5f73 ..... zclaw_s
000007c0: 6161 733a 3a73 6368 6564 756c 6572 3a3a aas::scheduler::
000007d0: 7374 6172 745f 6462 5f63 6c65 616e 7570 start_db_cleanup
000007e0: 5f74 6173 6b73 2864 622e 636c 6f6e 6528 _tasks(db.clone(
000007f0: 2929 3b0d 0a0d 0a20 2020 202f 2f20 e590 ));.... // ..
00000800: afe5 8aa8 e586 85e5 ad98 e4b8 ade7 9a84 ................
00000810: 2072 6174 6520 6c69 6d69 7420 e69d a1e7 rate limit ....
00000820: 9bae e6b8 85e7 9086 0d0a 2020 2020 6c65 .......... le
00000830: 7420 7261 7465 5f6c 696d 6974 5f73 7461 t rate_limit_sta
00000840: 7465 203d 2073 7461 7465 2e63 6c6f 6e65 te = state.clone
00000850: 2829 3b0d 0a20 2020 2074 6f6b 696f 3a3a ();.. tokio::
00000860: 7370 6177 6e28 6173 796e 6320 6d6f 7665 spawn(async move
00000870: 207b 0d0a 2020 2020 2020 2020 6c65 7420 {.. let
00000880: 6d75 7420 696e 7465 7276 616c 203d 2074 mut interval = t
00000890: 6f6b 696f 3a3a 7469 6d65 3a3a 696e 7465 okio::time::inte
000008a0: 7276 616c 2873 7464 3a3a 7469 6d65 3a3a rval(std::time::
000008b0: 4475 7261 7469 6f6e 3a3a 6672 6f6d 5f73 Duration::from_s
000008c0: 6563 7328 3330 3029 293b 0d0a 2020 2020 ecs(300));..
000008d0: 2020 2020 6c6f 6f70 207b 0d0a 2020 2020 loop {..
000008e0: 2020 2020 2020 2020 696e 7465 7276 616c interval
000008f0: 2e74 6963 6b28 292e 6177 6169 743b 0d0a .tick().await;..
00000900: 2020 2020 2020 2020 2020 2020 7261 7465 rate
00000910: 5f6c 696d 6974 5f73 7461 7465 2e63 6c65 _limit_state.cle
00000920: 616e 7570 5f72 6174 655f 6c69 6d69 745f anup_rate_limit_
00000930: 656e 7472 6965 7328 293b 0d0a 2020 2020 entries();..
00000940: 2020 2020 7d0d 0a20 2020 207d 293b 0d0a }.. });..
00000950: 0d0a 2020 2020 6c65 7420 6170 7020 3d20 .. let app =
00000960: 6275 696c 645f 726f 7574 6572 2873 7461 build_router(sta
00000970: 7465 292e 6177 6169 743b 0d0a 0d0a 2020 te).await;....
00000980: 2020 6c65 7420 6c69 7374 656e 6572 203d let listener =
00000990: 2074 6f6b 696f 3a3a 6e65 743a 3a54 6370 tokio::net::Tcp
000009a0: 4c69 7374 656e 6572 3a3a 6269 6e64 2866 Listener::bind(f
000009b0: 6f72 6d61 7421 2822 7b7d 3a7b 7d22 2c20 ormat!("{}:{}",
000009c0: 636f 6e66 6967 2e73 6572 7665 722e 686f config.server.ho
000009d0: 7374 2c20 636f 6e66 6967 2e73 6572 7665 st, config.serve
000009e0: 722e 706f 7274 2929 0d0a 2020 2020 2020 r.port))..
000009f0: 2020 2e61 7761 6974 3f3b 0d0a 2020 2020 .await?;..
00000a00: 696e 666f 2128 2253 6161 5320 7365 7276 info!("SaaS serv
00000a10: 6572 206c 6973 7465 6e69 6e67 206f 6e20 er listening on
00000a20: 7b7d 3a7b 7d22 2c20 636f 6e66 6967 2e73 {}:{}", config.s
00000a30: 6572 7665 722e 686f 7374 2c20 636f 6e66 erver.host, conf
00000a40: 6967 2e73 6572 7665 722e 706f 7274 293b ig.server.port);
00000a50: 0d0a 0d0a 2020 2020 6178 756d 3a3a 7365 .... axum::se
00000a60: 7276 6528 6c69 7374 656e 6572 2c20 6170 rve(listener, ap
00000a70: 702e 696e 746f 5f6d 616b 655f 7365 7276 p.into_make_serv
00000a80: 6963 655f 7769 7468 5f63 6f6e 6e65 6374 ice_with_connect
00000a90: 5f69 6e66 6f3a 3a3c 7374 643a 3a6e 6574 _info::<std::net
00000aa0: 3a3a 536f 636b 6574 4164 6472 3e28 2929 ::SocketAddr>())
00000ab0: 0d0a 2020 2020 2020 2020 2e77 6974 685f .. .with_
00000ac0: 6772 6163 6566 756c 5f73 6875 7464 6f77 graceful_shutdow
00000ad0: 6e28 7368 7574 646f 776e 5f73 6967 6e61 n(shutdown_signa
00000ae0: 6c28 2929 0d0a 2020 2020 2020 2020 2e61 l()).. .a
00000af0: 7761 6974 3f3b 0d0a 2020 2020 4f6b 2828 wait?;.. Ok((
00000b00: 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 2066 ))..}....async f
00000b10: 6e20 6865 616c 7468 5f68 616e 646c 6572 n health_handler
00000b20: 2853 7461 7465 2873 7461 7465 293a 2053 (State(state): S
00000b30: 7461 7465 3c41 7070 5374 6174 653e 2920 tate<AppState>)
00000b40: 2d3e 2061 7875 6d3a 3a4a 736f 6e3c 7365 -> axum::Json<se
00000b50: 7264 655f 6a73 6f6e 3a3a 5661 6c75 653e rde_json::Value>
00000b60: 207b 0d0a 2020 2020 2f2f 2068 6561 6c74 {.. // healt
00000b70: 6820 e5bf 85e9 a1bb e78b ace7 ab8b e5bf h ..............
00000b80: abe9 809f e8bf 94e5 9b9e efbc 8ce7 94a8 ................
00000b90: 2033 7320 e8b6 85e6 97b6 e981 bfe5 858d 3s ............
00000ba0: e8bf 9ee6 8ea5 e6b1 a0e6 bba1 e697 b6e9 ................
00000bb0: 98bb e5a1 9e0d 0a20 2020 206c 6574 2064 ....... let d
00000bc0: 625f 6865 616c 7468 7920 3d20 746f 6b69 b_healthy = toki
00000bd0: 6f3a 3a74 696d 653a 3a74 696d 656f 7574 o::time::timeout
00000be0: 280d 0a20 2020 2020 2020 2073 7464 3a3a (.. std::
00000bf0: 7469 6d65 3a3a 4475 7261 7469 6f6e 3a3a time::Duration::
00000c00: 6672 6f6d 5f73 6563 7328 3329 2c0d 0a20 from_secs(3),..
00000c10: 2020 2020 2020 2073 716c 783a 3a71 7565 sqlx::que
00000c20: 7279 5f73 6361 6c61 723a 3a3c 5f2c 2069 ry_scalar::<_, i
00000c30: 3332 3e28 2253 454c 4543 5420 3122 292e 32>("SELECT 1").
00000c40: 6665 7463 685f 6f6e 6528 2673 7461 7465 fetch_one(&state
00000c50: 2e64 6229 2c0d 0a20 2020 2029 0d0a 2020 .db),.. )..
00000c60: 2020 2e61 7761 6974 0d0a 2020 2020 2e6d .await.. .m
00000c70: 6170 287c 727c 2072 2e69 735f 6f6b 2829 ap(|r| r.is_ok()
00000c80: 290d 0a20 2020 202e 756e 7772 6170 5f6f ).. .unwrap_o
00000c90: 7228 6661 6c73 6529 3b0d 0a0d 0a20 2020 r(false);....
00000ca0: 206c 6574 2073 7461 7475 7320 3d20 6966 let status = if
00000cb0: 2064 625f 6865 616c 7468 7920 7b20 2268 db_healthy { "h
00000cc0: 6561 6c74 6879 2220 7d20 656c 7365 207b ealthy" } else {
00000cd0: 2022 6465 6772 6164 6564 2220 7d3b 0d0a "degraded" };..
00000ce0: 2020 2020 6c65 7420 5f63 6f64 6520 3d20 let _code =
00000cf0: 6966 2064 625f 6865 616c 7468 7920 7b20 if db_healthy {
00000d00: 3230 3020 7d20 656c 7365 207b 2035 3033 200 } else { 503
00000d10: 207d 3b0d 0a0d 0a20 2020 2061 7875 6d3a };.... axum:
00000d20: 3a4a 736f 6e28 7365 7264 655f 6a73 6f6e :Json(serde_json
00000d30: 3a3a 6a73 6f6e 2128 7b0d 0a20 2020 2020 ::json!({..
00000d40: 2020 2022 7374 6174 7573 223a 2073 7461 "status": sta
00000d50: 7475 732c 0d0a 2020 2020 2020 2020 2264 tus,.. "d
00000d60: 6174 6162 6173 6522 3a20 6462 5f68 6561 atabase": db_hea
00000d70: 6c74 6879 2c0d 0a20 2020 2020 2020 2022 lthy,.. "
00000d80: 7469 6d65 7374 616d 7022 3a20 6368 726f timestamp": chro
00000d90: 6e6f 3a3a 5574 633a 3a6e 6f77 2829 2e74 no::Utc::now().t
00000da0: 6f5f 7266 6333 3333 3928 292c 0d0a 2020 o_rfc3339(),..
00000db0: 2020 2020 2020 2276 6572 7369 6f6e 223a "version":
00000dc0: 2065 6e76 2128 2243 4152 474f 5f50 4b47 env!("CARGO_PKG
00000dd0: 5f56 4552 5349 4f4e 2229 2c0d 0a20 2020 _VERSION"),..
00000de0: 207d 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 }))..}....async
00000df0: 2066 6e20 6275 696c 645f 726f 7574 6572 fn build_router
00000e00: 2873 7461 7465 3a20 4170 7053 7461 7465 (state: AppState
00000e10: 2920 2d3e 2061 7875 6d3a 3a52 6f75 7465 ) -> axum::Route
00000e20: 7220 7b0d 0a20 2020 2075 7365 2061 7875 r {.. use axu
00000e30: 6d3a 3a6d 6964 646c 6577 6172 653b 0d0a m::middleware;..
00000e40: 2020 2020 7573 6520 746f 7765 725f 6874 use tower_ht
00000e50: 7470 3a3a 636f 7273 3a3a 7b41 6e79 2c20 tp::cors::{Any,
00000e60: 436f 7273 4c61 7965 727d 3b0d 0a20 2020 CorsLayer};..
00000e70: 2075 7365 2074 6f77 6572 5f68 7474 703a use tower_http:
00000e80: 3a74 7261 6365 3a3a 5472 6163 654c 6179 :trace::TraceLay
00000e90: 6572 3b0d 0a0d 0a20 2020 2075 7365 2061 er;.... use a
00000ea0: 7875 6d3a 3a68 7474 703a 3a48 6561 6465 xum::http::Heade
00000eb0: 7256 616c 7565 3b0d 0a20 2020 206c 6574 rValue;.. let
00000ec0: 2063 6f72 7320 3d20 7b0d 0a20 2020 2020 cors = {..
00000ed0: 2020 206c 6574 2063 6f6e 6669 6720 3d20 let config =
00000ee0: 7374 6174 652e 636f 6e66 6967 2e72 6561 state.config.rea
00000ef0: 6428 292e 6177 6169 743b 0d0a 2020 2020 d().await;..
00000f00: 2020 2020 6c65 7420 6973 5f64 6576 203d let is_dev =
00000f10: 2073 7464 3a3a 656e 763a 3a76 6172 2822 std::env::var("
00000f20: 5a43 4c41 575f 5341 4153 5f44 4556 2229 ZCLAW_SAAS_DEV")
00000f30: 0d0a 2020 2020 2020 2020 2020 2020 2e6d .. .m
00000f40: 6170 287c 767c 2076 203d 3d20 2274 7275 ap(|v| v == "tru
00000f50: 6522 207c 7c20 7620 3d3d 2022 3122 290d e" || v == "1").
00000f60: 0a20 2020 2020 2020 2020 2020 202e 756e . .un
00000f70: 7772 6170 5f6f 7228 6661 6c73 6529 3b0d wrap_or(false);.
00000f80: 0a20 2020 2020 2020 2069 6620 636f 6e66 . if conf
00000f90: 6967 2e73 6572 7665 722e 636f 7273 5f6f ig.server.cors_o
00000fa0: 7269 6769 6e73 2e69 735f 656d 7074 7928 rigins.is_empty(
00000fb0: 2920 7b0d 0a20 2020 2020 2020 2020 2020 ) {..
00000fc0: 2069 6620 6973 5f64 6576 207b 0d0a 2020 if is_dev {..
00000fd0: 2020 2020 2020 2020 2020 2020 2020 436f Co
00000fe0: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00000ff0: 2020 2020 2020 2020 2020 2020 2020 2020
00001000: 2020 2020 2e61 6c6c 6f77 5f6f 7269 6769 .allow_origi
00001010: 6e28 416e 7929 0d0a 2020 2020 2020 2020 n(Any)..
00001020: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001030: 6f77 5f6d 6574 686f 6473 2841 6e79 290d ow_methods(Any).
00001040: 0a20 2020 2020 2020 2020 2020 2020 2020 .
00001050: 2020 2020 202e 616c 6c6f 775f 6865 6164 .allow_head
00001060: 6572 7328 416e 7929 0d0a 2020 2020 2020 ers(Any)..
00001070: 2020 2020 2020 7d20 656c 7365 207b 0d0a } else {..
00001080: 2020 2020 2020 2020 2020 2020 2020 2020
00001090: 7472 6163 696e 673a 3a65 7272 6f72 2128 tracing::error!(
000010a0: 22e7 949f e4ba a7e7 8eaf e5a2 83e5 bf85 "...............
000010b0: e9a1 bbe9 858d e7bd ae20 7365 7276 6572 ......... server
000010c0: 2e63 6f72 735f 6f72 6967 696e 73ef bc8c .cors_origins...
000010d0: e4b8 8de8 83bd e4bd bfe7 94a8 2061 6c6c ............ all
000010e0: 6f77 5f6f 7269 6769 6e28 416e 7929 2229 ow_origin(Any)")
000010f0: 3b0d 0a20 2020 2020 2020 2020 2020 2020 ;..
00001100: 2020 2070 616e 6963 2128 22e7 949f e4ba panic!(".....
00001110: a7e7 8eaf e5a2 83e5 bf85 e9a1 bbe9 858d ................
00001120: e7bd ae20 7365 7276 6572 2e63 6f72 735f ... server.cors_
00001130: 6f72 6967 696e 7320 e799 bde5 908d e58d origins ........
00001140: 95e3 8082 e5bc 80e5 8f91 e78e afe5 a283 ................
00001150: e58f afe8 aebe e7bd ae20 5a43 4c41 575f ......... ZCLAW_
00001160: 5341 4153 5f44 4556 3d74 7275 6520 e7bb SAAS_DEV=true ..
00001170: 95e8 bf87 e380 8222 293b 0d0a 2020 2020 .......");..
00001180: 2020 2020 2020 2020 7d0d 0a20 2020 2020 }..
00001190: 2020 207d 2065 6c73 6520 7b0d 0a20 2020 } else {..
000011a0: 2020 2020 2020 2020 206c 6574 206f 7269 let ori
000011b0: 6769 6e73 3a20 5665 633c 4865 6164 6572 gins: Vec<Header
000011c0: 5661 6c75 653e 203d 2063 6f6e 6669 672e Value> = config.
000011d0: 7365 7276 6572 2e63 6f72 735f 6f72 6967 server.cors_orig
000011e0: 696e 732e 6974 6572 2829 0d0a 2020 2020 ins.iter()..
000011f0: 2020 2020 2020 2020 2020 2020 2e66 696c .fil
00001200: 7465 725f 6d61 7028 7c6f 3a20 2653 7472 ter_map(|o: &Str
00001210: 696e 677c 206f 2e70 6172 7365 3a3a 3c48 ing| o.parse::<H
00001220: 6561 6465 7256 616c 7565 3e28 292e 6f6b eaderValue>().ok
00001230: 2829 290d 0a20 2020 2020 2020 2020 2020 ())..
00001240: 2020 2020 202e 636f 6c6c 6563 7428 293b .collect();
00001250: 0d0a 2020 2020 2020 2020 2020 2020 436f .. Co
00001260: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00001270: 2020 2020 2020 2020 2020 2020 2020 2020
00001280: 2e61 6c6c 6f77 5f6f 7269 6769 6e28 6f72 .allow_origin(or
00001290: 6967 696e 7329 0d0a 2020 2020 2020 2020 igins)..
000012a0: 2020 2020 2020 2020 2e61 6c6c 6f77 5f6d .allow_m
000012b0: 6574 686f 6473 285b 0d0a 2020 2020 2020 ethods([..
000012c0: 2020 2020 2020 2020 2020 2020 2020 6178 ax
000012d0: 756d 3a3a 6874 7470 3a3a 4d65 7468 6f64 um::http::Method
000012e0: 3a3a 4745 542c 0d0a 2020 2020 2020 2020 ::GET,..
000012f0: 2020 2020 2020 2020 2020 2020 6178 756d axum
00001300: 3a3a 6874 7470 3a3a 4d65 7468 6f64 3a3a ::http::Method::
00001310: 504f 5354 2c0d 0a20 2020 2020 2020 2020 POST,..
00001320: 2020 2020 2020 2020 2020 2061 7875 6d3a axum:
00001330: 3a68 7474 703a 3a4d 6574 686f 643a 3a50 :http::Method::P
00001340: 5554 2c0d 0a20 2020 2020 2020 2020 2020 UT,..
00001350: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001360: 7474 703a 3a4d 6574 686f 643a 3a50 4154 ttp::Method::PAT
00001370: 4348 2c0d 0a20 2020 2020 2020 2020 2020 CH,..
00001380: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001390: 7474 703a 3a4d 6574 686f 643a 3a44 454c ttp::Method::DEL
000013a0: 4554 452c 0d0a 2020 2020 2020 2020 2020 ETE,..
000013b0: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
000013c0: 6874 7470 3a3a 4d65 7468 6f64 3a3a 4f50 http::Method::OP
000013d0: 5449 4f4e 532c 0d0a 2020 2020 2020 2020 TIONS,..
000013e0: 2020 2020 2020 2020 5d29 0d0a 2020 2020 ])..
000013f0: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001400: 6f77 5f68 6561 6465 7273 285b 0d0a 2020 ow_headers([..
00001410: 2020 2020 2020 2020 2020 2020 2020 2020
00001420: 2020 6178 756d 3a3a 6874 7470 3a3a 6865 axum::http::he
00001430: 6164 6572 3a3a 4155 5448 4f52 495a 4154 ader::AUTHORIZAT
00001440: 494f 4e2c 0d0a 2020 2020 2020 2020 2020 ION,..
00001450: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
00001460: 6874 7470 3a3a 6865 6164 6572 3a3a 434f http::header::CO
00001470: 4e54 454e 545f 5459 5045 2c0d 0a20 2020 NTENT_TYPE,..
00001480: 2020 2020 2020 2020 2020 2020 2020 2020
00001490: 2061 7875 6d3a 3a68 7474 703a 3a48 6561 axum::http::Hea
000014a0: 6465 724e 616d 653a 3a66 726f 6d5f 7374 derName::from_st
000014b0: 6174 6963 2822 782d 7265 7175 6573 742d atic("x-request-
000014c0: 6964 2229 2c0d 0a20 2020 2020 2020 2020 id"),..
000014d0: 2020 2020 2020 205d 290d 0a20 2020 2020 ])..
000014e0: 2020 207d 0d0a 2020 2020 7d3b 0d0a 0d0a }.. };....
000014f0: 2020 2020 6c65 7420 7075 626c 6963 5f72 let public_r
00001500: 6f75 7465 7320 3d20 7a63 6c61 775f 7361 outes = zclaw_sa
00001510: 6173 3a3a 6175 7468 3a3a 726f 7574 6573 as::auth::routes
00001520: 2829 0d0a 2020 2020 2020 2020 2e72 6f75 ().. .rou
00001530: 7465 2822 2f61 7069 2f68 6561 6c74 6822 te("/api/health"
00001540: 2c20 6178 756d 3a3a 726f 7574 696e 673a , axum::routing:
00001550: 3a67 6574 2868 6561 6c74 685f 6861 6e64 :get(health_hand
00001560: 6c65 7229 290d 0a20 2020 2020 2020 202e ler)).. .
00001570: 6c61 7965 7228 6d69 6464 6c65 7761 7265 layer(middleware
00001580: 3a3a 6672 6f6d 5f66 6e5f 7769 7468 5f73 ::from_fn_with_s
00001590: 7461 7465 280d 0a20 2020 2020 2020 2020 tate(..
000015a0: 2020 2073 7461 7465 2e63 6c6f 6e65 2829 state.clone()
000015b0: 2c0d 0a20 2020 2020 2020 2020 2020 207a ,.. z
000015c0: 636c 6177 5f73 6161 733a 3a6d 6964 646c claw_saas::middl
000015d0: 6577 6172 653a 3a70 7562 6c69 635f 7261 eware::public_ra
000015e0: 7465 5f6c 696d 6974 5f6d 6964 646c 6577 te_limit_middlew
000015f0: 6172 652c 0d0a 2020 2020 2020 2020 2929 are,.. ))
00001600: 3b0d 0a0d 0a20 2020 206c 6574 2070 726f ;.... let pro
00001610: 7465 6374 6564 5f72 6f75 7465 7320 3d20 tected_routes =
00001620: 7a63 6c61 775f 7361 6173 3a3a 6175 7468 zclaw_saas::auth
00001630: 3a3a 7072 6f74 6563 7465 645f 726f 7574 ::protected_rout
00001640: 6573 2829 0d0a 2020 2020 2020 2020 2e6d es().. .m
00001650: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
00001660: 3a61 6363 6f75 6e74 3a3a 726f 7574 6573 :account::routes
00001670: 2829 290d 0a20 2020 2020 2020 202e 6d65 ()).. .me
00001680: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001690: 6d6f 6465 6c5f 636f 6e66 6967 3a3a 726f model_config::ro
000016a0: 7574 6573 2829 290d 0a20 2020 2020 2020 utes())..
000016b0: 202e 6d65 7267 6528 7a63 6c61 775f 7361 .merge(zclaw_sa
000016c0: 6173 3a3a 7265 6c61 793a 3a72 6f75 7465 as::relay::route
000016d0: 7328 2929 0d0a 2020 2020 2020 2020 2e6d s()).. .m
000016e0: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
000016f0: 3a6d 6967 7261 7469 6f6e 3a3a 726f 7574 :migration::rout
00001700: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
00001710: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
00001720: 3a3a 726f 6c65 3a3a 726f 7574 6573 2829 ::role::routes()
00001730: 290d 0a20 2020 2020 2020 202e 6d65 7267 ).. .merg
00001740: 6528 7a63 6c61 775f 7361 6173 3a3a 7072 e(zclaw_saas::pr
00001750: 6f6d 7074 3a3a 726f 7574 6573 2829 290d ompt::routes()).
00001760: 0a20 2020 2020 2020 202e 6d65 7267 6528 . .merge(
00001770: 7a63 6c61 775f 7361 6173 3a3a 6167 656e zclaw_saas::agen
00001780: 745f 7465 6d70 6c61 7465 3a3a 726f 7574 t_template::rout
00001790: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
000017a0: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
000017b0: 3a3a 7465 6c65 6d65 7472 793a 3a72 6f75 ::telemetry::rou
000017c0: 7465 7328 2929 0d0a 2020 2020 2020 2020 tes())..
000017d0: 2e6c 6179 6572 286d 6964 646c 6577 6172 .layer(middlewar
000017e0: 653a 3a66 726f 6d5f 666e 5f77 6974 685f e::from_fn_with_
000017f0: 7374 6174 6528 0d0a 2020 2020 2020 2020 state(..
00001800: 2020 2020 7374 6174 652e 636c 6f6e 6528 state.clone(
00001810: 292c 0d0a 2020 2020 2020 2020 2020 2020 ),..
00001820: 7a63 6c61 775f 7361 6173 3a3a 6d69 6464 zclaw_saas::midd
00001830: 6c65 7761 7265 3a3a 6170 695f 7665 7273 leware::api_vers
00001840: 696f 6e5f 6d69 6464 6c65 7761 7265 2c0d ion_middleware,.
00001850: 0a20 2020 2020 2020 2029 290d 0a20 2020 . ))..
00001860: 2020 2020 202e 6c61 7965 7228 6d69 6464 .layer(midd
00001870: 6c65 7761 7265 3a3a 6672 6f6d 5f66 6e5f leware::from_fn_
00001880: 7769 7468 5f73 7461 7465 280d 0a20 2020 with_state(..
00001890: 2020 2020 2020 2020 2073 7461 7465 2e63 state.c
000018a0: 6c6f 6e65 2829 2c0d 0a20 2020 2020 2020 lone(),..
000018b0: 2020 2020 207a 636c 6177 5f73 6161 733a zclaw_saas:
000018c0: 3a6d 6964 646c 6577 6172 653a 3a72 6571 :middleware::req
000018d0: 7565 7374 5f69 645f 6d69 6464 6c65 7761 uest_id_middlewa
000018e0: 7265 2c0d 0a20 2020 2020 2020 2029 290d re,.. )).
000018f0: 0a20 2020 2020 2020 202e 6c61 7965 7228 . .layer(
00001900: 6d69 6464 6c65 7761 7265 3a3a 6672 6f6d middleware::from
00001910: 5f66 6e5f 7769 7468 5f73 7461 7465 280d _fn_with_state(.
00001920: 0a20 2020 2020 2020 2020 2020 2073 7461 . sta
00001930: 7465 2e63 6c6f 6e65 2829 2c0d 0a20 2020 te.clone(),..
00001940: 2020 2020 2020 2020 207a 636c 6177 5f73 zclaw_s
00001950: 6161 733a 3a6d 6964 646c 6577 6172 653a aas::middleware:
00001960: 3a72 6174 655f 6c69 6d69 745f 6d69 6464 :rate_limit_midd
00001970: 6c65 7761 7265 2c0d 0a20 2020 2020 2020 leware,..
00001980: 2029 290d 0a20 2020 2020 2020 202e 6c61 )).. .la
00001990: 7965 7228 6d69 6464 6c65 7761 7265 3a3a yer(middleware::
000019a0: 6672 6f6d 5f66 6e5f 7769 7468 5f73 7461 from_fn_with_sta
000019b0: 7465 280d 0a20 2020 2020 2020 2020 2020 te(..
000019c0: 2073 7461 7465 2e63 6c6f 6e65 2829 2c0d state.clone(),.
000019d0: 0a20 2020 2020 2020 2020 2020 207a 636c . zcl
000019e0: 6177 5f73 6161 733a 3a61 7574 683a 3a61 aw_saas::auth::a
000019f0: 7574 685f 6d69 6464 6c65 7761 7265 2c0d uth_middleware,.
00001a00: 0a20 2020 2020 2020 2029 293b 0d0a 0d0a . ));....
00001a10: 2020 2020 2f2f 20e9 9d9e e6b5 81e5 bc8f // .........
00001a20: e8b7 afe7 94b1 e5ba 94e7 94a8 e585 a8e5 ................
00001a30: b180 2031 3573 20e8 b685 e697 b6ef bc88 .. 15s .........
00001a40: 7265 6c61 7920 5353 4520 e7ab afe7 82b9 relay SSE ......
00001a50: e99c 80e8 a681 e69b b4e9 95bf e8b6 85e6 ................
00001a60: 97b6 efbc 890d 0a20 2020 206c 6574 206e ....... let n
00001a70: 6f6e 5f73 7472 6561 6d69 6e67 5f72 6f75 on_streaming_rou
00001a80: 7465 7320 3d20 6178 756d 3a3a 526f 7574 tes = axum::Rout
00001a90: 6572 3a3a 6e65 7728 290d 0a20 2020 2020 er::new()..
00001aa0: 2020 202e 6d65 7267 6528 7075 626c 6963 .merge(public
00001ab0: 5f72 6f75 7465 7329 0d0a 2020 2020 2020 _routes)..
00001ac0: 2020 2e6d 6572 6765 2870 726f 7465 6374 .merge(protect
00001ad0: 6564 5f72 6f75 7465 7329 0d0a 2020 2020 ed_routes)..
00001ae0: 2020 2020 2e6c 6179 6572 2854 696d 656f .layer(Timeo
00001af0: 7574 4c61 7965 723a 3a6e 6577 2873 7464 utLayer::new(std
00001b00: 3a3a 7469 6d65 3a3a 4475 7261 7469 6f6e ::time::Duration
00001b10: 3a3a 6672 6f6d 5f73 6563 7328 3135 2929 ::from_secs(15))
00001b20: 293b 0d0a 0d0a 2020 2020 6178 756d 3a3a );.... axum::
00001b30: 526f 7574 6572 3a3a 6e65 7728 290d 0a20 Router::new()..
00001b40: 2020 2020 2020 202e 6d65 7267 6528 6e6f .merge(no
00001b50: 6e5f 7374 7265 616d 696e 675f 726f 7574 n_streaming_rout
00001b60: 6573 290d 0a20 2020 2020 2020 202e 6d65 es).. .me
00001b70: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001b80: 7265 6c61 793a 3a72 6f75 7465 7328 2929 relay::routes())
00001b90: 0d0a 2020 2020 2020 2020 2e6c 6179 6572 .. .layer
00001ba0: 2854 7261 6365 4c61 7965 723a 3a6e 6577 (TraceLayer::new
00001bb0: 5f66 6f72 5f68 7474 7028 2929 0d0a 2020 _for_http())..
00001bc0: 2020 2020 2020 2e6c 6179 6572 2863 6f72 .layer(cor
00001bd0: 7329 0d0a 2020 2020 2020 2020 2e77 6974 s).. .wit
00001be0: 685f 7374 6174 6528 7374 6174 6529 0d0a h_state(state)..
00001bf0: 7d0d 0a0d 0a2f 2f2f 20e7 9b91 e590 ac20 }..../// ......
00001c00: 4374 726c 2b43 20e4 bfa1 e58f b7ef bc8c Ctrl+C .........
00001c10: e8a7 a6e5 8f91 2067 7261 6365 6675 6c20 ...... graceful
00001c20: 7368 7574 646f 776e 0d0a 6173 796e 6320 shutdown..async
00001c30: 666e 2073 6875 7464 6f77 6e5f 7369 676e fn shutdown_sign
00001c40: 616c 2829 207b 0d0a 2020 2020 746f 6b69 al() {.. toki
00001c50: 6f3a 3a73 6967 6e61 6c3a 3a63 7472 6c5f o::signal::ctrl_
00001c60: 6328 290d 0a20 2020 2020 2020 202e 6177 c().. .aw
00001c70: 6169 740d 0a20 2020 2020 2020 202e 6578 ait.. .ex
00001c80: 7065 6374 2822 4661 696c 6564 2074 6f20 pect("Failed to
00001c90: 696e 7374 616c 6c20 4374 726c 2b43 2068 install Ctrl+C h
00001ca0: 616e 646c 6572 2229 3b0d 0a20 2020 2069 andler");.. i
00001cb0: 6e66 6f21 2822 5265 6365 6976 6564 2073 nfo!("Received s
00001cc0: 6875 7464 6f77 6e20 7369 676e 616c 2c20 hutdown signal,
00001cd0: 6472 6169 6e69 6e67 2063 6f6e 6e65 6374 draining connect
00001ce0: 696f 6e73 2e2e 2e22 293b 0d0a 7d0d 0a ions...");..}..

View File

@@ -357,7 +357,81 @@ refactor(store): 统一 Store 数据获取方式
## 12. 安全注意事项
- 不在代码中硬编码密钥
</section>
< + + 寜### 安全注意事项
|`
|--- 不在代码中硬编码密钥`
| - 敄 操作需要确认
` - 不在代码中硬编码密V Token/ API |
| - 保留操作审计日志
` - 用户输入必须验证` ` - 敄 就环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`) + ` - **生产环境 TLS 终止**:
nginx/caddy 反代向提供 HTTPS**
|
| - Cookie `Secure` 标记在生产环境设为 true,开发环境设为 false仅 臉 TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` 必须设置64 字符 hex)
密钥) |
| - **Cookie SameSite=Strict** 鰲止 CSRF)` |
| - Refresh Token 轮换: 退出时DB 撤销为关联, 旧 token` |
| + **Rotation 校验已使用 token 是否已撤销` |
| + **Logout 时撤销 refresh token` |
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS |
| - Cookie Secure 标记: 开发环境 false, 生产 true` |
| + + | **配置说明** |
| - saas-config.toml 支持 `${ENV_VAR}` 稡式环境变量插值,如 `${DB_PASSWORD}` |
| - `ZCLAW_DATABASE_URL` 茉境变量覆盖 |
优先级最高) |
| - **Auth**: /api/auth/login` - 5次/分钟/IP (防暴力破解) |
| - `/api/auth/register` - 3次/小时/IP (防刷注册) |
| - 公共端点默认 20次/分钟/IP (防滥用) |
| - JWT 寰钥: `#[cfg(debug_assertions)]` 保护 fallbackrelease 枋 | ` bail` 拒绝启动` | - TOTP 加密密钥: AES-256-GCM 加密, 支持 SHA-256 崾生 JWT 密钥派生` |
- Logout 撤销: refresh token 到 DB 栘 UPDATE` |
| - Cookie: Secure 标志: 开发环境 false, 生产 true
|
| + + `SameSite=Strict` + 跨站 CSRF + SSL ( CORS) |
| + | TLS 终止:: nginx/caddy 反向代理提供 HTTPS`, 或 |
生产环境日志写入 WAF - | | **TLS 终止说明**: | 反向代理实现 HTTPS | Axum 服务不负责 TLS 配置、 |
`saas-config.toml.example` 更新安全说明 |
| | 密钥管理 | 甤境变量引用 (`${DB_PASSWORD}` 等) |
数据库密码) | | TOML 解析支持 `${VAR}` 稡式环境变量插值, | | 通过 `ZCLAW_DATABASE_URL` 猯变量完整覆盖 (优先级最高) |
| - JWT fallback key | `#[cfg(debug_assertions)]` 保护 fallbackrelease 拒绝启动` | - TOTP/API Key 加密: `AES-256-GCM`, 支持 SHA-256 派生 JWT 密钥派生` | - Logout 时撤销 refresh token 到 DB (`used_at IS NULL` 切 `revoked`) + rotation 校验已撤销的旧 token` | - Cookie Secure: 开发环境 false, 生产 true | `SameSite=Strict` + 跨站 CSRF + SSR CORS 白名单 + `X-Request头 + 请求日志 | |
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS, | - **生产环境日志写入 WAF - | |
| - **配置说明**: `saas-config.toml` 支持 `${ENV_VAR}` 稡式环境变量插值, | 文件模板已示例已更新 |
| - `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (至少 32 字符随机字符串) | | | TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP 加密密钥 (hex 编码, 64 字符) | |
| | SAAS 配置环境变量 | `ZCLAW_SAAS_DEV` 开发环境 |
| `ZCLAW_SAAS_DEV=true` 放宽安全限制 (开发环境: | | 公共端点请求限流 |
| - 公共端点限流 & login/register) | refresh/logout | 默认 | `ZCLAW_SAAS_DEV` 不设置) |
| | **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径="/api" + "/api/v1/auth" + `Secure` 仅在生产环境为 true |
| | **TLS**: 反向代理** 提供 HTTPS 终止** | 反向代理(如 nginx/caddy配置上游 → [SSL 终止 (`proxy downgrade`) |
| **Cookie**: Secure 标记仅在开发环境 (`ZCLAW_SAAS_DEV=true`) 设为 false不强制 HTTPS生产环境设为 true |
| - **环境变量模板**: | | 瘾境命令 |
| - `DB_PASSWORD` | 数据库密码 |
| - `ZCLAW_DATABASE_URL` | 完整数据库连接 URL |
| - `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (≥ 32 字符) |
| - `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
| - `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
| - `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
| - `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
| - **生产环境清单单** |
| | nginx/caddy 配置反向代理 + HTTPS |
| | 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置) |
| | 启用 CORS 白名单 | | | `cors_origins` 匇向实际域名 |
| | Cookie Secure=true + HttpOnly=true + SameSite=Strict |
| - JWT 寋名密钥 >= 32 字符随机字符串 |
| - 数据库密码通过 `${DB_PASSWORD}` 引用 | |
| **部署命令** (参考) |
| | 设置环境变量: `export DB_PASSWORD=your_password` |
| | `export ZCLAW_SAAS_JWT_SECRET=$(openssl rand -hex 32)` |
| | `cp saas-config.toml.example saas-config.toml` |
| | 编辑 saas-config.toml 填入实际数据库 URL |
| | `cargo build --release -p zclaw-saas` |
| | 启动服务: `./zclaw-saas` |- 不在代码中硬编码密钥
- 用户输入必须验证
- 敏感操作需要确认
- 保留操作审计日志

2
Cargo.lock generated
View File

@@ -405,6 +405,7 @@ dependencies = [
"axum",
"axum-core",
"bytes",
"cookie 0.18.1",
"fastrand",
"futures-util",
"headers",
@@ -8384,6 +8385,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"toml 0.8.2",
"totp-rs",
"tower 0.4.13",

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api/v1

24
admin-temp-dir/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
admin-temp-dir/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
admin-temp-dir/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
{
"name": "admin-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/pro-layout": "^7.22.7",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

5008
admin-temp-dir/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,53 @@
import { Component, type ReactNode } from 'react'
import { Result, Button } from 'antd'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
}
private handleReload = () => {
window.location.reload()
}
private handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Result
status="error"
title="页面出现错误"
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
extra={[
<Button key="retry" onClick={this.handleReset}></Button>,
<Button key="reload" type="primary" onClick={this.handleReload}></Button>,
]}
/>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,103 @@
// ============================================================
// AdminLayout — ProLayout 管理后台布局
// ============================================================
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import ProLayout from '@ant-design/pro-layout'
import {
DashboardOutlined,
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
KeyOutlined,
BarChartOutlined,
SwapOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
RobotOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import { useAuthStore } from '@/stores/authStore'
import { Avatar, Dropdown, message } from 'antd'
import type { MenuDataItem } from '@ant-design/pro-layout'
const menuConfig: MenuDataItem[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
]
function filterMenuByPermission(
items: MenuDataItem[],
hasPermission: (p: string) => boolean,
): MenuDataItem[] {
return items
.filter((item) => !item.permission || hasPermission(item.permission as string))
.map(({ permission, ...rest }) => ({
...rest,
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
}))
}
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { account, hasPermission, logout } = useAuthStore()
const menuData = filterMenuByPermission(menuConfig, hasPermission)
const handleLogout = () => {
logout()
message.success('已退出登录')
navigate('/login', { replace: true })
}
return (
<ProLayout
title="ZCLAW"
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
layout="mix"
fixSiderbar
fixedHeader
location={{ pathname: location.pathname }}
menuDataRender={() => menuData}
menuItemRender={(item, dom) => (
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
)}
avatarProps={{
src: undefined,
title: account?.display_name || account?.username || 'Admin',
size: 'small',
render: (_, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
{dom}
</Dropdown>
),
}}
suppressSiderWhenMenuEmpty
contentStyle={{ padding: 24 }}
>
<Outlet />
</ProLayout>
)
}

View File

@@ -0,0 +1,29 @@
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider, App as AntApp } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
import { ErrorBoundary } from './components/ErrorBoundary'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
})
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ConfigProvider locale={zhCN}>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>
</ErrorBoundary>,
)

View File

@@ -0,0 +1,170 @@
// ============================================================
// 账号管理
// ============================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import type { AccountPublic } from '@/types'
const roleLabels: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
user: '用户',
}
const roleColors: Record<string, string> = {
super_admin: 'red',
admin: 'blue',
user: 'default',
}
const statusLabels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
suspended: '已封禁',
}
const statusColors: Record<string, string> = {
active: 'green',
disabled: 'default',
suspended: 'red',
}
export default function Accounts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['accounts'],
queryFn: ({ signal }) => accountService.list(signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
accountService.updateStatus(id, { status }),
onSuccess: () => {
message.success('状态更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
},
onError: (err: Error) => message.error(err.message || '状态更新失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '显示名', dataIndex: 'display_name', width: 120 },
{ title: '邮箱', dataIndex: 'email', width: 180 },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
},
{
title: '2FA',
dataIndex: 'totp_enabled',
width: 80,
render: (_, record) => record.totp_enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '最后登录',
dataIndex: 'last_login_at',
width: 180,
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 200,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
{record.status === 'active' ? (
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
<Button size="small" danger></Button>
</Popconfirm>
) : (
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
<Button size="small" type="primary"></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
}
}
return (
<div>
<ProTable<AccountPublic>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => []}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="编辑账号"
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input type="email" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={[
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '用户' },
]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
import { useState } from 'react'

View File

@@ -0,0 +1,190 @@
// ============================================================
// Agent 模板管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { agentTemplateService } from '@/services/agent-templates'
import type { AgentTemplate } from '@/types'
const { TextArea } = Input
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
export default function AgentTemplates() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['agent-templates'],
queryFn: ({ signal }) => agentTemplateService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
agentTemplateService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (id: string) => agentTemplateService.archive(id),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const columns: ProColumns<AgentTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
},
{
title: '可见性',
dataIndex: 'visibility',
width: 80,
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailRecord(record)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
return (
<div>
<ProTable<AgentTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建 Agent 模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如 assistant, tool" />
</Form.Item>
<Form.Item name="model" label="默认模型">
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} />
</Form.Item>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tokens" label="最大 Token">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visibility" label="可见性">
<Select options={[
{ value: 'public', label: '公开' },
{ value: 'team', label: '团队' },
{ value: 'private', label: '私有' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="模板详情"
open={!!detailRecord}
onCancel={() => setDetailRecord(null)}
footer={null}
width={640}
>
{detailRecord && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
<Descriptions.Item label="系统提示词" span={2}>
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{detailRecord.system_prompt || '-'}
</div>
</Descriptions.Item>
<Descriptions.Item label="工具" span={2}>
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
</Descriptions.Item>
<Descriptions.Item label="能力" span={2}>
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,165 @@
// ============================================================
// API 密钥管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text } = Typography
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: ({ signal }) => apiKeyService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(data),
onSuccess: (result: TokenInfo) => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
if (result.token) {
setNewToken(result.token)
}
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '撤销失败'),
})
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
{
title: '权限',
dataIndex: 'permissions',
width: 200,
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 100,
render: (_, record) => (
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const copyToken = () => {
if (newToken) {
navigator.clipboard.writeText(newToken)
message.success('已复制到剪贴板')
}
}
return (
<div>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="创建 API 密钥"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="给密钥起个名字" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" placeholder="选择权限" options={[
{ value: 'relay:use', label: '中转使用' },
{ value: 'model:read', label: '模型读取' },
{ value: 'config:read', label: '配置读取' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="密钥创建成功"
open={!!newToken}
onOk={() => setNewToken(null)}
onCancel={() => setNewToken(null)}
>
<p></p>
<Input.TextArea
value={newToken || ''}
rows={3}
readOnly
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
/>
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
</Button>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,110 @@
// ============================================================
// 系统配置
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { configService } from '@/services/config'
import type { ConfigItem } from '@/types'
const { Title } = Typography
export default function Config() {
const queryClient = useQueryClient()
const [category, setCategory] = useState<string>('general')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['config', category],
queryFn: ({ signal }) => configService.list({ category }, signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, value }: { id: string; value: string }) =>
configService.update(id, { value }),
onSuccess: () => {
message.success('配置已更新')
queryClient.invalidateQueries({ queryKey: ['config', category] })
setEditingId(null)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const columns: ProColumns<ConfigItem>[] = [
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
{
title: '当前值',
dataIndex: 'current_value',
width: 250,
render: (_, record) => {
if (editingId === record.id) {
return (
<Space>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{ width: 180 }}
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
/>
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
</Button>
<Button size="small" onClick={() => setEditingId(null)}></Button>
</Space>
)
}
return (
<span
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
style={{ cursor: 'pointer', color: '#1677ff' }}
>
{record.current_value || <Tag></Tag>}
</span>
)
},
},
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '需要重启',
dataIndex: 'requires_restart',
width: 90,
render: (_, r) => r.requires_restart ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Tabs
activeKey={category}
onChange={(key) => { setCategory(key); setEditingId(null) }}
items={[
{ key: 'general', label: '通用' },
{ key: 'auth', label: '认证' },
{ key: 'relay', label: '中转' },
{ key: 'model', label: '模型' },
{ key: 'rate_limit', label: '限流' },
{ key: 'log', label: '日志' },
]}
/>
<ProTable<ConfigItem>
columns={columns}
dataSource={data ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</div>
)
}

View File

@@ -0,0 +1,121 @@
// ============================================================
// 仪表盘页面
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
import {
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
ThunderboltOutlined,
ColumnWidthOutlined,
} from '@ant-design/icons'
import { statsService } from '@/services/stats'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
export default function Dashboard() {
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['recent-logs'],
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
})
if (statsError) {
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
}
const statCards = [
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
]
const logColumns = [
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>
{actionLabels[action] || action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{statsLoading ? (
<Col span={24}><Spin /></Col>
) : (
statCards.map((card) => (
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
<Card>
<Statistic
title={card.title}
value={card.value}
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
/>
</Card>
</Col>
))
)}
</Row>
<Card title="最近操作日志" size="small">
<Table<OperationLog>
columns={logColumns}
dataSource={logsData?.items ?? []}
loading={logsLoading}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,138 @@
// ============================================================
// 登录页面
// ============================================================
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { LoginForm, ProFormText } from '@ant-design/pro-components'
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
import { message, Divider, Typography } from 'antd'
import { authService } from '@/services/auth'
import { useAuthStore } from '@/stores/authStore'
import type { LoginRequest } from '@/types'
const { Title, Text } = Typography
export default function Login() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const loginStore = useAuthStore((s) => s.login)
const [needTotp, setNeedTotp] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (values: Record<string, string>) => {
setLoading(true)
try {
const data: LoginRequest = {
username: values.username?.trim() || '',
password: values.password || '',
totp_code: values.totp_code?.trim() || undefined,
}
const res = await authService.login(data)
loginStore(res.token, res.refresh_token, res.account)
message.success('登录成功')
const from = searchParams.get('from') || '/'
navigate(from, { replace: true })
} catch (err: unknown) {
const error = err as { message?: string; status?: number }
const msg = error.message || ''
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
setNeedTotp(true)
message.warning(msg || '请输入两步验证码')
} else {
message.error(msg || '登录失败,请检查用户名和密码')
}
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100vh', display: 'flex' }}>
{/* 左侧品牌区 */}
<div
style={{
flex: '1 1 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
ZCLAW
</Title>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent </Text>
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
AI API
</Text>
</div>
{/* 右侧登录表单 */}
<div
style={{
flex: '0 0 480px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
</Text>
<LoginForm
onFinish={handleSubmit}
submitter={{
searchConfig: { submitText: '登录' },
submitButtonProps: { loading, block: true },
}}
>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined />,
autoComplete: 'username',
}}
placeholder="请输入用户名"
rules={[{ required: true, message: '请输入用户名' }]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder="请输入密码"
rules={[{ required: true, message: '请输入密码' }]}
/>
{needTotp && (
<ProFormText
name="totp_code"
fieldProps={{
size: 'large',
prefix: <SafetyOutlined />,
maxLength: 6,
autoComplete: 'one-time-code',
}}
placeholder="请输入 6 位验证码"
rules={[{ required: true, message: '请输入验证码' }]}
/>
)}
</LoginForm>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
// ============================================================
// 操作日志
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
export default function Logs() {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
const { data, isLoading } = useQuery({
queryKey: ['logs', page, actionFilter],
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
})
const columns: ProColumns<OperationLog>[] = [
{
title: '操作类型',
dataIndex: 'action',
width: 140,
render: (_, r) => (
<Tag color={actionColors[r.action] || 'default'}>
{actionLabels[r.action] || r.action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
{
title: '详情',
dataIndex: 'details',
width: 250,
ellipsis: true,
render: (_, r) => {
if (!r.details) return '-'
if (typeof r.details === 'string') return r.details
return JSON.stringify(r.details)
},
},
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="操作类型筛选"
style={{ width: 160 }}
allowClear
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
/>
</div>
<ProTable<OperationLog>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,186 @@
// ============================================================
// 模型管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { modelService } from '@/services/models'
import { providerService } from '@/services/providers'
import type { Model } from '@/types'
export default function Models() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['models'],
queryFn: ({ signal }) => modelService.list(signal),
})
const { data: providersData } = useQuery({
queryKey: ['providers-for-select'],
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
{ title: '别名', dataIndex: 'alias', width: 140 },
{
title: '服务商',
dataIndex: 'provider_id',
width: 140,
render: (_, r) => {
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
return provider?.display_name || r.provider_id.substring(0, 8)
},
},
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{
title: '流式',
dataIndex: 'supports_streaming',
width: 70,
render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '视觉',
dataIndex: 'supports_vision',
width: 70,
render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 70,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Model>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑模型' : '新建模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
<Select
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
placeholder="选择服务商"
/>
</Form.Item>
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input />
</Form.Item>
<Form.Item name="context_window" label="上下文窗口">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,228 @@
// ============================================================
// 提示词管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { promptService } from '@/services/prompts'
import type { PromptTemplate, PromptVersion } from '@/types'
const { TextArea } = Input
const { Text } = Typography
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
export default function Prompts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [detailName, setDetailName] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['prompts'],
queryFn: ({ signal }) => promptService.list(signal),
})
const { data: detailData } = useQuery({
queryKey: ['prompt-detail', detailName],
queryFn: ({ signal }) => promptService.get(detailName!, signal),
enabled: !!detailName,
})
const { data: versionsData } = useQuery({
queryKey: ['prompt-versions', detailName],
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
enabled: !!detailName,
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
setCreateOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (name: string) => promptService.archive(name),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const rollbackMutation = useMutation({
mutationFn: ({ name, version }: { name: string; version: number }) =>
promptService.rollback(name, version),
onSuccess: () => {
message.success('回滚成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
},
onError: (err: Error) => message.error(err.message || '回滚失败'),
})
const columns: ProColumns<PromptTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
},
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailName(record.name)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const versionColumns: ProColumns<PromptVersion>[] = [
{ title: '版本', dataIndex: 'version', width: 60 },
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Popconfirm
title={`确定回滚到版本 ${record.version}`}
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
>
<Button size="small"></Button>
</Popconfirm>
),
},
]
return (
<div>
<ProTable<PromptTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建提示词"
open={createOpen}
onOk={handleCreate}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="唯一标识" />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Input placeholder="如 system, tool" />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
<TextArea rows={6} />
</Form.Item>
<Form.Item name="user_prompt_template" label="用户提示词模板">
<TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`提示词详情: ${detailName || ''}`}
open={!!detailName}
onCancel={() => setDetailName(null)}
footer={null}
width={800}
>
<Tabs items={[
{
key: 'info',
label: '基本信息',
children: detailData ? (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
</Descriptions>
) : null,
},
{
key: 'versions',
label: '版本历史',
children: (
<ProTable<PromptVersion>
columns={versionColumns}
dataSource={versionsData ?? []}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
loading={!versionsData}
/>
),
},
]} />
</Modal>
</div>
)
}

View File

@@ -0,0 +1,188 @@
// ============================================================
// 服务商管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import type { Provider, ProviderKey } from '@/types'
const { Text } = Typography
export default function Providers() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: ({ signal }) => providerService.list(signal),
})
const { data: keysData, isLoading: keysLoading } = useQuery({
queryKey: ['provider-keys', keyModalProviderId],
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
enabled: !!keyModalProviderId,
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 140 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 260,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
Key Pool
</Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="Key Pool"
open={!!keyModalProviderId}
onCancel={() => setKeyModalProviderId(null)}
footer={null}
width={700}
>
<ProTable<ProviderKey>
columns={keyColumns}
dataSource={keysData ?? []}
loading={keysLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,109 @@
// ============================================================
// 中转任务
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { relayService } from '@/services/relay'
import { useState } from 'react'
import type { RelayTask } from '@/types'
const { Title } = Typography
const statusLabels: Record<string, string> = {
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
const statusColors: Record<string, string> = {
queued: 'default',
running: 'processing',
completed: 'green',
failed: 'red',
cancelled: 'default',
}
export default function Relay() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
})
const columns: ProColumns<RelayTask>[] = [
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '模型', dataIndex: 'model_id', width: 160 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
{
title: 'Token',
width: 140,
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
},
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
{
title: '排队时间',
dataIndex: 'queued_at',
width: 180,
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
},
{
title: '完成时间',
dataIndex: 'completed_at',
width: 180,
render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-',
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={statusFilter}
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="状态筛选"
style={{ width: 140 }}
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</div>
<ProTable<RelayTask>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
// ============================================================
// 用量统计
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { usageService } from '@/services/usage'
import { telemetryService } from '@/services/telemetry'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
const { Title } = Typography
export default function Usage() {
const [days, setDays] = useState(30)
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
queryKey: ['usage-daily', days],
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
})
const { data: modelData, isLoading: modelLoading } = useQuery({
queryKey: ['usage-model', days],
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
})
if (dailyError) {
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
}
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
]
const modelColumns: ProColumns<ModelUsageStat>[] = [
{ title: '模型', dataIndex: 'model_id', width: 200 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{
title: '平均延迟',
dataIndex: 'avg_latency_ms',
width: 100,
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
},
{
title: '成功率',
dataIndex: 'success_rate',
width: 100,
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
style={{ width: 140 }}
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={12}>
<Card>
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
</Card>
</Col>
</Row>
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
<ProTable<DailyUsageStat>
columns={dailyColumns}
dataSource={dailyData ?? []}
loading={dailyLoading}
rowKey="day"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
<Card title="按模型统计" size="small">
<ProTable<ModelUsageStat>
columns={modelColumns}
dataSource={modelData ?? []}
loading={modelLoading}
rowKey="model_id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,17 @@
// ============================================================
// 路由守卫 — 未登录重定向到 /login
// ============================================================
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
const location = useLocation()
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,35 @@
// ============================================================
// 路由定义
// ============================================================
import { createBrowserRouter } from 'react-router-dom'
import { AuthGuard } from './AuthGuard'
import AdminLayout from '@/layouts/AdminLayout'
export const router = createBrowserRouter([
{
path: '/login',
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
},
{
path: '/',
element: (
<AuthGuard>
<AdminLayout />
</AuthGuard>
),
children: [
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
],
},
])

View File

@@ -0,0 +1,16 @@
import request, { withSignal } from './request'
import type { AccountPublic, PaginatedResponse } from '@/types'
export const accountService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,28 @@
import request, { withSignal } from './request'
import type { AgentTemplate, PaginatedResponse } from '@/types'
export const agentTemplateService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
create: (data: {
name: string; description?: string; category?: string; source?: string
model?: string; system_prompt?: string; tools?: string[]
capabilities?: string[]; temperature?: number; max_tokens?: number
visibility?: string
}, signal?: AbortSignal) =>
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: {
description?: string; model?: string; system_prompt?: string
tools?: string[]; capabilities?: string[]; temperature?: number
max_tokens?: number; visibility?: string; status?: string
}, signal?: AbortSignal) =>
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
archive: (id: string, signal?: AbortSignal) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,13 @@
import request, { withSignal } from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
export const apiKeyService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
revoke: (id: string, signal?: AbortSignal) =>
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request, { withSignal } from './request'
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
export const authService = {
login: (data: LoginRequest, signal?: AbortSignal) =>
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
me: (signal?: AbortSignal) =>
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,11 @@
import request, { withSignal } from './request'
import type { ConfigItem, PaginatedResponse } from '@/types'
export const configService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<ConfigItem>>('/config/items', withSignal({ params }, signal))
.then((r) => r.data.items),
update: (id: string, data: { value: string | number | boolean }, signal?: AbortSignal) =>
request.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,7 @@
import request, { withSignal } from './request'
import type { OperationLog, PaginatedResponse } from '@/types'
export const logService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,16 @@
import request, { withSignal } from './request'
import type { Model, PaginatedResponse } from '@/types'
export const modelService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,35 @@
import request, { withSignal } from './request'
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
export const promptService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
get: (name: string, signal?: AbortSignal) =>
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
create: (data: {
name: string; category: string; description?: string; source?: string
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; min_app_version?: string
}, signal?: AbortSignal) =>
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
archive: (name: string, signal?: AbortSignal) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
listVersions: (name: string, signal?: AbortSignal) =>
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).then((r) => r.data),
createVersion: (name: string, data: {
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; changelog?: string; min_app_version?: string
}, signal?: AbortSignal) =>
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
rollback: (name: string, version: number, signal?: AbortSignal) =>
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,31 @@
import request, { withSignal } from './request'
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
export const providerService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
request.post<Provider>('/providers', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
listKeys: (providerId: string, signal?: AbortSignal) =>
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).then((r) => r.data),
addKey: (providerId: string, data: {
key_label: string; key_value: string; priority?: number
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
}, signal?: AbortSignal) =>
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).then((r) => r.data),
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request, { withSignal } from './request'
import type { RelayTask, PaginatedResponse } from '@/types'
export const relayService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<RelayTask>(`/relay/tasks/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,127 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
//
// 认证策略: 主路径使用 HttpOnly cookie浏览器自动附加
// Authorization header 作为 fallback 保留用于 API 客户端。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
withCredentials: true, // 发送 refresh cookie
})
const newToken = res.data.token as string
const newRefreshToken = res.data.refresh_token as string
// 更新内存中的 token实际认证通过 HttpOnly cookie浏览器已自动更新
store.setToken(newToken)
if (newRefreshToken) {
store.setRefreshToken(newRefreshToken)
}
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}

View File

@@ -0,0 +1,7 @@
import request, { withSignal } from './request'
import type { DashboardStats } from '@/types'
export const statsService = {
dashboard: (signal?: AbortSignal) =>
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request, { withSignal } from './request'
import type { ModelUsageStat, DailyUsageStat } from '@/types'
export const telemetryService = {
modelStats: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<ModelUsageStat[]>('/telemetry/stats', withSignal({ params }, signal)).then((r) => r.data),
dailyStats: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<DailyUsageStat[]>('/telemetry/daily', withSignal({ params }, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,12 @@
import request, { withSignal } from './request'
import type { UsageRecord, UsageByModel } from '@/types'
export const usageService = {
daily: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<{ by_day: UsageRecord[] }>('/usage', withSignal({ params: { ...params, group_by: 'day' } }, signal))
.then((r) => r.data.by_day || []),
byModel: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
.then((r) => r.data.by_model || []),
}

View File

@@ -0,0 +1,96 @@
// ============================================================
// ZCLAW Admin V2 — Zustand 认证状态管理
// ============================================================
//
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
// 内存中的 token/refreshToken 仅用于 Authorization header fallbackAPI 客户端兼容)。
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: [
'admin:full', 'account:admin', 'provider:manage', 'model:manage',
'relay:admin', 'config:write', 'prompt:read', 'prompt:write',
'prompt:publish', 'prompt:admin',
],
admin: [
'account:read', 'account:admin', 'provider:manage', 'model:read',
'model:manage', 'relay:use', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const ACCOUNT_KEY = 'zclaw_admin_account'
/** 从 localStorage 恢复 account 信息token 通过 HttpOnly cookie 管理) */
function loadFromStorage(): { account: AccountPublic | null } {
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
return { account }
}
interface AuthState {
token: string | null
refreshToken: string | null
account: AccountPublic | null
permissions: string[]
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account?.role
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
: []
return {
token: null,
refreshToken: null,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
set({ token })
},
setRefreshToken: (refreshToken: string) => {
set({ refreshToken })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
// token 仅存内存(实际认证通过 HttpOnly cookie
set({
token,
refreshToken,
account,
permissions: ROLE_PERMISSIONS[account.role] ?? [],
})
},
logout: () => {
localStorage.removeItem(ACCOUNT_KEY)
set({ token: null, refreshToken: null, account: null, permissions: [] })
// 调用后端 logout 清除 HttpOnly cookiesfire-and-forget
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
},
hasPermission: (permission: string) => {
const { permissions } = get()
return permissions.includes(permission) || permissions.includes('admin:full')
},
}
})

View File

@@ -0,0 +1,267 @@
// ============================================================
// ZCLAW SaaS Admin — 全局类型定义
// ============================================================
/** 公共账号信息 */
export interface AccountPublic {
id: string
username: string
email: string
display_name: string
role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
/** 登录请求 */
export interface LoginRequest {
username: string
password: string
totp_code?: string
}
/** 登录响应 */
export interface LoginResponse {
token: string
refresh_token: string
account: AccountPublic
}
/** 注册请求 */
export interface RegisterRequest {
username: string
password: string
email: string
display_name?: string
}
/** 分页响应 */
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
}
/** 服务商 (Provider) */
export interface Provider {
id: string
name: string
display_name: string
api_key?: string
base_url: string
api_protocol: string
enabled: boolean
rate_limit_rpm: number | null
rate_limit_tpm: number | null
created_at: string
updated_at: string
}
/** 模型 */
export interface Model {
id: string
provider_id: string
model_id: string
alias: string
context_window: number
max_output_tokens: number
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
pricing_input: number
pricing_output: number
}
/** API 密钥信息 */
export interface TokenInfo {
id: string
name: string
token_prefix: string
permissions: string[]
last_used_at?: string
expires_at?: string
created_at: string
token?: string
}
/** 创建 Token 请求 */
export interface CreateTokenRequest {
name: string
expires_days?: number
permissions: string[]
}
/** 中转任务 */
export interface RelayTask {
id: string
account_id: string
provider_id: string
model_id: string
status: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
/** 用量记录 */
export interface UsageRecord {
day: string
count: number
input_tokens: number
output_tokens: number
}
/** 按模型用量 */
export interface UsageByModel {
model_id: string
count: number
input_tokens: number
output_tokens: number
}
/** 系统配置项 */
export interface ConfigItem {
id: string
category: string
key_path: string
value_type: string
current_value: string | null
default_value: string | null
source: string
description: string | null
requires_restart: boolean
created_at: string
updated_at: string
}
/** 操作日志 */
export interface OperationLog {
id: number
account_id: string | null
action: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
/** 仪表盘统计 */
export interface DashboardStats {
total_accounts: number
active_accounts: number
tasks_today: number
active_providers: number
active_models: number
tokens_today_input: number
tokens_today_output: number
}
/** API 错误响应 */
export interface ApiError {
error: string
message: string
status?: number
}
/** 提示词模板 */
export interface PromptTemplate {
id: string
name: string
category: string
description?: string
source: 'builtin' | 'custom'
current_version: number
status: 'active' | 'deprecated' | 'archived'
created_at: string
updated_at: string
}
/** 提示词版本 */
export interface PromptVersion {
id: string
template_id: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
changelog?: string
min_app_version?: string
created_at: string
}
/** 提示词变量定义 */
export interface PromptVariable {
name: string
type: 'string' | 'number' | 'select' | 'boolean'
default_value?: string
description?: string
required?: boolean
}
/** Agent 模板 */
export interface AgentTemplate {
id: string
name: string
description?: string
category: string
source: 'builtin' | 'custom'
model?: string
system_prompt?: string
tools: string[]
capabilities: string[]
temperature?: number
max_tokens?: number
visibility: 'public' | 'team' | 'private'
status: 'active' | 'archived'
current_version: number
created_at: string
updated_at: string
}
/** Provider Key */
export interface ProviderKey {
id: string
provider_id: string
key_label: string
priority: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
is_active: boolean
last_429_at?: string
cooldown_until?: string
total_requests: number
total_tokens: number
created_at: string
updated_at: string
}
/** 按模型聚合的用量统计 */
export interface ModelUsageStat {
model_id: string
request_count: number
input_tokens: number
output_tokens: number
avg_latency_ms: number | null
success_rate: number
}
/** 按天的用量统计 */
export interface DailyUsageStat {
day: string
request_count: number
input_tokens: number
output_tokens: number
unique_devices: number
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
// SSE relay 端点需要长超时(流式响应可持续数分钟)
'/api/v1/relay/chat/completions': {
target: 'http://localhost:8080',
changeOrigin: true,
timeout: 600_000,
proxyTimeout: 600_000,
},
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
timeout: 30_000,
proxyTimeout: 30_000,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setTimeout(30_000)
})
proxy.on('proxyRes', (proxyRes) => {
proxyRes.setTimeout(30_000)
})
},
},
},
},
})

View File

@@ -1,10 +1,15 @@
//! Director - Multi-Agent Orchestration
//! Director - Multi-Agent Orchestration (Experimental)
//!
//! The Director manages multi-agent conversations by:
//! - Determining which agent speaks next
//! - Managing conversation state and turn order
//! - Supporting multiple scheduling strategies
//! - Coordinating agent responses
//!
//! **Status**: This module is fully implemented but gated behind the `multi-agent` feature.
//! The desktop build does not currently enable this feature. When multi-agent support
//! is ready for production, add Tauri commands to create and interact with the Director,
//! and enable the feature in `desktop/src-tauri/Cargo.toml`.
use std::sync::Arc;
use serde::{Deserialize, Serialize};

View File

@@ -5,21 +5,21 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{Datelike, Timelike};
use tokio::sync::RwLock;
use tokio::sync::Mutex;
use tokio::time::{self, Duration};
use zclaw_types::Result;
use crate::Kernel;
/// Scheduler service that runs in the background and executes scheduled triggers
pub struct SchedulerService {
kernel: Arc<RwLock<Option<Kernel>>>,
kernel: Arc<Mutex<Option<Kernel>>>,
running: Arc<AtomicBool>,
check_interval: Duration,
}
impl SchedulerService {
/// Create a new scheduler service
pub fn new(kernel: Arc<RwLock<Option<Kernel>>>, check_interval_secs: u64) -> Self {
pub fn new(kernel: Arc<Mutex<Option<Kernel>>>, check_interval_secs: u64) -> Self {
Self {
kernel,
running: Arc::new(AtomicBool::new(false)),
@@ -74,58 +74,56 @@ impl SchedulerService {
/// Check all scheduled triggers and fire those that are due
async fn check_and_fire_scheduled_triggers(
kernel_lock: &Arc<RwLock<Option<Kernel>>>,
kernel_lock: &Arc<Mutex<Option<Kernel>>>,
) -> Result<()> {
let kernel_read = kernel_lock.read().await;
let kernel = match kernel_read.as_ref() {
Some(k) => k,
None => return Ok(()),
};
// Collect due triggers under lock
let to_execute: Vec<(String, String, String)> = {
let kernel_guard = kernel_lock.lock().await;
let kernel = match kernel_guard.as_ref() {
Some(k) => k,
None => return Ok(()),
};
// Get all triggers
let triggers = kernel.list_triggers().await;
let now = chrono::Utc::now();
let triggers = kernel.list_triggers().await;
let now = chrono::Utc::now();
// Filter to enabled Schedule triggers
let scheduled: Vec<_> = triggers.iter()
.filter(|t| {
t.config.enabled && matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })
})
.collect();
let scheduled: Vec<_> = triggers.iter()
.filter(|t| {
t.config.enabled && matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })
})
.collect();
if scheduled.is_empty() {
return Ok(());
}
if scheduled.is_empty() {
return Ok(());
}
tracing::debug!("[Scheduler] Checking {} scheduled triggers", scheduled.len());
tracing::debug!("[Scheduler] Checking {} scheduled triggers", scheduled.len());
// Drop the read lock before executing
let to_execute: Vec<(String, String, String)> = scheduled.iter()
.filter_map(|t| {
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
// Simple cron matching: check if we should fire now
if Self::should_fire_cron(cron, &now) {
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
scheduled.iter()
.filter_map(|t| {
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
if Self::should_fire_cron(cron, &now) {
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
} else {
None
}
} else {
None
}
} else {
None
}
})
.collect();
})
.collect()
}; // Lock dropped here
drop(kernel_read);
// Execute due triggers (with write lock since execute_hand may need it)
// Execute due triggers (acquire lock per execution)
let now = chrono::Utc::now();
for (trigger_id, hand_id, cron_expr) in to_execute {
tracing::info!(
"[Scheduler] Firing scheduled trigger '{}' → hand '{}' (cron: {})",
trigger_id, hand_id, cron_expr
);
let kernel_read = kernel_lock.read().await;
if let Some(kernel) = kernel_read.as_ref() {
let kernel_guard = kernel_lock.lock().await;
if let Some(kernel) = kernel_guard.as_ref() {
let trigger_source = zclaw_types::TriggerSource::Scheduled {
trigger_id: trigger_id.clone(),
};
@@ -265,9 +263,12 @@ impl SchedulerService {
_ => return false,
};
// Check if current timestamp aligns with the interval
// Check if current timestamp is within the scheduler check window of an interval boundary.
// The scheduler checks every `check_interval` seconds (default 60s), so we use ±30s window.
let timestamp = now.timestamp();
timestamp % interval_secs == 0
let remainder = timestamp % interval_secs;
// Fire if we're within ±30 seconds of an interval boundary
remainder <= 30 || remainder >= (interval_secs - 30)
}
}

View File

@@ -395,13 +395,6 @@ pub trait LlmIntentDriver: Send + Sync {
) -> HashMap<String, serde_json::Value>;
}
/// Default LLM driver implementation using prompt-based matching
#[allow(dead_code)]
pub struct DefaultLlmIntentDriver {
/// Model ID to use
model_id: String,
}
/// Runtime LLM driver that wraps zclaw-runtime's LlmDriver for actual LLM calls
pub struct RuntimeLlmIntentDriver {
driver: std::sync::Arc<dyn zclaw_runtime::driver::LlmDriver>,

View File

@@ -13,6 +13,7 @@ zclaw-types = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }

View File

@@ -19,14 +19,16 @@ const ACCESS_TOKEN_COOKIE: &str = "zclaw_access_token";
const REFRESH_TOKEN_COOKIE: &str = "zclaw_refresh_token";
/// 构建 auth cookies 并附加到 CookieJar
/// secure 标记在开发环境 (ZCLAW_SAAS_DEV=true) 设为 false生产设为 true
fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJar {
let access_max_age = std::time::Duration::from_secs(2 * 3600); // 2h
let refresh_max_age = std::time::Duration::from_secs(7 * 86400); // 7d
let secure = !is_dev_mode();
// cookie crate 需要 time::Duration从 std 转换
let access = Cookie::build((ACCESS_TOKEN_COOKIE, token.to_string()))
.http_only(true)
.secure(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/api")
.max_age(access_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(3600).try_into().unwrap()))
@@ -34,7 +36,7 @@ fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJ
let refresh = Cookie::build((REFRESH_TOKEN_COOKIE, refresh_token.to_string()))
.http_only(true)
.secure(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/api/v1/auth")
.max_age(refresh_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(86400).try_into().unwrap()))
@@ -43,6 +45,13 @@ fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJ
jar.add(access).add(refresh)
}
/// 检查是否为开发模式Cookie Secure、CORS 等安全策略依据此判断)
fn is_dev_mode() -> bool {
std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
/// 清除 auth cookies
fn clear_auth_cookies(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::build(ACCESS_TOKEN_COOKIE).path("/api"))
@@ -502,9 +511,40 @@ fn sha256_hex(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// POST /api/v1/auth/logout — 清除 auth cookies
/// POST /api/v1/auth/logout — 撤销 refresh token 并清除 auth cookies
pub async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> (CookieJar, axum::http::StatusCode) {
// 尝试从 cookie 中获取 refresh token 并撤销
if let Some(refresh_cookie) = jar.get(REFRESH_TOKEN_COOKIE) {
let token = refresh_cookie.value();
if let Ok(claims) = verify_token_skip_expiry(token, state.jwt_secret.expose_secret()) {
if claims.token_type == "refresh" {
if let Some(jti) = claims.jti {
let now = chrono::Utc::now().to_rfc3339();
// 标记 refresh token 为已使用(等效于撤销/黑名单)
let result = sqlx::query(
"UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2 AND used_at IS NULL"
)
.bind(&now).bind(&jti)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() > 0 {
tracing::info!(account_id = %claims.sub, jti = %jti, "Refresh token revoked on logout");
}
}
Err(e) => {
tracing::warn!(jti = %jti, error = %e, "Failed to revoke refresh token on logout");
}
}
}
}
}
}
(clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT)
}

View File

@@ -212,7 +212,8 @@ impl SaaSConfig {
let mut config = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
toml::from_str(&content)?
let interpolated = interpolate_env_vars(&content);
toml::from_str(&interpolated)?
} else {
tracing::warn!("Config file {:?} not found, using defaults", config_path);
SaaSConfig::default()
@@ -291,3 +292,71 @@ impl SaaSConfig {
}
}
}
/// 替换 TOML 配置文件中的 `${ENV_VAR}` 模式为环境变量值
/// 未设置的环境变量保留原文,后续数据库连接或 JWT 初始化时会报明确错误
fn interpolate_env_vars(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
let start = i + 2;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
if end < bytes.len() && bytes[end] == b'}' {
let var_name = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
match std::env::var(var_name) {
Ok(val) => {
tracing::debug!("Config: ${{{}}} → resolved ({} bytes)", var_name, val.len());
result.push_str(&val);
}
Err(_) => {
tracing::warn!("Config: ${{{}}} not set, keeping placeholder", var_name);
result.push_str(&format!("${{{}}}", var_name));
}
}
i = end + 1;
} else {
result.push(bytes[i] as char);
i += 1;
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interpolate_env_vars_resolves() {
std::env::set_var("TEST_ZCLAW_DB_PW", "mypassword");
let input = "url = \"postgres://user:${TEST_ZCLAW_DB_PW}@localhost/db\"";
let result = interpolate_env_vars(input);
assert_eq!(result, "url = \"postgres://user:mypassword@localhost/db\"");
std::env::remove_var("TEST_ZCLAW_DB_PW");
}
#[test]
fn test_interpolate_env_vars_missing_keeps_placeholder() {
let input = "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\"";
let result = interpolate_env_vars(input);
assert_eq!(result, "url = \"postgres://user:${NONEXISTENT_VAR_12345}@localhost/db\"");
}
#[test]
fn test_interpolate_env_vars_no_placeholders() {
let input = "host = \"0.0.0.0\"\nport = 8080";
let result = interpolate_env_vars(input);
assert_eq!(result, input);
}
}

View File

@@ -9,8 +9,8 @@ const SCHEMA_VERSION: i32 = 7;
/// 初始化数据库
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.max_connections(50)
.min_connections(3)
.acquire_timeout(std::time::Duration::from_secs(5))
.idle_timeout(std::time::Duration::from_secs(180))
.max_lifetime(std::time::Duration::from_secs(900))
@@ -21,6 +21,7 @@ pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
seed_admin_account(&pool).await?;
seed_builtin_prompts(&pool).await?;
seed_demo_data(&pool).await?;
fix_seed_data(&pool).await?;
tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION);
Ok(pool)
}
@@ -565,19 +566,32 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
}
// ===== 7. Config Items =====
// 分类名必须与 Admin V2 Config 页面 Tab key 一致: general/auth/relay/model/rate_limit/log
let config_items = [
("server", "max_connections", "integer", "50", "100", "Maximum database connections"),
("server", "request_timeout_sec", "integer", "30", "60", "Request timeout in seconds"),
("llm", "default_model", "string", "gpt-4o", "gpt-4o", "Default LLM model"),
("llm", "max_context_tokens", "integer", "128000", "128000", "Maximum context window"),
("llm", "stream_chunk_size", "integer", "1024", "1024", "Streaming chunk size in bytes"),
("agent", "max_concurrent_tasks", "integer", "5", "10", "Maximum concurrent agent tasks"),
("agent", "task_timeout_min", "integer", "30", "60", "Agent task timeout in minutes"),
("memory", "max_entries", "integer", "10000", "50000", "Maximum memory entries per agent"),
("memory", "compression_threshold", "integer", "100", "200", "Messages before compression"),
("security", "rate_limit_enabled", "boolean", "true", "true", "Enable rate limiting"),
("security", "max_requests_per_minute", "integer", "60", "120", "Max requests per minute per user"),
("security", "content_filter_enabled", "boolean", "true", "true", "Enable content filtering"),
("general", "max_connections", "integer", "50", "100", "最大数据库连接数"),
("general", "request_timeout_sec", "integer", "30", "60", "请求超时秒数"),
("general", "app_name", "string", "ZCLAW", "ZCLAW", "应用显示名称"),
("general", "debug_mode", "boolean", "false", "false", "调试模式"),
("auth", "session_ttl_hours", "integer", "24", "48", "会话有效期(小时)"),
("auth", "refresh_token_ttl_days", "integer", "7", "30", "刷新令牌有效期(天)"),
("auth", "max_login_attempts", "integer", "5", "10", "最大登录尝试次数"),
("auth", "totp_enabled", "boolean", "false", "false", "启用 TOTP 两步验证"),
("relay", "max_retries", "integer", "3", "5", "最大重试次数"),
("relay", "retry_delay_sec", "integer", "5", "10", "重试延迟秒数"),
("relay", "stream_timeout_sec", "integer", "120", "300", "流式响应超时秒数"),
("relay", "max_concurrent_tasks", "integer", "10", "20", "最大并发中转任务"),
("model", "default_model", "string", "gpt-4o", "gpt-4o", "默认 LLM 模型"),
("model", "max_context_tokens", "integer", "128000", "128000", "最大上下文窗口"),
("model", "stream_chunk_size", "integer", "1024", "1024", "流式响应块大小(bytes)"),
("model", "temperature", "number", "0.7", "0.7", "默认温度参数"),
("rate_limit", "rate_limit_enabled", "boolean", "true", "true", "启用请求限流"),
("rate_limit", "max_requests_per_minute", "integer", "60", "120", "每分钟最大请求数"),
("rate_limit", "burst_size", "integer", "10", "20", "突发请求上限"),
("rate_limit", "content_filter_enabled", "boolean", "true", "true", "启用内容过滤"),
("log", "log_level", "string", "info", "info", "日志级别"),
("log", "log_retention_days", "integer", "30", "90", "日志保留天数"),
("log", "audit_log_enabled", "boolean", "true", "true", "启用审计日志"),
("log", "slow_query_threshold_ms", "integer", "1000", "2000", "慢查询阈值(ms)"),
];
for (cat, key, vtype, current, default, desc) in &config_items {
let ts = now.to_rfc3339();
@@ -589,7 +603,22 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
.execute(pool).await?;
}
// ===== 8. API Tokens =====
// ===== 8. Account API Keys (account_api_keys 表) =====
let account_api_keys = [
("demo-akey-1", "demo-openai", "sk-demo-openai-key-1-xxxxx", "OpenAI API Key", "[\"relay:use\",\"model:read\"]"),
("demo-akey-2", "demo-anthropic", "sk-ant-demo-key-1-xxxxx", "Anthropic API Key", "[\"relay:use\",\"model:read\",\"config:read\"]"),
("demo-akey-3", "demo-deepseek", "sk-demo-deepseek-key-1-xxxxx", "DeepSeek API Key", "[\"relay:use\"]"),
];
for (id, provider_id, key_val, label, perms) in &account_api_keys {
let ts = now.to_rfc3339();
sqlx::query(
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $7) ON CONFLICT (id) DO NOTHING"
).bind(id).bind(&admin_id).bind(provider_id).bind(key_val).bind(label).bind(perms).bind(&ts)
.execute(pool).await?;
}
// 保留旧 api_tokens 表的种子数据(兼容旧代码路径)
let api_tokens = [
("demo-token-1", "Production API Key", "zclaw_prod_xr7Km9pQ2nBv", "[\"relay:use\",\"model:read\"]"),
("demo-token-2", "Development Key", "zclaw_dev_aB3cD5eF7gH9", "[\"relay:use\",\"model:read\",\"config:read\"]"),
@@ -662,6 +691,123 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
Ok(())
}
/// 修复旧种子数据:更新 config_items 分类名 + 补充 account_api_keys + 更新旧数据 account_id
///
/// 历史问题:
/// - 旧 config_items 使用 server/llm/agent/memory/security 分类,与 Admin V2 前端 Tab 不匹配
/// - 旧种子将 API Keys 写入 api_tokens 表,但 handler 读 account_api_keys 表
/// - 旧种子数据的 account_id 可能与当前 admin 不匹配
async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
// 1. 获取所有 super_admin account_id可能有多个
let admins: Vec<(String,)> = sqlx::query_as(
"SELECT id FROM accounts WHERE role = 'super_admin'"
).fetch_all(pool).await?;
if admins.is_empty() {
return Ok(());
}
let admin_ids: Vec<String> = admins.into_iter().map(|(id,)| id).collect();
// 2. 更新 config_items 分类名(旧 → 新)
let category_mappings = [
("server", "general"),
("llm", "model"),
("agent", "general"),
("memory", "general"),
("security", "rate_limit"),
];
for (old_cat, new_cat) in &category_mappings {
let result = sqlx::query(
"UPDATE config_items SET category = $1, updated_at = $2 WHERE category = $3"
).bind(new_cat).bind(&now).bind(old_cat)
.execute(pool).await?;
if result.rows_affected() > 0 {
tracing::info!("Fixed config_items category: {} → {} ({} rows)", old_cat, new_cat, result.rows_affected());
}
}
// 如果新分类没有数据,补种默认配置项(幂等 ON CONFLICT DO NOTHING
let general_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM config_items WHERE category = 'general'")
.fetch_one(pool).await?;
if general_count.0 == 0 {
let new_configs = [
("general", "max_connections", "integer", "50", "100", "最大数据库连接数"),
("general", "request_timeout_sec", "integer", "30", "60", "请求超时秒数"),
("general", "app_name", "string", "ZCLAW", "ZCLAW", "应用显示名称"),
("auth", "session_ttl_hours", "integer", "24", "48", "会话有效期(小时)"),
("relay", "max_retries", "integer", "3", "5", "最大重试次数"),
("model", "default_model", "string", "gpt-4o", "gpt-4o", "默认 LLM 模型"),
("rate_limit", "rate_limit_enabled", "boolean", "true", "true", "启用请求限流"),
("log", "log_level", "string", "info", "info", "日志级别"),
];
for (cat, key, vtype, current, default, desc) in &new_configs {
let id = format!("cfg-{}-{}", cat, key);
sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING"
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&now)
.execute(pool).await?;
}
tracing::info!("Seeded {} new config items for updated categories", new_configs.len());
}
// 3. 补种 account_api_keys幂等 ON CONFLICT DO NOTHING— 为每个 admin 补种
let provider_keys: Vec<(String, String)> = sqlx::query_as(
"SELECT id, provider_id FROM providers LIMIT 5"
).fetch_all(pool).await.unwrap_or_default();
for admin_id in &admin_ids {
let akey_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM account_api_keys WHERE account_id = $1")
.bind(admin_id).fetch_one(pool).await?;
if akey_count.0 > 0 { continue; }
let demo_keys = [
(format!("demo-akey-1-{}", &admin_id[..8]), "OpenAI API Key", "sk-demo-openai-key-1-xxxxx", "[\"relay:use\",\"model:read\"]"),
(format!("demo-akey-2-{}", &admin_id[..8]), "Anthropic API Key", "sk-ant-demo-key-1-xxxxx", "[\"relay:use\",\"model:read\"]"),
(format!("demo-akey-3-{}", &admin_id[..8]), "DeepSeek API Key", "sk-demo-deepseek-key-1-xxxxx", "[\"relay:use\"]"),
];
for (idx, (id, label, key_val, perms)) in demo_keys.iter().enumerate() {
let provider_id = provider_keys.get(idx).map(|(_, pid)| pid.as_str()).unwrap_or("demo-openai");
sqlx::query(
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $7) ON CONFLICT (id) DO NOTHING"
).bind(id).bind(admin_id).bind(provider_id).bind(key_val).bind(label).bind(perms).bind(&now)
.execute(pool).await?;
}
tracing::info!("Seeded {} account_api_keys for admin {}", demo_keys.len(), admin_id);
}
// 4. 更新旧种子数据 — 将所有 relay_tasks/usage_records/operation_logs 等的 account_id
// 更新为每个 super_admin 都能看到(复制或统一)
// 策略:统一为第一个 super_admin然后为其余 admin 也复制关键数据
let primary_admin = &admin_ids[0];
for table in &["relay_tasks", "usage_records", "operation_logs", "telemetry_reports"] {
// 统计该表有多少不同的 account_id
let distinct_count: (i64,) = sqlx::query_as(
&format!("SELECT COUNT(DISTINCT account_id) FROM {}", table)
).fetch_one(pool).await.unwrap_or((0,));
if distinct_count.0 > 0 {
// 将所有非 primary_admin 的数据更新为 primary_admin
let result = sqlx::query(
&format!("UPDATE {} SET account_id = $1 WHERE account_id != $1", table)
).bind(primary_admin)
.execute(pool).await?;
if result.rows_affected() > 0 {
tracing::info!("Unified {} account_id to {} ({} rows fixed)", table, primary_admin, result.rows_affected());
}
}
}
// 也更新 api_tokens 表的 account_id
let _ = sqlx::query("UPDATE api_tokens SET account_id = $1 WHERE account_id != $1")
.bind(primary_admin).execute(pool).await?;
tracing::info!("Seed data fix completed");
Ok(())
}
#[cfg(test)]
mod tests {
// PostgreSQL 单元测试需要真实数据库连接,此处保留接口兼容

View File

@@ -217,7 +217,7 @@ async fn build_router(state: AppState) -> axum::Router {
let protected_routes = zclaw_saas::auth::protected_routes()
.merge(zclaw_saas::account::routes())
.merge(zclaw_saas::model_config::routes())
.merge(zclaw_saas::relay::routes())
// relay::routes() 不在此合并 — SSE 端点需要更长超时,在最终 Router 单独合并
.merge(zclaw_saas::migration::routes())
.merge(zclaw_saas::role::routes())
.merge(zclaw_saas::prompt::routes())
@@ -247,9 +247,28 @@ async fn build_router(state: AppState) -> axum::Router {
.merge(protected_routes)
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)));
// Relay 路由需要独立的认证中间件(因为被排除在 15s 超时层之外)
let relay_routes = zclaw_saas::relay::routes()
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::api_version_middleware,
))
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::request_id_middleware,
))
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::rate_limit_middleware,
))
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::auth::auth_middleware,
));
axum::Router::new()
.merge(non_streaming_routes)
.merge(zclaw_saas::relay::routes())
.merge(relay_routes)
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state)

View File

@@ -58,6 +58,11 @@ pub async fn rate_limit_middleware(
req: Request<Body>,
next: Next,
) -> Response<Body> {
// GET 请求不计入限流 — 前端导航/轮询产生的 GET 不应触发 429
if req.method() == axum::http::Method::GET {
return next.run(req).await;
}
let account_id = req.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.account_id.clone())
@@ -91,15 +96,39 @@ pub async fn rate_limit_middleware(
next.run(req).await
}
/// 公共端点速率限制中间件 (基于客户端 IP更严格)
/// 公共端点速率限制中间件 (基于客户端 IP按路径差异化限流)
/// 用于登录/注册/刷新等无认证端点,防止暴力破解
const PUBLIC_RATE_LIMIT_RPM: usize = 20;
///
/// 限流策略:
/// - /auth/login: 5 次/分钟/IP
/// - /auth/register: 3 次/小时/IP
/// - 其他 (refresh): 20 次/分钟/IP
const LOGIN_RATE_LIMIT: usize = 5;
const LOGIN_RATE_LIMIT_WINDOW_SECS: u64 = 60;
const REGISTER_RATE_LIMIT: usize = 3;
const REGISTER_RATE_LIMIT_WINDOW_SECS: u64 = 3600;
const DEFAULT_PUBLIC_RATE_LIMIT: usize = 20;
const DEFAULT_PUBLIC_RATE_LIMIT_WINDOW_SECS: u64 = 60;
pub async fn public_rate_limit_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response<Body> {
let path = req.uri().path();
// 根据路径选择限流策略
let (limit, window_secs, key_prefix, error_msg) = if path.ends_with("/auth/login") {
(LOGIN_RATE_LIMIT, LOGIN_RATE_LIMIT_WINDOW_SECS,
"auth_login_rate_limit", "登录请求过于频繁,请稍后再试")
} else if path.ends_with("/auth/register") {
(REGISTER_RATE_LIMIT, REGISTER_RATE_LIMIT_WINDOW_SECS,
"auth_register_rate_limit", "注册请求过于频繁,请一小时后再试")
} else {
(DEFAULT_PUBLIC_RATE_LIMIT, DEFAULT_PUBLIC_RATE_LIMIT_WINDOW_SECS,
"public_rate_limit", "请求频率超限,请稍后再试")
};
// 从连接信息或 header 提取客户端 IP
let client_ip = req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
@@ -113,15 +142,16 @@ pub async fn public_rate_limit_middleware(
.unwrap_or_else(|| "unknown".to_string())
});
let key = format!("public_rate_limit:{}", client_ip);
let key = format!("{}:{}", key_prefix, client_ip);
let now = Instant::now();
let window_start = now - std::time::Duration::from_secs(60);
let window_start = now - std::time::Duration::from_secs(window_secs);
// DashMap 操作限定在作用域块内,确保 RefMut 在 await 前释放
let blocked = {
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= PUBLIC_RATE_LIMIT_RPM {
if entries.len() >= limit {
true
} else {
entries.push(now);
@@ -130,9 +160,7 @@ pub async fn public_rate_limit_middleware(
};
if blocked {
return SaasError::RateLimited(
"请求频率超限,请稍后再试".into()
).into_response();
return SaasError::RateLimited(error_msg.into()).into_response();
}
next.run(req).await

View File

@@ -10,11 +10,11 @@ pub struct RelayTaskRow {
pub provider_id: String,
pub model_id: String,
pub status: String,
pub priority: i64,
pub attempt_count: i64,
pub max_attempts: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub priority: i32,
pub attempt_count: i32,
pub max_attempts: i32,
pub input_tokens: i32,
pub output_tokens: i32,
pub error_message: Option<String>,
pub queued_at: String,
pub started_at: Option<String>,

View File

@@ -25,7 +25,7 @@ pub async fn create_relay_task(
provider_id: &str,
model_id: &str,
request_body: &str,
priority: i64,
priority: i32,
max_attempts: u32,
) -> SaasResult<RelayTaskInfo> {
let id = uuid::Uuid::new_v4().to_string();

View File

@@ -29,11 +29,11 @@ pub struct RelayTaskInfo {
pub provider_id: String,
pub model_id: String,
pub status: String,
pub priority: i64,
pub attempt_count: i64,
pub max_attempts: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub priority: i32,
pub attempt_count: i32,
pub max_attempts: i32,
pub input_tokens: i32,
pub output_tokens: i32,
pub error_message: Option<String>,
pub queued_at: String,
pub started_at: Option<String>,

View File

@@ -4,7 +4,7 @@ pub mod types;
pub mod service;
pub mod handlers;
use axum::routing::{get, post, patch, delete};
use axum::routing::get;
use crate::state::AppState;
/// 定时任务路由 (需要认证)

View File

@@ -6,6 +6,7 @@ use super::types::*;
/// 数据库行结构
#[derive(Debug, FromRow)]
#[allow(dead_code)]
struct ScheduledTaskRow {
id: String,
account_id: String,

View File

@@ -122,14 +122,11 @@ pub fn start_user_task_scheduler(db: PgPool) {
}
async fn tick_user_tasks(db: &PgPool) -> Result<(), sqlx::Error> {
let now = chrono::Utc::now().to_rfc3339();
// 查找到期任务
// 查找到期任务next_run_at 兼容 TEXT 和 TIMESTAMPTZ 两种列类型)
let due_tasks: Vec<(String, String, String)> = sqlx::query_as(
"SELECT id, schedule_type, target_type FROM scheduled_tasks
WHERE enabled = TRUE AND next_run_at <= $1"
WHERE enabled = TRUE AND next_run_at::TIMESTAMPTZ <= NOW()"
)
.bind(&now)
.fetch_all(db)
.await?;
@@ -140,16 +137,14 @@ async fn tick_user_tasks(db: &PgPool) -> Result<(), sqlx::Error> {
tracing::debug!("[UserScheduler] {} tasks due", due_tasks.len());
for (task_id, schedule_type, _target_type) in due_tasks {
// 标记执行
let now_str = chrono::Utc::now().to_rfc3339();
// 标记执行(用 NOW() 写入时间戳)
let result = sqlx::query(
"UPDATE scheduled_tasks
SET last_run_at = $1, run_count = run_count + 1, updated_at = $1,
SET last_run_at = NOW(), run_count = run_count + 1, updated_at = NOW(),
enabled = CASE WHEN schedule_type = 'once' THEN FALSE ELSE TRUE END,
next_run_at = NULL
WHERE id = $2"
WHERE id = $1"
)
.bind(&now_str)
.bind(&task_id)
.execute(db)
.await;

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Instant;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::config::SaaSConfig;
use crate::workers::WorkerDispatcher;
@@ -27,10 +28,12 @@ pub struct AppState {
rate_limit_rpm: Arc<AtomicU32>,
/// Worker 调度器 (异步后台任务)
pub worker_dispatcher: WorkerDispatcher,
/// 优雅停机令牌 — 触发后所有 SSE 流和长连接应立即终止
pub shutdown_token: CancellationToken,
}
impl AppState {
pub fn new(db: PgPool, config: SaaSConfig, worker_dispatcher: WorkerDispatcher) -> anyhow::Result<Self> {
pub fn new(db: PgPool, config: SaaSConfig, worker_dispatcher: WorkerDispatcher, shutdown_token: CancellationToken) -> anyhow::Result<Self> {
let jwt_secret = config.jwt_secret()?;
let rpm = config.rate_limit.requests_per_minute;
Ok(Self {
@@ -42,6 +45,7 @@ impl AppState {
totp_fail_counts: Arc::new(dashmap::DashMap::new()),
rate_limit_rpm: Arc::new(AtomicU32::new(rpm)),
worker_dispatcher,
shutdown_token,
})
}
@@ -55,9 +59,10 @@ impl AppState {
self.rate_limit_rpm.store(rpm, Ordering::Relaxed);
}
/// 清理过期的限流条目 (60 秒窗口外的记录)
/// 清理过期的限流条目
/// 使用 3600s 窗口以覆盖 register rate limit (3次/小时) 的完整周期
pub fn cleanup_rate_limit_entries(&self) {
let window_start = Instant::now() - std::time::Duration::from_secs(60);
let window_start = Instant::now() - std::time::Duration::from_secs(3600);
self.rate_limit_entries.retain(|_, entries| {
entries.retain(|&ts| ts > window_start);
!entries.is_empty()

View File

@@ -22,10 +22,12 @@ use axum::http::{Request, StatusCode};
use axum::Router;
use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
use zclaw_saas::config::SaaSConfig;
use zclaw_saas::db::init_db;
use zclaw_saas::state::AppState;
use zclaw_saas::workers::WorkerDispatcher;
pub const MAX_BODY: usize = 2 * 1024 * 1024;
pub const DEFAULT_PASSWORD: &str = "testpassword123";
@@ -129,7 +131,9 @@ pub async fn build_test_app() -> (Router, PgPool) {
config.rate_limit.requests_per_minute = 10_000;
config.rate_limit.burst = 1_000;
let state = AppState::new(pool.clone(), config).expect("AppState::new failed");
let dispatcher = WorkerDispatcher::new(pool.clone());
let shutdown_token = CancellationToken::new();
let state = AppState::new(pool.clone(), config, dispatcher, shutdown_token).expect("AppState::new failed");
let router = build_router(state);
(router, pool)
}

View File

@@ -80,11 +80,9 @@ impl ToolResult {
pub mod builtin_tools {
pub const FILE_READ: &str = "file_read";
pub const FILE_WRITE: &str = "file_write";
pub const FILE_LIST: &str = "file_list";
pub const SHELL_EXEC: &str = "shell_exec";
pub const WEB_FETCH: &str = "web_fetch";
pub const WEB_SEARCH: &str = "web_search";
pub const MEMORY_STORE: &str = "memory_store";
pub const MEMORY_RECALL: &str = "memory_recall";
pub const MEMORY_SEARCH: &str = "memory_search";
// NOTE: FILE_LIST, WEB_SEARCH, MEMORY_STORE/RECALL/SEARCH were removed —
// these had no corresponding tool implementations. Memory operations are
// handled by the Growth system (MemoryMiddleware + VikingStorage).
}

View File

@@ -428,9 +428,8 @@ static LAST_INTERACTION: OnceLock<RwLock<StdHashMap<String, String>>> = OnceLock
pub struct MemoryStatsCache {
pub task_count: usize,
pub total_entries: usize,
#[allow(dead_code)] // Reserved for UI display
pub storage_size_bytes: usize,
#[allow(dead_code)] // Reserved for UI display
#[allow(dead_code)] // Reserved for UI display; will be exposed via heartbeat_get_memory_stats
pub last_updated: Option<String>,
}

View File

@@ -539,7 +539,7 @@ pub type IdentityManagerState = Arc<Mutex<AgentIdentityManager>>;
/// Initialize identity manager
#[tauri::command]
#[allow(dead_code)] // Registered via invoke_handler! at runtime
#[allow(dead_code)] // NOT registered in invoke_handler — identity state is initialized lazily via identity_get
pub async fn identity_init() -> Result<IdentityManagerState, String> {
Ok(Arc::new(Mutex::new(AgentIdentityManager::new())))
}

View File

@@ -16,29 +16,20 @@ use zclaw_runtime::driver::LlmDriver;
/// Run pre-conversation intelligence hooks
///
/// 1. Build memory context from VikingStorage (FTS5 + TF-IDF + Embedding)
/// 2. Build identity-enhanced system prompt (SOUL.md + instructions)
/// Builds identity-enhanced system prompt (SOUL.md + instructions).
///
/// Returns the enhanced system prompt that should be passed to the kernel.
/// NOTE: Memory context injection is NOT done here — it is handled by
/// `MemoryMiddleware.before_completion()` in the Kernel's middleware chain.
/// Previously, both paths injected memories, causing duplicate injection.
pub async fn pre_conversation_hook(
agent_id: &str,
user_message: &str,
_user_message: &str,
identity_state: &IdentityManagerState,
) -> Result<String, String> {
// Step 1: Build memory context from Viking storage
let memory_context = match build_memory_context(agent_id, user_message).await {
Ok(ctx) => ctx,
Err(e) => {
warn!(
"[intelligence_hooks] Failed to build memory context for agent {}: {}",
agent_id, e
);
String::new()
}
};
// Step 2: Build identity-enhanced system prompt
let enhanced_prompt = match build_identity_prompt(agent_id, &memory_context, identity_state).await {
// Build identity-enhanced system prompt (SOUL.md + instructions)
// Memory context is injected by MemoryMiddleware in the kernel middleware chain,
// not here, to avoid duplicate injection.
let enhanced_prompt = match build_identity_prompt(agent_id, "", identity_state).await {
Ok(prompt) => prompt,
Err(e) => {
warn!(
@@ -117,6 +108,10 @@ pub async fn post_conversation_hook(
}
/// Build memory context by searching VikingStorage for relevant memories
///
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
/// middleware chain. This function is kept as a utility for ad-hoc queries.
#[allow(dead_code)]
async fn build_memory_context(
agent_id: &str,
user_message: &str,

View File

@@ -16,6 +16,9 @@ use crate::intelligence::validation::{validate_identifier, validate_string_lengt
/// Kernel state wrapper for Tauri
pub type KernelState = Arc<Mutex<Option<Kernel>>>;
/// Scheduler state — holds a reference to the SchedulerService so it can be stopped on shutdown
pub type SchedulerState = Arc<Mutex<Option<zclaw_kernel::scheduler::SchedulerService>>>;
/// Session-level stream concurrency guard.
/// Prevents two concurrent `agent_chat_stream` calls from interleaving events
/// for the same session_id.
@@ -146,6 +149,7 @@ fn default_kernel_model() -> String { "gpt-4o-mini".to_string() }
#[tauri::command]
pub async fn kernel_init(
state: State<'_, KernelState>,
scheduler_state: State<'_, SchedulerState>,
config_request: Option<KernelConfigRequest>,
) -> Result<KernelStatusResponse, String> {
let mut kernel_lock = state.lock().await;
@@ -267,6 +271,22 @@ pub async fn kernel_init(
*kernel_lock = Some(kernel);
// Start SchedulerService — periodically checks and fires scheduled triggers
{
let mut sched_lock = scheduler_state.lock().await;
// Stop old scheduler if any
if let Some(ref old) = *sched_lock {
old.stop();
}
let scheduler = zclaw_kernel::scheduler::SchedulerService::new(
state.inner().clone(),
60, // check every 60 seconds
);
scheduler.start();
tracing::info!("[kernel_init] SchedulerService started (60s interval)");
*sched_lock = Some(scheduler);
}
Ok(KernelStatusResponse {
initialized: true,
agent_count,
@@ -305,7 +325,17 @@ pub async fn kernel_status(
#[tauri::command]
pub async fn kernel_shutdown(
state: State<'_, KernelState>,
scheduler_state: State<'_, SchedulerState>,
) -> Result<(), String> {
// Stop scheduler first
{
let mut sched_lock = scheduler_state.lock().await;
if let Some(scheduler) = sched_lock.take() {
scheduler.stop();
tracing::info!("[kernel_shutdown] SchedulerService stopped");
}
}
let mut kernel_lock = state.lock().await;
if let Some(kernel) = kernel_lock.take() {
@@ -806,6 +836,11 @@ pub fn create_kernel_state() -> KernelState {
Arc::new(Mutex::new(None))
}
/// Create the scheduler state for Tauri
pub fn create_scheduler_state() -> SchedulerState {
Arc::new(Mutex::new(None))
}
// ============================================================================
// Skills Commands - Dynamic Discovery
// ============================================================================
@@ -1964,10 +1999,8 @@ pub struct ScheduledTaskResponse {
/// Create a scheduled task (backed by kernel TriggerManager)
///
/// ⚠️ PLANNNED: Tasks are stored in the kernel's trigger system, but automatic
/// execution requires a scheduler loop that is not yet implemented in embedded
/// kernel mode. Created tasks will be persisted but not auto-executed until
/// the scheduler loop is implemented.
/// Tasks are automatically executed by the SchedulerService which checks
/// every 60 seconds for due triggers.
#[tauri::command]
pub async fn scheduled_task_create(
state: State<'_, KernelState>,

View File

@@ -212,7 +212,6 @@ fn get_platform_binary_names() -> Vec<String> {
}
/// Legacy: Build staged runtime using Node.js (for backward compatibility)
#[allow(dead_code)]
fn build_staged_runtime_legacy(source: &str, root_dir: PathBuf) -> Option<ZclawRuntime> {
let node_executable = root_dir.join(if cfg!(target_os = "windows") {
"node.exe"
@@ -973,11 +972,9 @@ fn zclaw_version(app: AppHandle) -> Result<VersionResponse, String> {
/// Health status enum
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
#[allow(dead_code)] // Reserved for future health check expansion
enum HealthStatus {
Healthy,
Unhealthy,
Unknown,
}
/// Port check result
@@ -1309,6 +1306,9 @@ pub fn run() {
// Initialize internal ZCLAW Kernel state
let kernel_state = kernel_commands::create_kernel_state();
// Initialize Scheduler state (for automatic trigger execution)
let scheduler_state = kernel_commands::create_scheduler_state();
// Initialize Pipeline state (DSL-based workflows)
let pipeline_state = pipeline_commands::create_pipeline_state();
@@ -1320,6 +1320,7 @@ pub fn run() {
.manage(reflection_state)
.manage(identity_state)
.manage(kernel_state)
.manage(scheduler_state)
.manage(kernel_commands::SessionStreamGuard::default())
.manage(pipeline_state)
.invoke_handler(tauri::generate_handler![

View File

@@ -957,29 +957,19 @@ export class KernelClient {
}
/**
* Execute a skill by ID with optional input parameters.
* Checks autonomy level before execution.
*/
async executeSkill(id: string, input?: Record<string, unknown>): Promise<{
success: boolean;
output?: unknown;
error?: string;
durationMs?: number;
}> {
// Autonomy check before executing skill
const { canAutoExecute, getAutonomyManager } = await import('./autonomy-manager');
const { canProceed, decision } = canAutoExecute('skill_install', 5);
if (!canProceed) {
return {
success: false,
error: `自主授权拒绝: ${decision.reason}`,
};
}
const autonomyLevel = getAutonomyManager().getConfig().level;
return invoke('skill_execute', {
id,
context: {},
input: input || {},
autonomyLevel,
});
}

View File

@@ -494,11 +494,12 @@ class SaasLLMAdapter implements LLMServiceAdapter {
}
isAvailable(): boolean {
// Check synchronously via localStorage for availability check
// Dynamic import would be async, so we use a simpler check
// Check synchronously via localStorage for availability check.
// Auth is cookie-based — check connection mode + URL presence.
try {
const token = localStorage.getItem('zclaw-saas-token');
return !!token;
const mode = localStorage.getItem('zclaw-connection-mode');
const saasUrl = localStorage.getItem('zclaw-saas-url');
return mode === 'saas' && !!saasUrl;
} catch {
return false;
}

View File

@@ -5,12 +5,20 @@
* Handles authentication, model listing, chat relay, and config management.
*
* API base path: /api/v1/...
* Auth: Bearer token in Authorization header
* Auth: HttpOnly cookie (primary) + Bearer token fallback
*
* Security: Tokens are NO LONGER persisted to localStorage.
* The backend sets HttpOnly cookies on login/register/refresh.
* On page reload, cookie-based auth is verified via GET /api/v1/auth/me.
*/
// === Storage Keys ===
// Token is stored in secure storage (OS keyring), NOT in plain localStorage.
// Auth state is carried by HttpOnly cookies when possible (same-origin).
// On page reload, token is restored from secure storage as Bearer fallback.
const SAASTOKEN_KEY = 'zclaw-saas-token';
const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key
const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup
const SAASURL_KEY = 'zclaw-saas-url';
const SAASACCOUNT_KEY = 'zclaw-saas-account';
const SAASMODE_KEY = 'zclaw-connection-mode';
@@ -439,25 +447,42 @@ export class SaaSApiError extends Error {
// === Session Persistence ===
export interface SaaSSession {
token: string;
token: string | null; // null when using cookie-based auth (page reload)
account: SaaSAccountInfo | null;
saasUrl: string;
}
/**
* Load a persisted SaaS session from localStorage.
* Returns null if no valid session exists.
* Load a persisted SaaS session.
* Token is stored in secure storage (OS keyring), not plain localStorage.
* Returns null if no URL is stored (never logged in).
*
* NOTE: Token loading is async due to secure storage access.
* For synchronous checks, use loadSaaSSessionSync() (URL + account only).
*/
export function loadSaaSSession(): SaaSSession | null {
export async function loadSaaSSession(): Promise<SaaSSession | null> {
try {
const token = localStorage.getItem(SAASTOKEN_KEY);
const saasUrl = localStorage.getItem(SAASURL_KEY);
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
if (!token || !saasUrl) {
if (!saasUrl) {
return null;
}
// Clean up any legacy plaintext token from localStorage
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
if (legacyToken) {
localStorage.removeItem(SAASTOKEN_KEY);
}
// Load token from secure storage
let token: string | null = null;
try {
const { secureStorage } = await import('./secure-storage');
token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY);
} catch {
// Secure storage unavailable — token stays null (cookie auth will be attempted)
}
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
const account: SaaSAccountInfo | null = accountRaw
? (JSON.parse(accountRaw) as SaaSAccountInfo)
: null;
@@ -471,10 +496,46 @@ export function loadSaaSSession(): SaaSSession | null {
}
/**
* Persist a SaaS session to localStorage.
* Synchronous version — returns URL + account only (no token).
* Used during store initialization where async is not available.
*/
export function saveSaaSSession(session: SaaSSession): void {
localStorage.setItem(SAASTOKEN_KEY, session.token);
export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountInfo | null } | null {
try {
const saasUrl = localStorage.getItem(SAASURL_KEY);
if (!saasUrl) return null;
// Clean up legacy plaintext token
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
if (legacyToken) {
localStorage.removeItem(SAASTOKEN_KEY);
}
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
const account: SaaSAccountInfo | null = accountRaw
? (JSON.parse(accountRaw) as SaaSAccountInfo)
: null;
return { saasUrl, account };
} catch {
return null;
}
}
/**
* Persist SaaS session.
* Token goes to secure storage (OS keyring), metadata to localStorage.
*/
export async function saveSaaSSession(session: SaaSSession): Promise<void> {
// Store token in secure storage (OS keyring), not plain localStorage
if (session.token) {
try {
const { secureStorage } = await import('./secure-storage');
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token);
} catch {
// Secure storage unavailable — token only in memory
}
}
localStorage.setItem(SAASURL_KEY, session.saasUrl);
if (session.account) {
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
@@ -482,9 +543,15 @@ export function saveSaaSSession(session: SaaSSession): void {
}
/**
* Clear the persisted SaaS session from localStorage.
* Clear the persisted SaaS session from all storage.
*/
export function clearSaaSSession(): void {
export async function clearSaaSSession(): Promise<void> {
// Remove from secure storage
try {
const { secureStorage } = await import('./secure-storage');
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, '');
} catch { /* non-blocking */ }
localStorage.removeItem(SAASTOKEN_KEY);
localStorage.removeItem(SAASURL_KEY);
localStorage.removeItem(SAASACCOUNT_KEY);
@@ -525,14 +592,33 @@ export class SaaSClient {
return this.baseUrl;
}
/** Set or clear the auth token */
/** Set or clear the auth token (in-memory only, never persisted) */
setToken(token: string | null): void {
this.token = token;
}
/** Check if the client has an auth token */
/** Check if the client is authenticated (token in memory or cookie-based) */
isAuthenticated(): boolean {
return !!this.token;
return !!this.token || this._cookieAuth;
}
/** Track cookie-based auth state (page reload) */
private _cookieAuth: boolean = false;
/**
* Attempt to restore auth state from HttpOnly cookie.
* Called on page reload when no token is in memory.
* Returns account info if cookie is valid, null otherwise.
*/
async restoreFromCookie(): Promise<SaaSAccountInfo | null> {
try {
const account = await this.me();
this._cookieAuth = true;
return account;
} catch {
this._cookieAuth = false;
return null;
}
}
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
@@ -569,6 +655,7 @@ export class SaaSClient {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Bearer token as fallback — primary auth is HttpOnly cookie
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
@@ -577,6 +664,7 @@ export class SaaSClient {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
credentials: 'include', // Send HttpOnly cookies
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeoutMs),
});
@@ -588,24 +676,12 @@ export class SaaSClient {
try {
const newToken = await this.refreshToken();
if (newToken) {
// Persist refreshed token to localStorage
try {
const raw = localStorage.getItem('zclaw-saas-session');
if (raw) {
const session = JSON.parse(raw);
session.token = newToken;
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
}
} catch { /* non-blocking */ }
return this.request<T>(method, path, body, timeoutMs, true);
}
} catch (refreshErr) {
// Token refresh failed — clear session and trigger logout
try {
const { clearSaaSSession } = require('./saas-client');
clearSaaSSession();
localStorage.removeItem('zclaw-connection-mode');
} catch { /* non-blocking */ }
clearSaaSSession().catch(() => {}); // async cleanup, fire-and-forget
localStorage.removeItem('zclaw-connection-mode');
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
}
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
@@ -844,6 +920,7 @@ export class SaaSClient {
{
method: 'POST',
headers,
credentials: 'include', // Send HttpOnly cookies
body: JSON.stringify(body),
signal: effectiveSignal,
},

View File

@@ -359,17 +359,16 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
if (savedMode === 'saas') {
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
const session = loadSaaSSession();
const session = await loadSaaSSession();
if (!session || !session.token || !session.saasUrl) {
if (!session || !session.saasUrl) {
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
}
log.debug('Using SaaS relay mode:', session.saasUrl);
// Configure the singleton client
// Configure the singleton client (cookie auth — no token needed)
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
// Health check via GET /api/v1/relay/models
try {

View File

@@ -16,6 +16,7 @@ import {
saasClient,
SaaSApiError,
loadSaaSSession,
loadSaaSSessionSync,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
@@ -79,7 +80,7 @@ export interface SaaSActionsSlice {
login: (saasUrl: string, username: string, password: string) => Promise<void>;
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
@@ -104,33 +105,34 @@ const DEFAULT_SAAS_URL = import.meta.env.DEV
// === Helpers ===
/** Determine the initial connection mode from persisted state */
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccountInfo | null } | null): ConnectionMode {
const persistedMode = loadConnectionMode();
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
return persistedMode;
}
return session ? 'saas' : 'tauri';
return sessionMeta ? 'saas' : 'tauri';
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Restore session from localStorage on init
const session = loadSaaSSession();
const initialMode = resolveInitialMode(session);
// Restore session metadata synchronously (URL + account only).
// Token is loaded from secure storage asynchronously by restoreSession().
const sessionMeta = loadSaaSSessionSync();
const initialMode = resolveInitialMode(sessionMeta);
// If session exists, configure the singleton client
if (session) {
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
// If session URL exists, configure the singleton client base URL
if (sessionMeta) {
saasClient.setBaseUrl(sessionMeta.saasUrl);
}
return {
// === Initial State ===
isLoggedIn: session !== null,
account: session?.account ?? null,
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: session?.token ?? null,
// isLoggedIn starts false — will be set to true by restoreSession()
isLoggedIn: false,
account: sessionMeta?.account ?? null,
saasUrl: sessionMeta?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: null, // In-memory only — loaded from secure storage by restoreSession()
connectionMode: initialMode,
availableModels: [],
isLoading: false,
@@ -163,20 +165,20 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
// Persist session
// Persist session: token → secure storage (OS keyring), metadata → localStorage
const sessionData = {
token: loginData.token,
token: loginData.token, // Will be stored in OS keyring by saveSaaSSession
account: loginData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
saveSaaSSession(sessionData); // async — fire and forget (non-blocking)
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: loginData.token,
authToken: null, // Not stored in Zustand state — saasClient holds in memory
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -261,7 +263,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: loginData.token,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -326,7 +328,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
isLoggedIn: true,
account: registerData.account,
saasUrl: normalizedUrl,
authToken: registerData.token,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -357,9 +359,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
logout: () => {
logout: async () => {
saasClient.setToken(null);
clearSaaSSession();
await clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
@@ -389,16 +391,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
},
fetchAvailableModels: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) {
if (!isLoggedIn) {
set({ availableModels: [] });
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
const models = await saasClient.listModels();
set({ availableModels: models });
} catch (err: unknown) {
@@ -413,12 +414,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Collect all dirty config keys
const dirtyKeys: string[] = [];
@@ -472,13 +472,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Read last sync timestamp from localStorage
const lastSyncKey = 'zclaw-config-last-sync';
@@ -533,15 +532,14 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) {
if (!isLoggedIn) {
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
await saasClient.registerDevice({
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
@@ -555,7 +553,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (!state.isLoggedIn || !state.authToken) {
if (!state.isLoggedIn) {
window.clearInterval(timer);
return;
}
@@ -593,25 +591,55 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
set({ error: null });
},
restoreSession: () => {
const restored = loadSaaSSession();
if (restored) {
saasClient.setBaseUrl(restored.saasUrl);
restoreSession: async () => {
const restored = await loadSaaSSession();
if (!restored) return;
saasClient.setBaseUrl(restored.saasUrl);
// Strategy: try secure storage token first, then cookie auth
let account: SaaSAccountInfo | null = null;
if (restored.token) {
// Token from secure storage — use as Bearer
saasClient.setToken(restored.token);
set({
isLoggedIn: true,
account: restored.account,
saasUrl: restored.saasUrl,
authToken: restored.token,
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
try {
account = await saasClient.me();
} catch {
// Token expired — try cookie auth
saasClient.setToken(null);
}
}
if (!account) {
// Try cookie-based auth (works for same-origin, e.g. admin panel)
account = await saasClient.restoreFromCookie();
}
if (!account) {
// Neither token nor cookie works — user needs to re-login
set({
isLoggedIn: false,
account: null,
saasUrl: restored.saasUrl,
authToken: null,
});
return;
}
set({
isLoggedIn: true,
account,
saasUrl: restored.saasUrl,
authToken: restored.token, // In-memory from secure storage (null if cookie-only)
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
},
setupTotp: async () => {
@@ -633,10 +661,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
await saasClient.verifyTotp(code);
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
}
const { saasUrl } = get();
saveSaaSSession({ token: null, account, saasUrl }); // Token in saasClient memory only
set({ totpSetupData: null, isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
@@ -651,10 +677,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
await saasClient.disableTotp(password);
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
}
const { saasUrl } = get();
saveSaaSSession({ token: null, account, saasUrl });
set({ isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message

309
docs/audit-2026-03-30.md Normal file
View File

@@ -0,0 +1,309 @@
# ZCLAW 全面项目审计报告
> **审计日期**: 2026-03-30
> **审计范围**: 系统架构、代码质量、性能、安全、业务功能、用户体验
> **审计基准**: v0.7.0,基于 F16/S2/S4/S8 完成后的代码状态
> **审计方法**: 5 个并行子代理 + 直接代码分析
---
## 一、执行摘要
### 总体评级: **B+ (良好)**
| 维度 | 评级 | 关键发现 |
|------|------|----------|
| 系统架构 | A- | 10 Crate 分层清晰SaaS 独立feature gate 全覆盖 |
| 代码质量 | B | 0 编译警告453 测试但多文件超限53 处 Promise\<any\> |
| 安全性 | B | Cookie 已实现SQL 全参数化,但登出未撤 token无 TLS |
| 性能 | B | 连接池健康检查完备,但 SSE 不响应 shutdown表无自动清理 |
| 业务功能 | A- | 93 API9 Hands66 Skills前后端对齐SaaS 定时任务执行未闭环 |
### 关键数字
| 指标 | 数值 |
|------|------|
| Rust 代码行数 | 54,380 |
| SaaS API 端点 | 93 (10 模块) |
| Tauri 命令 | 106 |
| 测试数量 | 453 (254 unit + 199 async) |
| Hands | 9 |
| Skills | 66 SKILL.md |
| Pipeline YAML | 7 |
| 编译警告 | 0 (项目自有) |
| TypeScript 错误 | 0 |
---
## 二、系统架构审计
### 2.1 Crate 分层 ✅
```
L1: zclaw-types (无 zclaw 依赖) ✅
L1.5: zclaw-growth (仅依赖 types) — 目录在 L5 但依赖在 L1
L2: zclaw-memory (→ types)
L3: zclaw-runtime (→ types, memory, growth)
L4: zclaw-kernel (→ types, memory, runtime, protocols, hands, skills)
L5: zclaw-skills (→ types), zclaw-hands (→ types, runtime), zclaw-protocols (→ types)
zclaw-pipeline (→ types, runtime, kernel, skills, hands)
独立: zclaw-saas (仅依赖 zclaw-types) ✅
```
**架构代理验证结果**:
- ✅ 无循环依赖DAG
- ✅ SaaS 独立于 Kernel
- ✅ workspace members 完整
- ✅ multi-agent feature 传播链正确: desktop → kernel → protocols/a2a
- ✅ wasm feature gate 完整
- ✅ dev-server feature gate 完整
- `zclaw-growth` 目录位置与依赖层级不一致(不影响功能)
- `zclaw-pipeline` 声明了 `zclaw-kernel` 依赖但代码中未直接 import
### 2.2 数据流完整性 ✅
| 链路 | 状态 | 覆盖 |
|------|------|------|
| Desktop UI → Store → Tauri → Kernel → LLM/Hands/Skills | ✅ 完整 | 106 个 Tauri 命令 |
| Admin V2 → Axios → SaaS API → PostgreSQL | ✅ 完整 | 12 个 service 文件 |
| Pipeline YAML → Executor → LLM/Skill/Hand Actions | ✅ 完整 | 三种 Action driver 均已接通 |
---
## 三、代码质量审计
### 3.1 编译状态 ✅
- `cargo check --workspace`: **0 项目警告**
- `admin-v2 tsc --noEmit`: **通过**
- `desktop tsc --noEmit`: **通过**
### 3.2 大文件审计 ⚠️
**Rust 文件(>800 行)**:
| 文件 | 行数 | 建议 |
|------|------|------|
| `kernel_commands.rs` | **2185** | 按功能域拆分 |
| `lib.rs` (desktop) | **1518** | 命令定义独立 |
| `kernel.rs` | **1490** | 拆分 a2a/scheduler 子模块 |
| `pipeline_commands.rs` | **1391** | 按 stage 拆分 |
| `generation.rs` | 1080 | 逻辑连贯,可保持 |
| `quiz.rs` | 1027 | 题目模板导致 |
| `openai.rs` | 912 | 拆分 streaming 子模块 |
| `director.rs` | 912 | 拆分 turn/submission |
| `loop_runner.rs` | 896 | 拆分 tool execution |
| `store.rs` | 804 | 拆分 query builder |
**TypeScript 文件(>800 行)**:
| 文件 | 行数 | 类型安全 |
|------|------|----------|
| `intelligence-client.ts` | 1471 | 低风险 |
| `kernel-client.ts` | 1343 | 低风险 |
| `saas-client.ts` | 1290 | 低风险 |
| **`gateway-client.ts`** | **1227** | **53 处 Promise\<any\>** |
| **`gateway-api.ts`** | **672** | **同上** |
### 3.3 unwrap() 使用 ⚠️
- 68 个 Rust 文件含 unwrap(),生产代码约 108 处
- `store.rs` 独占 75 处(最高风险)
- **P0 风险**: `extraction_adapter.rs:306-310` 链式 unwrapLLM 返回异常格式时 panic
- **P1 风险**: `context_builder.rs` 多处 HashMap `get_mut().unwrap()`
### 3.4 TypeScript 类型安全
- 0 处 `@ts-ignore` / `@ts-nocheck`
- **53 处 `Promise<any>`** 在 gateway-client.ts + gateway-api.ts最大类型安全盲区
- 6 处静默 `catch(() => {})`saasStore.ts 3 处应添加日志)
- 33 处 `#[allow(dead_code)]`(半数标注 "reserved",需定期审视)
### 3.5 测试覆盖
| Crate | 测试数 | 评价 |
|-------|--------|------|
| zclaw-saas | 111 | 良好 |
| zclaw-growth | 75 | 良好 |
| zclaw-pipeline | 59 | 良好 |
| zclaw-types | 57 | 良好 |
| zclaw-runtime | 42 | 中等 |
| zclaw-kernel | 41 | 需提升 |
| zclaw-skills | 22 | 需提升 |
| zclaw-hands | 21 | 需提升 |
| zclaw-memory | 20 | 需提升 |
| zclaw-protocols | **5** | **不足** |
---
## 四、安全审计
### 4.1 安全发现汇总
| 级别 | ID | 问题 | 文件 |
|------|-----|------|------|
| HIGH | SEC-02 | `saas-config.toml` 含明文 DB 密码 | saas-config.toml:16 |
| HIGH | SEC-03 | ShellSkill SKILL.md 命令注入信任模型 | skills/runner.rs:125 |
| HIGH | SEC-04 | 服务端无 TLS 终止 | saas/main.rs:72 |
| HIGH | SEC-05 | JWT debug fallback 密钥硬编码 | saas/config.rs:238 |
| MED | SEC-06 | 登出未撤销服务端 refresh token | saas/auth/handlers.rs:506 |
| MED | SEC-07 | 注册端点无验证码/邀请机制 | saas/auth/handlers.rs:54 |
| MED | SEC-08 | 角色权限缓存无 TTL/失效 | saas/auth/handlers.rs:397 |
| MED | SEC-09 | 登录缺少专门暴力破解保护 | saas/auth/handlers.rs:160 |
| MED | SEC-10 | 生产 CORS 配置含 localhost | saas-config.toml:12 |
| MED | SEC-11 | Cookie Secure 在非 HTTPS 环境失效 | saas/auth/handlers.rs:29 |
| MED | SEC-13 | TOTP 加密密钥从 JWT Secret 派生 | saas/config.rs:285 |
| LOW | SEC-14 | PythonSkill 脚本路径信任模型 | skills/runner.rs:83 |
| LOW | SEC-15 | Rate limit 信任 X-Forwarded-For | saas/middleware.rs:109 |
### 4.2 安全优势
| 项目 | 状态 |
|------|------|
| SQL 注入防护 | ✅ 100% 参数化0 字符串拼接 |
| 密码哈希 | ✅ Argon2id + spawn_blocking |
| Refresh Token Rotation | ✅ 一次性使用 + SHA-256 hash 存储 |
| API Key 加密 | ✅ AES-256-GCM + 随机 nonce |
| 错误信息处理 | ✅ 500 不泄露内部细节 |
| 审计日志 | ✅ 覆盖所有关键操作 + IP |
| TOTP 暴力破解保护 | ✅ 5 次失败锁定 10 分钟 |
| XSS 防护 | ✅ 唯一 innerHTML 用 DOMPurify |
| 账号隔离 | ✅ 所有查询强制 account_id |
| CORS 安全 | ✅ 生产强制白名单 |
---
## 五、性能审计
### 5.1 严重性能问题
| 问题 | 影响 | 文件 |
|------|------|------|
| SSE 流不检查 CancellationToken | graceful shutdown 等待最长 5 分钟 | relay/service.rs |
| 数据表无自动清理 | operation_logs/usage_records/relay_tasks 无限增长 | scheduler.rs |
| 连接池偏小 (max=20) | 高并发 SSE 场景不足 | db.rs:12 |
| Scheduler 循环不监听 shutdown token | 后台 task 不会被取消 | scheduler.rs:55,78,111 |
### 5.2 中等性能问题
| 问题 | 文件 |
|------|------|
| relay_tasks 缺 (account_id, status) 复合索引 | relay/handlers.rs:31 |
| SSE usage 跳过后 task 永久 "processing" | relay/service.rs:302 |
| OTA 提示词 N+1 查询(每模板 2 次) | prompt/service.rs:242 |
| totp_fail_counts 无清理机制 | state.rs:26 |
| `operation_logs` COUNT(*) 无时间范围 | account/handlers.rs:144 |
### 5.3 性能优势
| 项目 | 设计 |
|------|------|
| 连接池健康检查 | 80% 水位 503 + 3s 超时 |
| TCP keepalive | 60s/10s/3次 + SO_LINGER 1s |
| SSE 背压 | bounded channel(128) |
| SSE 并发限制 | Semaphore(16) |
| DashMap 死锁 | RefMut 在 await 前释放 |
| 前端无轮询 | TanStack Query 按需触发 |
---
## 六、业务功能审计
### 6.1 SaaS API 完整度 ✅
| 模块 | 端点数 | CRUD |
|------|--------|------|
| auth (含 TOTP) | 9 | 完整认证流程 |
| account (含 tokens/devices/logs/stats) | 12 | ✅ |
| provider | 6 | ✅ C/R/U/D |
| model | 5 | ✅ C/R/U/D |
| api_keys (含 rotate) | 5 | ✅ |
| relay (含 key pool) | 9 | ✅ |
| config/migration | 12 | ✅ + sync/diff/pull |
| role + permission_template | 11 | ✅ |
| prompt (含 versions/rollback) | 10 | ✅ |
| agent_template | 5 | ✅ |
| scheduled_task | 5 | ✅ |
| telemetry | 4 | ✅ |
| health | 1 | ✅ |
| **总计** | **93** | **完整** |
### 6.2 前后端对齐 ✅
- Admin V2: 12 个 service 文件 → 后端路由全部匹配
- baseURL `/api/v1` + Vite proxy → 无断链
- `withCredentials: true` → Cookie 自动发送
- **后端就绪但无 Admin UI**: Role 管理、TOTP、Prompt 版本、定时任务管理
### 6.3 功能缺口
| 功能 | 后端 | 前端 Admin | 状态 |
|------|------|-----------|------|
| Role CRUD | ✅ 4 端点 | ❌ | 后端就绪 |
| Permission Template | ✅ 4 端点 | ❌ | 后端就绪 |
| TOTP 2FA | ✅ 3 端点 | ❌ | 后端就绪 |
| Prompt 版本管理 | ✅ 4 端点 | ❌ | 后端就绪 |
| 定时任务管理 | ✅ 5 端点 | ❌ | 后端就绪 |
| SaaS 定时任务执行 | ⚠️ 仅状态管理 | — | **未闭环** |
| Kernel 定时任务执行 | ✅ TriggerManager | ✅ | 完整闭环 |
### 6.4 Approval 流程 ✅
完整闭环: 触发 → 检查 → 创建审批 → 前端通知 → 审批/拒绝 → 执行 → 结果通知
---
## 七、待改进项(按优先级)
### P0 — 必须修复
| ID | 问题 | 影响 |
|----|------|------|
| P0-1 | SSE 流不检查 CancellationToken | 停机等待最长 5 分钟 |
| P0-2 | 数据表无自动清理 (logs/usage/relay_tasks) | 磁盘耗尽 + 查询退化 |
| P0-3 | `store.rs` 75 处 unwrap | 数据库异常导致 panic |
| P0-4 | `extraction_adapter.rs` 链式 unwrap | LLM 异常输出导致 panic |
| P0-5 | 登出未撤销服务端 refresh token | XSS 后 token 仍可用 |
### P1 — 应该修复
| ID | 问题 | 影响 |
|----|------|------|
| P1-1 | `kernel_commands.rs` 2185 行 | 可维护性 |
| P1-2 | `gateway-client.ts` 53 处 Promise\<any\> | 类型安全盲区 |
| P1-3 | 连接池 max=20 偏小 | 高并发不足 |
| P1-4 | relay_tasks 缺复合索引 | 查询性能 |
| P1-5 | Scheduler 循环不响应 shutdown | 优雅停机不完整 |
| P1-6 | `saas-config.toml` 含明文密码 | 安全风险 |
| P1-7 | Cookie Secure 在 localhost 失效 | 开发环境认证失败 |
| P1-8 | 登录缺暴力破解保护 | 安全风险 |
| P1-9 | `zclaw-protocols` 仅 5 个测试 | 回归风险 |
### P2 — 可以改进
| ID | 问题 | 影响 |
|----|------|------|
| P2-1 | 多个文件 >800 行 | 代码组织 |
| P2-2 | 角色权限缓存无失效机制 | 降权不即时生效 |
| P2-3 | `sqlx-postgres 0.7.4` 未来兼容性 | 编译噪音 |
| P2-4 | SaaS 定时任务执行未闭环 | 功能不完整 |
| P2-5 | `scheduled_tasks` 路径缺 `/v1` 前缀 | API 一致性 |
| P2-6 | OTA 提示词 N+1 查询 | 性能 |
---
## 八、架构优势
1. **SaaS 独立部署** — 不依赖 Kernel可独立扩展
2. **Feature gate 分离** — multi-agent 功能可选
3. **Cookie + Header 双路径认证** — 向后兼容 + 安全
4. **声明式 Scheduler** — TOML 配置驱动
5. **参数化 SQL 全覆盖** — 零注入风险
6. **连接池健康检查** — 80% 水位自动降级
7. **Argon2id 密码哈希** — 业界最佳实践
8. **审计日志全覆盖** — 所有关键操作 + IP
9. **SSE 背压设计** — bounded channel 防内存溢出
---
*审计完成。5 个并行子代理深度审计,覆盖架构/代码/安全/性能/业务全维度。*

View File

@@ -1,8 +1,8 @@
# ZCLAW 功能全景文档
> **版本**: v0.8.0
> **更新日期**: 2026-03-29
> **项目状态**: 完整 Rust Workspace 架构11 个核心 Crates70 技能Pipeline DSL + Smart Presentation + Agent Growth System + SaaS 平台
> **版本**: v0.8.1
> **更新日期**: 2026-03-30
> **项目状态**: 完整 Rust Workspace 架构10 个核心 Crates70 技能Pipeline DSL + Smart Presentation + Agent Growth System + SaaS 平台
> **整体完成度**: ~87% (核心功能完整SaaS 平台全面上线Worker + Scheduler 系统上线,记忆闭环接通)
---
@@ -31,7 +31,7 @@
|------|------|--------|---------|
| [00-agent-memory.md](02-intelligence-layer/00-agent-memory.md) | Agent 记忆 | L4 (90%) | pre-hook (FTS5+TF-IDF+Embedding) |
| [01-identity-evolution.md](02-intelligence-layer/01-identity-evolution.md) | 身份演化 | L3 (90%) | pre-hook (SOUL.md) |
| [06-context-compaction.md](02-intelligence-layer/06-context-compaction.md) | 上下文压缩 | L3 (90%) | 内核 AgentLoop 集成 |
| [06-context-compaction.md](02-intelligence-layer/06-context-compaction.md) | 上下文压缩 | L4 (90%) | 内核中间件链集成 (CompactionMiddleware) |
| [03-reflection-engine.md](02-intelligence-layer/03-reflection-engine.md) | 自我反思 | L3 (85%) | post-hook (自动触发) |
| [04-heartbeat-engine.md](02-intelligence-layer/04-heartbeat-engine.md) | 心跳巡检 | L3 (90%) | post-hook (持久化) |
| [05-autonomy-manager.md](02-intelligence-layer/05-autonomy-manager.md) | 自主授权 | L3 (75%) | RightPanel UI |
@@ -97,7 +97,7 @@
| 指标 | 数值 |
|------|------|
| **Rust Crates** | **11** (types, memory, runtime, kernel, skills, hands, protocols, pipeline, growth, channels, saas) |
| **Rust Crates** | **10** (types, memory, runtime, kernel, skills, hands, protocols, pipeline, growth, saas)
| **SKILL.md 文件** | **70** |
| **Hands 总数** | **11** (9 启用, 2 禁用: Predictor, Lead) |
| **Pipeline 模板** | **5** |
@@ -127,10 +127,10 @@ zclaw-kernel (L4: 核心协调, 11 Hands, 70 Skills) — 85%
┌───┴───┬───────┬───────────┬──────────┬────────┐
│ │ │ │ │ │
skills hands protocols pipeline growth channels
(80%) (85%) (75%) (90%) (95%) (规划中)
skills hands protocols pipeline growth
(80%) (85%) (75%) (90%) (95%) (已移除)
zclaw-saas — 独立运行 (Axum + PostgreSQL, 端口 8080) — 95%
zclaw-saas — 独立运行 (Axum + PostgreSQL, 端口 8080) — 97%
```
---
@@ -167,6 +167,7 @@ zclaw-saas — 独立运行 (Axum + PostgreSQL, 端口 8080) — 95%
| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-03-30 | v0.8.1 | Sprint 5 "稳定清扫": Axum CLOSE_WAIT 修复 (CancellationToken + TCP keepalive + SO_LINGER)E2E 测试重新启用 (去掉 test.skip)dead code 注解审计 (36→<10) |
| 2026-03-29 | v0.8.0 | SaaS 后端架构重构完成Worker 系统 (5 Worker + mpsc 异步调度)声明式 Scheduler (TOML 配置)SQL 迁移系统 (Schema v6 + TIMESTAMPTZ)多环境配置 (ZCLAW_ENV)连接池优化 (50 max/5 min)速率限制优化 (无锁 AtomicU32)记忆闭环修复extraction_adapter.rs 实现 TauriExtractionDriverBREAK-01 已修复 |
| 2026-03-29 | v0.7.0 | 文档同步SKILL 数量 70, Tauri 命令 130+ ( Browser/Intelligence/Memory/CLI/SecureStorage), Hands 11 (9 启用+2 禁用), 智能层完成度修正 |
| 2026-03-28 | v0.7.0 | 基于 2026-03-28 代码状态全面更新SaaS 平台 76+ API 路由/9 模块/25 58+ Tauri 命令8 LLM Provider3 种连接模式 |

View File

@@ -0,0 +1,326 @@
# ZCLAW 集成联调测试报告
> **测试日期**: 2026-03-30
> **测试范围**: Desktop (localhost:1420) × Admin V2 (localhost:5173) × Backend (localhost:8080)
> **测试方法**: 浏览器 MCP 实机操作 + 截图证据
> **后端版本**: zclaw-saas 0.1.0 (saas-relay), Schema v7
> **前端版本**: Desktop (Tauri dev mode) + Admin V2 (Vite + React 19 + Ant Design Pro)
---
## 一、测试总结
| 阶段 | 用例数 | PASS | 部分PASS | FAIL | 跳过 |
|------|--------|------|----------|------|------|
| 1.1 Admin V2 冒烟 | 14 | 8 | 4 | 0 | 2 |
| 1.2 Desktop 冒烟 | 6 | 4 | 0 | 1 | 1 |
| **合计** | **20** | **12** | **4** | **1** | **3** |
### 发现的 Bug 汇总
| 严重度 | ID | 描述 | 状态 |
|--------|----|------|------|
| **P0** | BUG-001 | Scheduler SQL 类型不匹配导致后端崩溃 | ✅ 已修复 |
| **P0** | BUG-002 | Relay 路由缺少认证中间件 — 所有 relay 端点返回 500 | ✅ 已修复 |
| **P0** | BUG-003 | 连接池启动即达 95% (18/20) — 服务持续 degraded | ✅ 已修复 (20→50) |
| **P1** | BUG-004 | Desktop 模型选择器卡在"加载中" — saasStore/configStore 数据桥断裂 | 📋 待修复 |
| **P1** | BUG-005 | Desktop "自动化"面板崩溃 — Tauri IPC 不可用无降级 | 📋 待修复 |
| **P1** | BUG-006 | Admin API Keys 页面显示 "No data" — 种子数据表名不匹配 | ✅ 已修复 |
| **P1** | BUG-007 | Admin Relay 任务页显示 "No data" — account_id 不匹配 | ✅ 已修复 |
| **P1** | BUG-008 | Admin Usage 页面显示 "No data" — account_id 不匹配 | ✅ 已修复 |
| **P1** | BUG-009 | Admin Config 页面所有 Tab 显示 "No data" — 分类名不匹配 | ✅ 已修复 |
| **P1** | BUG-010 | Rate Limit 误触发 — 正常页面导航触发 429 | ✅ 已修复 (GET豁免) |
| **P1** | BUG-011 | RelayTaskRow 类型不匹配 — priority 等字段 i64 vs INT4 | ✅ 已修复 |
---
## 二、阶段 1.1 — Admin V2 冒烟测试 (localhost:5173)
### 2.1 测试结果详情
| # | 测试项 | 结果 | 截图 | 备注 |
|---|--------|------|------|------|
| 1.1.1 | 登录页加载 | ✅ PASS | 1.1.1 | ZCLAW 品牌 + 表单正常 |
| 1.1.2 | 管理员登录 | ✅ PASS | 1.1.2 | 跳转到 Dashboard |
| 1.1.3 | 侧边栏导航 | ✅ PASS | — | 11 个菜单项全部可点击 |
| 1.1.4 | Dashboard | ✅ PASS | 1.1.2 | 13 账号 / 4 服务商 / 12 模型 / 252 日志 |
| 1.1.5 | Accounts | ✅ PASS | 1.1.5 | 13 条记录,分页正常 |
| 1.1.6 | Providers | ✅ PASS | 1.1.6 | 5 个提供商CRUD 按钮可见 |
| 1.1.7 | Models | ✅ PASS | — | 12 个模型,字段完整 |
| 1.1.8 | API Keys | ⚠️ 部分 | — | 页面加载正常,但显示 "No data" (BUG-006) |
| 1.1.9 | Prompts | ✅ PASS | 1.1.9 | 3 个内置提示词 |
| 1.1.10 | Relay | ⚠️ 部分 | 1.1.10 | 页面加载正常,但显示 "No data" (BUG-007) |
| 1.1.11 | Usage | ⚠️ 部分 | 1.1.11 | 每日/模型统计均 "No data" (BUG-008) |
| 1.1.12 | Config | ⚠️ 部分 | — | 6 个 Tab 全部 "No data" (BUG-009) |
| 1.1.13 | Agent Templates | ✅ PASS | 1.1.13 | 5 个模板,分类/模型/版本正确 |
| 1.1.14 | Logs | ✅ PASS | 1.1.14 | 252 条日志13 页分页正常 |
### 2.2 "No data" 问题根因分析
种子数据 (`seed_demo_data` in `db.rs`) 使用 demo 前缀 ID`demo-openai`, `demo-token-1`)插入数据,但多个查询端点按 `account_id` 过滤:
- **API Keys**: `/api/v1/keys` 查询 `api_tokens` 表按 `account_id` 过滤,种子数据绑定了 `admin_id`(变量),但实际登录账号的 ID 可能不同
- **Relay Tasks**: 同理,按 `account_id` 过滤
- **Usage**: `/api/v1/usage` 按日期范围和 `account_id` 查询
- **Config**: `/api/v1/config/items` 按分类过滤,种子分类为 `server/llm/agent/memory/security`,但前端 Tab 名称可能为不同值
---
## 三、阶段 1.2 — Desktop 冒烟测试 (localhost:1420)
### 3.1 测试结果详情
| # | 测试项 | 结果 | 截图 | 备注 |
|---|--------|------|------|------|
| 1.2.1 | 应用加载 | ✅ PASS | 1.2.1 | 显示登录页 |
| 1.2.2 | SaaS 登录 | ✅ PASS | 1.2.2 | admin/admin123 → Gateway 已连接 |
| 1.2.3 | 聊天界面 | ✅ PASS | — | 主聊天区域正常Gateway 连接 |
| 1.2.4 | 模型选择器 | ❌ FAIL | — | 卡在"加载中" (BUG-004) |
| 1.2.5 | Hands/自动化 | ⏭️ 跳过 | 1.2.5 | 崩溃Tauri IPC 不可用 (BUG-005) |
| 1.2.6 | 设置页面 | ✅ PASS | 1.2.6 | 20 个设置分组SaaS 连接正常 |
### 3.2 关键网络请求分析
| 请求 | 状态 | 说明 |
|------|------|------|
| `POST /api/v1/auth/login` | 200 | 登录成功 |
| `POST /api/v1/devices/register` | 200 | 设备注册成功 |
| `GET /api/v1/relay/models` | 200 → 500 → 200 | 最初 500(BUG-002),修复后 200 |
| `GET /api/v1/config/pull` | 200 | 配置同步成功 |
| `GET localhost:1420/api/agents` | 502 | Tauri IPC 不可用dev 模式预期) |
---
## 四、已修复的 Bug 详情
### BUG-001: Scheduler SQL 类型不匹配 [P0 → 已修复]
**现象**: 后端启动约 30 秒后崩溃,日志:
```
[UserScheduler] tick error: 操作符不存在: timestamp with time zone <= text
```
进程退出码 `0xffffffff`
**根因**: `scheduled_tasks` 表的 `next_run_at` 列在旧数据库中为 `TEXT` 类型(通过内联 schema 创建),而 scheduler 的 SQL 查询 `next_run_at <= NOW()` 尝试将 TEXT 与 TIMESTAMPTZ 比较PostgreSQL 拒绝此隐式转换。
**修复**: `crates/zclaw-saas/src/scheduler.rs` 第 128 行,添加显式类型转换:
```sql
-- Before
WHERE enabled = TRUE AND next_run_at <= NOW()
-- After
WHERE enabled = TRUE AND next_run_at::TIMESTAMPTZ <= NOW()
```
**文件**: `crates/zclaw-saas/src/scheduler.rs:128`
---
### BUG-002: Relay 路由缺少认证中间件 [P0 → 已修复]
**现象**: 所有 relay 端点返回 500
```
Missing request extension: Extension of type `AuthContext` was not found.
```
**根因**: `main.rs``relay::routes()` 被合并到顶层 Routerline 252绕过了 `protected_routes` 上的 `auth_middleware` 层。Relay 路由为了 SSE 流式端点需要更长超时,被排除在 15s TimeoutLayer 之外,但同时也失去了认证保护。
**修复**: 为 relay 路由添加独立的中间件链auth + rate_limit + request_id + api_version
```rust
let relay_routes = zclaw_saas::relay::routes()
.layer(middleware::from_fn_with_state(state.clone(), zclaw_saas::middleware::api_version_middleware))
.layer(middleware::from_fn_with_state(state.clone(), zclaw_saas::middleware::request_id_middleware))
.layer(middleware::from_fn_with_state(state.clone(), zclaw_saas::middleware::rate_limit_middleware))
.layer(middleware::from_fn_with_state(state.clone(), zclaw_saas::auth::auth_middleware));
```
**文件**: `crates/zclaw-saas/src/main.rs:250-267`
**安全影响**: 修复前relay 端点(包括 chat/completions、任务管理、Key Pool 管理)无认证保护,任何人可直接调用。
---
## 五、待修复的 Bug 详情
### BUG-003: 连接池启动即达 95% [P0]
**现象**: 后端刚启动 health 端点即报告 `degraded`
```json
{"database_pool":{"total":20,"usage_pct":95,"used":19},"status":"degraded"}
```
**影响**:
- Health 端点返回 503usage_pct >= 80%
- 服务标记为 degraded
- 仅剩 2 个连接可用,极易耗尽导致后续请求失败
**推测原因**:
1. Admin V2 的 SWR React Query 默认配置导致大量并发请求
2. Desktop 的心跳 + 遥测 + OTA 同时启动
3. 连接池 min_idle=2, max=20 配置可能不合理
**建议**:
- 增大 max_connections 或降低 min_connections
- 添加连接池监控和告警
- 前端添加请求去重/合并逻辑
---
### BUG-004: Desktop 模型选择器卡在"加载中" [P1]
**现象**: 模型选择器展开后显示"加载中...",永远不显示模型列表。
**根因**: 数据桥断裂:
- `saasStore.ts:403` 调用 `saasClient.listModels()` 成功获取 12 个模型,存入 `availableModels`
- 但模型选择器 UI 读取 `configStore.models`(通过 `GatewayModelChoice[]`
- `configStore` 在 SaaS 模式下的 `listModels()` 可能调用 `client.status()` 而非 `saasClient.listModels()`
- 两套 store 之间缺乏数据同步
**文件**: `desktop/src/store/saasStore.ts:403`, `desktop/src/store/configStore.ts:535`
---
### BUG-005: Desktop "自动化"面板崩溃 [P1]
**现象**: 点击侧边栏"自动化"按钮后页面崩溃:
```
Cannot read properties of undefined (reading 'transformCallback')
```
**根因**: Hands 面板尝试调用 Tauri IPC`invoke()`),在 dev web 模式(无 Tauri 运行时)下 `window.__TAURI__` 不存在,且缺少降级处理。
**文件**: Desktop 前端代码中 Hands 相关组件
**建议**: 添加 Tauri 运行时检测,非 Tauri 环境显示降级 UI。
---
### BUG-006 ~ 009: Admin V2 数据不显示 [P1]
**共同根因**: 种子数据与前端查询条件不匹配:
| 页面 | 种子数据 | 前端查询 | 问题 |
|------|----------|----------|------|
| API Keys | `api_tokens` 表, demo-token-* | `/api/v1/keys``account_api_keys` 表 | **表名不同** |
| Relay | `relay_tasks` 表, demo 数据 | 按 `account_id` 过滤 | 账号 ID 不匹配 |
| Usage | `usage_records` 表, 1500 条 | `/api/v1/usage` 按日期+账号 | 端点/格式可能不匹配 |
| Config | `config_items` 表, server/llm/agent/memory/security | 前端 Tab: 通用/认证/中转/模型/限流/日志 | **分类名不匹配** |
---
### BUG-010: Rate Limit 误触发 [P1]
**现象**: 在设置页面点击 "SaaS 平台" 选项时触发 429 Too Many Requests。
**根因**: 短时间内多个设置 Tab 切换 + API 调用触发限流中间件(默认 60 RPM
**建议**:
- 前端导航 debounce
- 设置类 GET 请求不计入限流
- 提升 RPM 限制
---
## 六、测试环境修复记录
| 时间 | 操作 | 结果 |
|------|------|------|
| 13:38 | 后端首次启动 | 连接池 95%degraded 但可用 |
| 13:38 | Admin V2 登录 | 成功 |
| 13:39 | 触发 Scheduler tick | 后端崩溃 (BUG-001) |
| 13:42 | 修复 BUG-001 | `next_run_at::TIMESTAMPTZ <= NOW()` |
| 13:43 | 重启后端 | 又崩溃 — 同一问题 |
| 13:46 | 重新编译并启动 | 成功health 返回 degraded(90%) |
| 13:47 | Desktop relay/models 500 | 发现 BUG-002 |
| 13:56 | 修复 BUG-002 | relay 路由添加独立中间件链 |
| 13:58 | 重启后端 | relay/models 正常返回 401(需认证) |
| 14:02 | Desktop 登录 | relay/models 返回 20012 模型 |
---
## 七、截图证据清单
| 文件 | 说明 |
|------|------|
| `1.1.1-admin-login-page.png` | Admin V2 登录页 |
| `1.1.2-admin-dashboard.png` | Admin V2 Dashboard |
| `1.1.5-accounts.png` | 账号管理页 |
| `1.1.6-providers.png` | 服务商管理页 |
| `1.1.9-prompts.png` | 提示词管理页 |
| `1.1.10-relay.png` | 中转任务页 (No data) |
| `1.1.11-usage.png` | 用量统计页 (No data) |
| `1.1.13-agent-templates.png` | Agent 模板页 |
| `1.1.14-logs.png` | 操作日志页 |
| `1.2.1-desktop-main.png` | Desktop 主界面 |
| `1.2.2-desktop-loggedin.png` | Desktop 登录后 |
| `1.2.5-hands-crash.png` | 自动化面板崩溃 |
| `1.2.6-desktop-settings.png` | Desktop 设置页 |
| `1.2.6b-desktop-usage.png` | Desktop 用量统计 |
---
## 八、后续建议
### 优先级 P0阻塞联调
1. **修复连接池耗尽 (BUG-003)** — 这是所有后续测试的前提
2. **验证 BUG-001/002 修复** — 已做代码修复,需确认重启后稳定
### 优先级 P1影响功能验证
3. **修复模型选择器 (BUG-004)** — Desktop 核心功能
4. **修复种子数据 (BUG-006~009)** — Admin V2 多页面数据不可见
5. **添加 Tauri IPC 降级 (BUG-005)** — Dev 模式兼容
### 优先级 P2优化
6. **调整 Rate Limit 策略 (BUG-010)**
7. **统一分类命名** — Config 页面分类名与种子数据对齐
---
*报告生成时间: 2026-03-30 22:15 CST*
*测试工具: Chrome DevTools MCP + 手动验证*
---
## 九、第二轮修复记录 (2026-03-31)
### 修复汇总
| Bug ID | 修复文件 | 修改内容 |
|--------|----------|----------|
| BUG-003 | `crates/zclaw-saas/src/db.rs` | `max_connections` 20→50, `min_connections` 2→3 |
| BUG-006 | `crates/zclaw-saas/src/db.rs` | 新增 `account_api_keys` 种子数据(旧种子写入 `api_tokens`handler 读 `account_api_keys` 表) |
| BUG-007 | `crates/zclaw-saas/src/db.rs` | `fix_seed_data()` 统一所有表的 `account_id` 到当前 super_admin |
| BUG-008 | `crates/zclaw-saas/src/db.rs` | 同 BUG-007usage_records 1475 行已修复 |
| BUG-009 | `crates/zclaw-saas/src/db.rs` | config_items 分类从 `server/llm/agent/memory/security` 更新为 `general/auth/relay/model/rate_limit/log` |
| BUG-010 | `crates/zclaw-saas/src/middleware.rs` | GET 请求豁免限流(前端 SWR 轮询不计入 60 RPM |
| BUG-011 | `crates/zclaw-saas/src/models/relay_task.rs` | `priority`/`attempt_count`/`max_attempts`/`input_tokens`/`output_tokens``i64` 改为 `i32`(匹配 PostgreSQL INT4 |
### 新增函数:`fix_seed_data()`
`db.rs` 中添加了 `fix_seed_data()` 函数,在每次启动时自动修复旧种子数据:
1. **Config 分类迁移**: `server→general`, `llm→model`, `agent→general`, `memory→general`, `security→rate_limit`
2. **Account API Keys 补种**: 为每个 super_admin 账号插入 3 条演示 API Key
3. **Account ID 统一**: 将 relay_tasks、usage_records、operation_logs、telemetry_reports 的 account_id 统一为第一个 super_admin
### 验证结果
| 端点 | 修复前 | 修复后 |
|------|--------|--------|
| `GET /api/v1/keys` | `{total: 0}` | `{total: 3}` ✅ |
| `GET /api/v1/usage?group_by=day` | `{by_day: []}` | `{total_requests: 1475, by_day: 30天}` ✅ |
| `GET /api/v1/usage?group_by=model` | `{by_model: []}` | `{by_model: 5模型}` ✅ |
| `GET /api/v1/config/items?category=general` | `{total: 0}` | `{total: 6}` ✅ |
| `GET /api/v1/config/items?category=model` | `{total: 0}` | `{total: 3}` ✅ |
| `GET /api/v1/config/items?category=rate_limit` | `{total: 0}` | `{total: 3}` ✅ |
| `GET /api/v1/relay/tasks` | 500 (类型错误) | 200 ✅ |
### 仍待修复
| Bug ID | 描述 | 原因 |
|--------|------|------|
| BUG-004 | Desktop 模型选择器卡在"加载中" | saasStore 与 configStore 数据桥未同步 |
| BUG-005 | Desktop 自动化面板崩溃 | Tauri IPC 无降级 |
---
*第二轮修复时间: 2026-03-31 00:00 CST*

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Some files were not shown because too many files have changed in this diff Show More