diff --git a/crates/zclaw-saas/src/account/handlers.rs b/crates/zclaw-saas/src/account/handlers.rs index 770c297..329f84f 100644 --- a/crates/zclaw-saas/src/account/handlers.rs +++ b/crates/zclaw-saas/src/account/handlers.rs @@ -146,7 +146,7 @@ pub async fn list_operation_logs( let rows: Vec = sqlx::query_as( - "SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at + "SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at::TEXT FROM operation_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2" ) .bind(page_size as i64) @@ -186,13 +186,11 @@ pub async fn dashboard_stats( let today_start = chrono::Utc::now() .date_naive() .and_hms_opt(0, 0, 0).expect("midnight is always valid") - .and_utc() - .to_rfc3339(); + .and_utc(); let tomorrow_start = (chrono::Utc::now() + chrono::Duration::days(1)) .date_naive() .and_hms_opt(0, 0, 0).expect("midnight is always valid") - .and_utc() - .to_rfc3339(); + .and_utc(); let today_row: DashboardTodayRow = sqlx::query_as( "SELECT (SELECT COUNT(*) FROM relay_tasks WHERE created_at >= $1 AND created_at < $2) as tasks_today, @@ -248,7 +246,7 @@ pub async fn register_device( let device_name = if req.device_name.is_empty() { "Unknown" } else { &req.device_name }; let platform = if req.platform.is_empty() { "unknown" } else { &req.platform }; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let device_uuid = uuid::Uuid::new_v4().to_string(); // UPSERT: 已存在则更新 last_seen_at,不存在则插入 @@ -285,7 +283,7 @@ pub async fn device_heartbeat( .and_then(|v| v.as_str()) .ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // Also update platform/app_version if provided (supports client upgrades) let platform = req.get("platform").and_then(|v| v.as_str()); diff --git a/crates/zclaw-saas/src/account/service.rs b/crates/zclaw-saas/src/account/service.rs index 26ff1dd..7a6a449 100644 --- a/crates/zclaw-saas/src/account/service.rs +++ b/crates/zclaw-saas/src/account/service.rs @@ -23,7 +23,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)" ).bind(role).bind(status).bind(&pattern).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3) ORDER BY created_at DESC LIMIT $4 OFFSET $5" ).bind(role).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -35,7 +35,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2" ).bind(role).bind(status).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE role = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4" ).bind(role).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -48,7 +48,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)" ).bind(role).bind(&pattern).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4" ).bind(role).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -61,7 +61,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)" ).bind(status).bind(&pattern).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4" ).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -73,7 +73,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE role = $1" ).bind(role).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE role = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ).bind(role).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -85,7 +85,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE status = $1" ).bind(status).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -98,7 +98,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)" ).bind(&pattern).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3" ).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; @@ -110,7 +110,7 @@ pub async fn list_accounts( "SELECT COUNT(*) FROM accounts" ).fetch_one(db).await?; let rows = sqlx::query_as::<_, AccountRow>( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts ORDER BY created_at DESC LIMIT $1 OFFSET $2" ).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?; (total, rows) @@ -134,7 +134,7 @@ pub async fn list_accounts( pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing FROM accounts WHERE id = $1" ) .bind(account_id) @@ -155,7 +155,7 @@ pub async fn update_account( account_id: &str, req: &UpdateAccountRequest, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // COALESCE pattern: all updatable fields in a single static SQL. // NULL parameters leave the column unchanged. @@ -190,7 +190,7 @@ pub async fn update_account_status( if !valid.contains(&status) { return Err(SaasError::InvalidInput(format!("无效状态: {},有效值: {:?}", status, valid))); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let result = sqlx::query("UPDATE accounts SET status = $1, updated_at = $2 WHERE id = $3") .bind(status).bind(&now).bind(account_id) .execute(db).await?; @@ -215,9 +215,9 @@ pub async fn create_api_token( let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes())); let token_prefix = raw_token[..8].to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let expires_at = req.expires_days.map(|d| { - (chrono::Utc::now() + chrono::Duration::days(d)).to_rfc3339() + chrono::Utc::now() + chrono::Duration::days(d) }); let permissions = serde_json::to_string(&req.permissions)?; let token_id = uuid::Uuid::new_v4().to_string(); @@ -243,8 +243,8 @@ pub async fn create_api_token( token_prefix, permissions: req.permissions.clone(), last_used_at: None, - expires_at, - created_at: now, + expires_at: expires_at.map(|dt| dt.to_rfc3339()), + created_at: now.to_rfc3339(), token: Some(raw_token), }) } @@ -266,7 +266,7 @@ pub async fn list_api_tokens( let rows: Vec = sqlx::query_as( - "SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at + "SELECT id, name, token_prefix, permissions, last_used_at::TEXT, expires_at::TEXT, created_at::TEXT FROM api_tokens WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3" ) .bind(account_id) @@ -300,7 +300,7 @@ pub async fn list_devices( let rows: Vec = sqlx::query_as( - "SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at + "SELECT id, device_id, device_name, platform, app_version, last_seen_at::TEXT, created_at::TEXT FROM devices WHERE account_id = $1 ORDER BY last_seen_at DESC LIMIT $2 OFFSET $3" ) .bind(account_id) @@ -321,7 +321,7 @@ pub async fn list_devices( } pub async fn revoke_api_token(db: &PgPool, token_id: &str, account_id: &str) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND account_id = $3 AND revoked_at IS NULL" ) diff --git a/crates/zclaw-saas/src/agent_template/service.rs b/crates/zclaw-saas/src/agent_template/service.rs index 9ab9b23..bb5095c 100644 --- a/crates/zclaw-saas/src/agent_template/service.rs +++ b/crates/zclaw-saas/src/agent_template/service.rs @@ -4,8 +4,16 @@ use sqlx::{PgPool, Row}; use crate::error::{SaasError, SaasResult}; use super::types::*; -/// Shared SELECT column list. +/// Shared SELECT column list (with ::TEXT cast for TIMESTAMPTZ decode). const SELECT_COLUMNS: &str = "\ + id, name, description, category, source, model, system_prompt, \ + tools, capabilities, temperature, max_tokens, visibility, status, \ + current_version, created_at::TEXT, updated_at::TEXT, \ + soul_content, scenarios, welcome_message, quick_commands, \ + personality, communication_style, emoji, version, source_id"; + +/// Plain column names for INSERT statements (no casts). +const INSERT_COLUMNS: &str = "\ id, name, description, category, source, model, system_prompt, \ tools, capabilities, temperature, max_tokens, visibility, status, \ current_version, created_at, updated_at, \ @@ -69,14 +77,14 @@ pub async fn create_template( source_id: Option<&str>, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let tools_json = serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string()); let caps_json = serde_json::to_string(capabilities).unwrap_or_else(|_| "[]".to_string()); let scenarios_json = serde_json::to_string(&scenarios.unwrap_or(&[])).unwrap_or_else(|_| "[]".to_string()); let quick_commands_json = serde_json::to_string(&quick_commands.unwrap_or(&[])).unwrap_or_else(|_| "[]".to_string()); sqlx::query( - &format!("INSERT INTO agent_templates ({}) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',1,$13,$13,$14,$15,$16,$17,$18,$19,$20,1,$21)", SELECT_COLUMNS) + &format!("INSERT INTO agent_templates ({}) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',1,$13,$13,$14,$15,$16,$17,$18,$19,$20,1,$21)", INSERT_COLUMNS) ) .bind(&id) // $1 id .bind(name) // $2 name @@ -209,7 +217,7 @@ pub async fn update_template( // Confirm existence get_template(db, id).await?; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // Serialize JSON fields upfront so we can bind Option<&str> consistently let tools_json = tools.map(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string())); @@ -282,7 +290,7 @@ pub async fn assign_template_to_account( return Err(SaasError::InvalidInput("模板不可用(已归档)".into())); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE accounts SET assigned_template_id = $1, updated_at = $2 WHERE id = $3" ) @@ -317,7 +325,7 @@ pub async fn get_assigned_template( Ok(t) => Ok(Some(t)), Err(SaasError::NotFound(_)) => { // Template deleted — clear stale reference - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2" ) @@ -336,7 +344,7 @@ pub async fn unassign_template( db: &PgPool, account_id: &str, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2" ) diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index 985878f..f539e2a 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -116,7 +116,7 @@ pub async fn register( let account_id = uuid::Uuid::new_v4().to_string(); let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配 let display_name = req.display_name.unwrap_or_default(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at, llm_routing) @@ -175,7 +175,7 @@ pub async fn register( role, status: "active".into(), totp_enabled: false, - created_at: now, + created_at: now.to_rfc3339(), llm_routing: "local".into(), }, }; @@ -194,8 +194,8 @@ pub async fn login( let row: Option = sqlx::query_as( "SELECT id, username, email, display_name, role, status, totp_enabled, - password_hash, totp_secret, created_at, llm_routing, - password_version, failed_login_count, locked_until + password_hash, totp_secret, created_at::TEXT, llm_routing, + password_version, failed_login_count, locked_until::TEXT FROM accounts WHERE username = $1 OR email = $1" ) .bind(&req.username) @@ -222,7 +222,7 @@ pub async fn login( let new_count = r.failed_login_count + 1; if new_count >= 5 { // 锁定 15 分钟 - let locked_until = (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339(); + let locked_until = chrono::Utc::now() + chrono::Duration::minutes(15); sqlx::query( "UPDATE accounts SET failed_login_count = $1, locked_until = $2 WHERE id = $3" ) @@ -280,7 +280,7 @@ pub async fn login( )?; drop(config); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // 登录成功: 重置失败计数和锁定状态 sqlx::query("UPDATE accounts SET last_login_at = $1, failed_login_count = 0, locked_until = NULL WHERE id = $2") .bind(&now).bind(&r.id) @@ -330,7 +330,7 @@ pub async fn refresh( "SELECT account_id FROM refresh_tokens WHERE jti = $1 AND used_at IS NULL AND expires_at > $2" ) .bind(jti) - .bind(&chrono::Utc::now().to_rfc3339()) + .bind(&chrono::Utc::now()) .fetch_optional(&state.db) .await?; @@ -344,7 +344,7 @@ pub async fn refresh( } // 5. 标记旧 refresh token 为已使用 (一次性) - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query("UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2") .bind(&now).bind(jti) .execute(&state.db).await?; @@ -387,7 +387,7 @@ pub async fn refresh( let new_claims = verify_token(&new_refresh, state.jwt_secret.expose_secret())?; let new_jti = new_claims.jti.unwrap_or_default(); let new_id = uuid::Uuid::new_v4().to_string(); - let refresh_expires = (chrono::Utc::now() + chrono::Duration::hours(168)).to_rfc3339(); + let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(168); sqlx::query( "INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)" @@ -413,7 +413,7 @@ pub async fn me( ) -> SaasResult> { let row: Option = sqlx::query_as( - "SELECT id, username, email, display_name, role, status, totp_enabled, created_at, llm_routing + "SELECT id, username, email, display_name, role, status, totp_enabled, created_at::TEXT, llm_routing FROM accounts WHERE id = $1" ) .bind(&ctx.account_id) @@ -454,7 +454,7 @@ pub async fn change_password( // 更新密码 + 递增 password_version 使旧 token 失效 let new_hash = hash_password_async(req.new_password.clone()).await?; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2, password_version = password_version + 1 WHERE id = $3") .bind(&new_hash) .bind(&now) @@ -515,7 +515,7 @@ pub async fn log_operation( details: Option, ip_address: Option<&str>, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)" @@ -543,8 +543,8 @@ async fn store_refresh_token( let claims = verify_token(refresh_token, secret)?; let jti = claims.jti.unwrap_or_default(); let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let expires_at = (chrono::Utc::now() + chrono::Duration::hours(refresh_hours)).to_rfc3339(); + let now = chrono::Utc::now(); + let expires_at = chrono::Utc::now() + chrono::Duration::hours(refresh_hours); sqlx::query( "INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at) @@ -560,7 +560,7 @@ async fn store_refresh_token( /// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用 #[allow(dead_code)] async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // 删除过期超过 30 天的已使用 token (减少 DB 膨胀) sqlx::query( "DELETE FROM refresh_tokens WHERE (used_at IS NOT NULL AND used_at < $1) OR (expires_at < $1)" @@ -587,7 +587,7 @@ pub async fn logout( 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(); + let now = chrono::Utc::now(); // 标记 refresh token 为已使用(等效于撤销/黑名单) let result = sqlx::query( "UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2 AND used_at IS NULL" diff --git a/crates/zclaw-saas/src/auth/mod.rs b/crates/zclaw-saas/src/auth/mod.rs index 915c091..67f36dc 100644 --- a/crates/zclaw-saas/src/auth/mod.rs +++ b/crates/zclaw-saas/src/auth/mod.rs @@ -28,7 +28,7 @@ async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option, String)> = sqlx::query_as( - "SELECT account_id, expires_at, permissions FROM api_tokens + "SELECT account_id, expires_at::TEXT, permissions FROM api_tokens WHERE token_hash = $1 AND revoked_at IS NULL" ) .bind(&token_hash) diff --git a/crates/zclaw-saas/src/billing/mod.rs b/crates/zclaw-saas/src/billing/mod.rs index 063abed..5906b42 100644 --- a/crates/zclaw-saas/src/billing/mod.rs +++ b/crates/zclaw-saas/src/billing/mod.rs @@ -8,23 +8,41 @@ pub mod invoice_pdf; use axum::routing::{get, post}; -/// 需要认证的计费路由 +/// 全部计费路由(用于 main.rs 一次性挂载) pub fn routes() -> axum::Router { axum::Router::new() .route("/api/v1/billing/plans", get(handlers::list_plans)) - .route("/api/v1/billing/plans/{id}", get(handlers::get_plan)) + .route("/api/v1/billing/plans/:id", get(handlers::get_plan)) .route("/api/v1/billing/subscription", get(handlers::get_subscription)) .route("/api/v1/billing/usage", get(handlers::get_usage)) .route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension)) .route("/api/v1/billing/payments", post(handlers::create_payment)) - .route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status)) - .route("/api/v1/billing/invoices/{id}/pdf", get(handlers::get_invoice_pdf)) + .route("/api/v1/billing/payments/:id", get(handlers::get_payment_status)) + .route("/api/v1/billing/invoices/:id/pdf", get(handlers::get_invoice_pdf)) +} + +/// 计划查询路由(无需 AuthContext,可挂载到公开区域) +pub fn plan_routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/billing/plans", get(handlers::list_plans)) + .route("/api/v1/billing/plans/:id", get(handlers::get_plan)) +} + +/// 需要认证的计费路由(订阅、用量、支付、发票) +pub fn protected_routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/billing/subscription", get(handlers::get_subscription)) + .route("/api/v1/billing/usage", get(handlers::get_usage)) + .route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension)) + .route("/api/v1/billing/payments", post(handlers::create_payment)) + .route("/api/v1/billing/payments/:id", get(handlers::get_payment_status)) + .route("/api/v1/billing/invoices/:id/pdf", get(handlers::get_invoice_pdf)) } /// 支付回调路由(无需 auth — 支付宝/微信服务器回调) pub fn callback_routes() -> axum::Router { axum::Router::new() - .route("/api/v1/billing/callback/{method}", post(handlers::payment_callback)) + .route("/api/v1/billing/callback/:method", post(handlers::payment_callback)) } /// mock 支付页面路由(开发模式) diff --git a/crates/zclaw-saas/src/billing/payment.rs b/crates/zclaw-saas/src/billing/payment.rs index a0c9a59..fc3b361 100644 --- a/crates/zclaw-saas/src/billing/payment.rs +++ b/crates/zclaw-saas/src/billing/payment.rs @@ -63,8 +63,8 @@ pub async fn create_payment( .bind(plan.price_cents) .bind(&plan.currency) .bind(format!("{} - {} ({})", plan.display_name, plan.interval, now.format("%Y-%m"))) - .bind(due.to_rfc3339()) - .bind(now.to_rfc3339()) + .bind(&due) + .bind(&now) .execute(&mut *tx) .await?; @@ -83,7 +83,7 @@ pub async fn create_payment( .bind(&plan.currency) .bind(req.payment_method.to_string()) .bind(&trade_no) - .bind(now.to_rfc3339()) + .bind(&now) .execute(&mut *tx) .await?; @@ -168,7 +168,7 @@ pub async fn handle_payment_callback( tracing::warn!("DEV: Skipping amount verification for trade={}", sanitize_log(trade_no)); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); if status == "success" || status == "TRADE_SUCCESS" || status == "SUCCESS" { // 3. 更新支付状态 @@ -211,8 +211,8 @@ pub async fn handle_payment_callback( // 7. 创建新订阅(30 天周期) let sub_id = uuid::Uuid::new_v4().to_string(); - let period_end = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339(); - let period_start = chrono::Utc::now().to_rfc3339(); + let period_end = chrono::Utc::now() + chrono::Duration::days(30); + let period_start = chrono::Utc::now(); sqlx::query( "INSERT INTO billing_subscriptions \ diff --git a/crates/zclaw-saas/src/db.rs b/crates/zclaw-saas/src/db.rs index f398d55..0d9396b 100644 --- a/crates/zclaw-saas/src/db.rs +++ b/crates/zclaw-saas/src/db.rs @@ -313,16 +313,14 @@ fn split_sql_statements(sql: &str) -> Vec { /// Seed 角色数据 async fn seed_roles(pool: &PgPool) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); sqlx::query( r#"INSERT INTO roles (id, name, description, permissions, is_system, created_at, updated_at) VALUES - ('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write","prompt:read","prompt:write","prompt:publish","prompt:admin"]', TRUE, $1, $1), - ('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write","prompt:read","prompt:write","prompt:publish"]', TRUE, $1, $1), - ('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read","prompt:read"]', TRUE, $1, $1) + ('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write","prompt:read","prompt:write","prompt:publish","prompt:admin"]', TRUE, NOW(), NOW()), + ('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write","prompt:read","prompt:write","prompt:publish"]', TRUE, NOW(), NOW()), + ('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read","prompt:read"]', TRUE, NOW(), NOW()) ON CONFLICT (id) DO NOTHING"# ) - .bind(&now) .execute(pool) .await?; Ok(()) @@ -364,7 +362,7 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> { if let Some((account_id,)) = existing { // 更新现有用户的密码和角色(使用 spawn_blocking 避免阻塞 tokio 运行时) let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE accounts SET password_hash = $1, role = 'super_admin', updated_at = $2 WHERE id = $3" @@ -381,7 +379,7 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> { let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?; let account_id = uuid::Uuid::new_v4().to_string(); let email = format!("{}@zclaw.local", admin_username); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at) @@ -411,7 +409,7 @@ async fn seed_builtin_prompts(pool: &PgPool) -> SaasResult<()> { return Ok(()); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // reflection 提示词 let reflection_id = uuid::Uuid::new_v4().to_string(); @@ -490,7 +488,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("demo-local", "local-ollama", "本地 Ollama", "http://localhost:11434/v1", false, 10, 20000), ]; for (id, name, display, url, enabled, rpm, tpm) in &providers { - let ts = now.to_rfc3339(); + let ts = now; sqlx::query( "INSERT INTO providers (id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at) VALUES ($1, $2, $3, $4, 'openai', $5, $6, $7, $8, $8) ON CONFLICT (id) DO NOTHING" @@ -518,7 +516,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("demo-deepseek-reasoner", "demo-deepseek", "deepseek-reasoner", "DeepSeek R1", 65536, 8192, true, false, 0.00055, 0.00219), ]; for (id, pid, mid, alias, ctx, max_out, stream, vision, price_in, price_out) in &models { - let ts = now.to_rfc3339(); + let ts = now; sqlx::query( "INSERT INTO models (id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $11) ON CONFLICT (id) DO NOTHING" @@ -537,7 +535,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("demo-key-d1", "demo-deepseek", "DeepSeek Key 1", "sk-demo-deepseek-key-1-xxxxx", 0, 30, 50000), ]; for (id, pid, label, kv, priority, rpm, tpm) in &provider_keys { - let ts = now.to_rfc3339(); + let ts = now; sqlx::query( "INSERT INTO provider_keys (id, provider_id, key_label, key_value, priority, max_rpm, max_tpm, is_active, total_requests, total_tokens, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, true, 0, 0, $8, $8) ON CONFLICT (id) DO NOTHING" @@ -565,7 +563,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1); let hour = rng_seed as i32 % 24; rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1); - let ts = (day + chrono::Duration::hours(hour as i64) + chrono::Duration::minutes(i as i64)).to_rfc3339(); + let ts = day + chrono::Duration::hours(hour as i64) + chrono::Duration::minutes(i as i64); let input = (500 + (rng_seed % 8000)) as i32; rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1); let output = (200 + (rng_seed % 4000)) as i32; @@ -590,8 +588,8 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { let (provider_id, model_id) = models_for_usage[i % models_for_usage.len()]; let status = relay_statuses[i % relay_statuses.len()]; let offset_hours = (20 - i) as i64; - let ts = (now - chrono::Duration::hours(offset_hours)).to_rfc3339(); - let ts_completed = (now - chrono::Duration::hours(offset_hours) + chrono::Duration::seconds(3)).to_rfc3339(); + let ts = now - chrono::Duration::hours(offset_hours); + let ts_completed = now - chrono::Duration::hours(offset_hours) + chrono::Duration::seconds(3); let task_id = uuid::Uuid::new_v4().to_string(); let hash = format!("{:064x}", i); let body = format!(r#"{{"model":"{}","messages":[{{"role":"user","content":"demo request {}"}}]}}"#, model_id, i); @@ -609,7 +607,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ).bind(&task_id).bind(&admin_id).bind(provider_id).bind(model_id) .bind(&hash).bind(status).bind(&body) .bind(in_tok).bind(out_tok).bind(err.as_deref()) - .bind(&ts).bind(&ts).bind(if status == "queued" { None::<&str> } else { Some(ts_completed.as_str()) }) + .bind(&ts).bind(&ts).bind(if status == "queued" { None::<&chrono::DateTime> } else { Some(&ts_completed) }) .execute(pool).await?; } @@ -681,7 +679,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ]; for (id, name, desc, cat, model, prompt, tools, caps, temp, max_tok, soul, scenarios, welcome, quick_cmds, personality, comm_style, emoji, source_id) in &agent_templates { - let ts = now.to_rfc3339(); + let ts = now; sqlx::query( "INSERT INTO agent_templates (id, name, description, category, source, model, system_prompt, tools, capabilities, temperature, max_tokens, visibility, status, current_version, created_at, updated_at, @@ -724,7 +722,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("log", "slow_query_threshold_ms", "integer", "1000", "2000", "慢查询阈值(ms)"), ]; for (cat, key, vtype, current, default, desc) in &config_items { - let ts = now.to_rfc3339(); + let ts = now; 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) @@ -740,7 +738,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("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(); + let ts = now; 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" @@ -755,7 +753,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { ("demo-token-3", "Testing Key", "zclaw_test_jK4lM6nO8pQ0", "[\"relay:use\"]"), ]; for (id, name, prefix, perms) in &api_tokens { - let ts = now.to_rfc3339(); + let ts = now; let hash = { use sha2::{Sha256, Digest}; hex::encode(Sha256::digest(format!("{}-dummy-hash", id).as_bytes())) @@ -786,7 +784,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { for i in 0..50 { let (action, target_type, _detail) = log_actions[i % log_actions.len()]; let offset_hours = (i * 3 + 1) as i64; - let ts = (now - chrono::Duration::hours(offset_hours)).to_rfc3339(); + let ts = now - chrono::Duration::hours(offset_hours); let detail = serde_json::json!({"index": i}).to_string(); sqlx::query( "INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at) @@ -801,7 +799,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { for day_offset in 0i32..14 { let day = now - chrono::Duration::days(13 - day_offset as i64); for h in 0i32..8 { - let ts = (day + chrono::Duration::hours(h as i64 * 3)).to_rfc3339(); + let ts = day + chrono::Duration::hours(h as i64 * 3); let model = telem_models[(day_offset as usize + h as usize) % telem_models.len()]; let report_id = format!("telem-d{}-h{}", day_offset, h); let input = 1000 + (day_offset as i64 * 100 + h as i64 * 50); @@ -828,7 +826,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> { /// - 旧种子将 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(); + let now = chrono::Utc::now(); // 1. 获取所有 super_admin account_id(可能有多个) let admins: Vec<(String,)> = sqlx::query_as( diff --git a/crates/zclaw-saas/src/knowledge/mod.rs b/crates/zclaw-saas/src/knowledge/mod.rs index 08a9c64..7f1fa0f 100644 --- a/crates/zclaw-saas/src/knowledge/mod.rs +++ b/crates/zclaw-saas/src/knowledge/mod.rs @@ -11,22 +11,22 @@ pub fn routes() -> axum::Router { // 分类管理 .route("/api/v1/knowledge/categories", get(handlers::list_categories)) .route("/api/v1/knowledge/categories", post(handlers::create_category)) - .route("/api/v1/knowledge/categories/{id}", put(handlers::update_category)) - .route("/api/v1/knowledge/categories/{id}", delete(handlers::delete_category)) - .route("/api/v1/knowledge/categories/{id}/items", get(handlers::list_category_items)) + .route("/api/v1/knowledge/categories/:id", put(handlers::update_category)) + .route("/api/v1/knowledge/categories/:id", delete(handlers::delete_category)) + .route("/api/v1/knowledge/categories/:id/items", get(handlers::list_category_items)) .route("/api/v1/knowledge/categories/reorder", patch(handlers::reorder_categories)) // 知识条目 CRUD .route("/api/v1/knowledge/items", get(handlers::list_items)) .route("/api/v1/knowledge/items", post(handlers::create_item)) .route("/api/v1/knowledge/items/batch", post(handlers::batch_create_items)) .route("/api/v1/knowledge/items/import", post(handlers::import_items)) - .route("/api/v1/knowledge/items/{id}", get(handlers::get_item)) - .route("/api/v1/knowledge/items/{id}", put(handlers::update_item)) - .route("/api/v1/knowledge/items/{id}", delete(handlers::delete_item)) + .route("/api/v1/knowledge/items/:id", get(handlers::get_item)) + .route("/api/v1/knowledge/items/:id", put(handlers::update_item)) + .route("/api/v1/knowledge/items/:id", delete(handlers::delete_item)) // 版本控制 - .route("/api/v1/knowledge/items/{id}/versions", get(handlers::list_versions)) - .route("/api/v1/knowledge/items/{id}/versions/{v}", get(handlers::get_version)) - .route("/api/v1/knowledge/items/{id}/rollback/{v}", post(handlers::rollback_version)) + .route("/api/v1/knowledge/items/:id/versions", get(handlers::list_versions)) + .route("/api/v1/knowledge/items/:id/versions/:v", get(handlers::get_version)) + .route("/api/v1/knowledge/items/:id/rollback/:v", post(handlers::rollback_version)) // 检索 .route("/api/v1/knowledge/search", post(handlers::search)) .route("/api/v1/knowledge/recommend", post(handlers::recommend)) diff --git a/crates/zclaw-saas/src/migration/service.rs b/crates/zclaw-saas/src/migration/service.rs index 62f0713..06de888 100644 --- a/crates/zclaw-saas/src/migration/service.rs +++ b/crates/zclaw-saas/src/migration/service.rs @@ -15,19 +15,19 @@ pub(crate) async fn fetch_all_config_items( ) -> SaasResult> { let sql = match (&query.category, &query.source) { (Some(_), Some(_)) => { - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 AND source = $2 ORDER BY category, key_path" } (Some(_), None) => { - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 ORDER BY key_path" } (None, Some(_)) => { - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE source = $1 ORDER BY category, key_path" } (None, None) => { - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items ORDER BY category, key_path" } }; @@ -61,7 +61,7 @@ pub async fn list_config_items( "SELECT COUNT(*) FROM config_items WHERE category = $1 AND source = $2" ).bind(cat).bind(src).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 AND source = $2 ORDER BY category, key_path LIMIT $3 OFFSET $4" ).bind(cat).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) @@ -71,7 +71,7 @@ pub async fn list_config_items( "SELECT COUNT(*) FROM config_items WHERE category = $1" ).bind(cat).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3" ).bind(cat).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) @@ -81,7 +81,7 @@ pub async fn list_config_items( "SELECT COUNT(*) FROM config_items WHERE source = $1" ).bind(src).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE source = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3" ).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) @@ -91,7 +91,7 @@ pub async fn list_config_items( "SELECT COUNT(*) FROM config_items" ).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items ORDER BY category, key_path LIMIT $1 OFFSET $2" ).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) @@ -108,7 +108,7 @@ pub async fn list_config_items( pub async fn get_config_item(db: &PgPool, item_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE id = $1" ) .bind(item_id) @@ -124,7 +124,7 @@ pub async fn create_config_item( db: &PgPool, req: &CreateConfigItemRequest, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let source = req.source.as_deref().unwrap_or("local"); let requires_restart = req.requires_restart.unwrap_or(false); @@ -156,7 +156,7 @@ pub async fn create_config_item( pub async fn update_config_item( db: &PgPool, item_id: &str, req: &UpdateConfigItemRequest, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // COALESCE pattern: all updatable fields in a single static SQL. // NULL parameters leave the column unchanged. @@ -244,7 +244,7 @@ pub async fn seed_default_config_items(db: &PgPool) -> SaasResult { ]; let mut created = 0; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); for (category, key_path, value_type, default_value, current_value, description) in defaults { let existing: Option<(String,)> = sqlx::query_as( @@ -319,7 +319,7 @@ pub async fn compute_config_diff( pub async fn sync_config( db: &PgPool, account_id: &str, req: &SyncConfigRequest, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let config_keys_str = serde_json::to_string(&req.config_keys)?; let client_values_str = Some(serde_json::to_string(&req.client_values)?); @@ -458,7 +458,7 @@ pub async fn list_sync_logs( let rows: Vec = sqlx::query_as( - "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at + "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at::TEXT FROM config_sync_log WHERE account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ) .bind(account_id) diff --git a/crates/zclaw-saas/src/model_config/service.rs b/crates/zclaw-saas/src/model_config/service.rs index b894ffe..c644bb1 100644 --- a/crates/zclaw-saas/src/model_config/service.rs +++ b/crates/zclaw-saas/src/model_config/service.rs @@ -17,13 +17,13 @@ pub async fn list_providers( let (count_sql, data_sql) = if enabled_filter.is_some() { ( "SELECT COUNT(*) FROM providers WHERE enabled = $1", - "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at + "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at::TEXT, updated_at::TEXT FROM providers WHERE enabled = $1 ORDER BY name LIMIT $2 OFFSET $3", ) } else { ( "SELECT COUNT(*) FROM providers", - "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at + "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at::TEXT, updated_at::TEXT FROM providers ORDER BY name LIMIT $1 OFFSET $2", ) }; @@ -55,7 +55,7 @@ pub async fn list_providers( pub async fn get_provider(db: &PgPool, provider_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at + "SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at::TEXT, updated_at::TEXT FROM providers WHERE id = $1" ) .bind(provider_id) @@ -69,7 +69,7 @@ pub async fn get_provider(db: &PgPool, provider_id: &str) -> SaasResult SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // 检查名称唯一性 let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM providers WHERE name = $1") @@ -103,7 +103,7 @@ pub async fn create_provider(db: &PgPool, req: &CreateProviderRequest, enc_key: pub async fn update_provider( db: &PgPool, provider_id: &str, req: &UpdateProviderRequest, enc_key: &[u8; 32], ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // Encrypt api_key upfront if provided let encrypted_api_key = match req.api_key { @@ -160,13 +160,13 @@ pub async fn list_models( let (count_sql, data_sql) = if provider_id.is_some() { ( "SELECT COUNT(*) FROM models WHERE provider_id = $1", - "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at::TEXT, updated_at::TEXT FROM models WHERE provider_id = $1 ORDER BY alias LIMIT $2 OFFSET $3", ) } else { ( "SELECT COUNT(*) FROM models", - "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at::TEXT, updated_at::TEXT FROM models ORDER BY provider_id, alias LIMIT $1 OFFSET $2", ) }; @@ -195,7 +195,7 @@ pub async fn create_model(db: &PgPool, req: &CreateModelRequest) -> SaasResult = sqlx::query_as( @@ -240,7 +240,7 @@ pub async fn create_model(db: &PgPool, req: &CreateModelRequest) -> SaasResult SaasResult { let row: Option = sqlx::query_as( - "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at + "SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at::TEXT, updated_at::TEXT FROM models WHERE id = $1" ) .bind(model_id) @@ -255,7 +255,7 @@ pub async fn get_model(db: &PgPool, model_id: &str) -> SaasResult { pub async fn update_model( db: &PgPool, model_id: &str, req: &UpdateModelRequest, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // COALESCE pattern: all updatable fields in a single static SQL. // NULL parameters leave the column unchanged. @@ -309,13 +309,13 @@ pub async fn list_account_api_keys( let (count_sql, data_sql) = if provider_id.is_some() { ( "SELECT COUNT(*) FROM account_api_keys WHERE account_id = $1 AND provider_id = $2 AND revoked_at IS NULL", - "SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value + "SELECT id, provider_id, key_label, permissions, enabled, last_used_at::TEXT, created_at::TEXT, key_value FROM account_api_keys WHERE account_id = $1 AND provider_id = $2 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $3 OFFSET $4", ) } else { ( "SELECT COUNT(*) FROM account_api_keys WHERE account_id = $1 AND revoked_at IS NULL", - "SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value + "SELECT id, provider_id, key_label, permissions, enabled, last_used_at::TEXT, created_at::TEXT, key_value FROM account_api_keys WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) }; @@ -351,7 +351,7 @@ pub async fn create_account_api_key( get_provider(db, &req.provider_id).await?; let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let permissions = serde_json::to_string(&req.permissions)?; // 加密 key_value 后存储 @@ -369,14 +369,14 @@ pub async fn create_account_api_key( Ok(AccountApiKeyInfo { id, provider_id: req.provider_id.clone(), key_label: req.key_label.clone(), permissions: req.permissions.clone(), enabled: true, last_used_at: None, - created_at: now, masked_key: masked, + created_at: now.to_rfc3339(), masked_key: masked, }) } pub async fn rotate_account_api_key( db: &PgPool, key_id: &str, account_id: &str, new_key_value: &str, enc_key: &[u8; 32], ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let encrypted_value = crypto::encrypt_value(new_key_value, enc_key)?; let result = sqlx::query( "UPDATE account_api_keys SET key_value = $1, updated_at = $2 WHERE id = $3 AND account_id = $4 AND revoked_at IS NULL" @@ -393,7 +393,7 @@ pub async fn rotate_account_api_key( pub async fn revoke_account_api_key( db: &PgPool, key_id: &str, account_id: &str, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE account_api_keys SET revoked_at = $1 WHERE id = $2 AND account_id = $3 AND revoked_at IS NULL" ) @@ -448,8 +448,7 @@ pub async fn get_usage_stats( let from_days = (chrono::Utc::now() - chrono::Duration::days(days)) .date_naive() .and_hms_opt(0, 0, 0).unwrap() - .and_utc() - .to_rfc3339(); + .and_utc(); let daily_sql = "SELECT created_at::date::text as day, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens FROM usage_records WHERE account_id = $1 AND created_at >= $2 GROUP BY created_at::date ORDER BY day DESC LIMIT $3"; @@ -480,7 +479,7 @@ pub async fn record_usage( input_tokens: i64, output_tokens: i64, latency_ms: Option, status: &str, error_message: Option<&str>, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO usage_records (account_id, provider_id, model_id, input_tokens, output_tokens, latency_ms, status, error_message, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" @@ -506,7 +505,7 @@ fn mask_api_key(key: &str) -> String { pub async fn list_model_groups(db: &PgPool) -> SaasResult> { let group_rows: Vec<(String, String, String, String, bool, String, String, String)> = sqlx::query_as( "SELECT id, name, display_name, COALESCE(description, ''), enabled, - COALESCE(failover_strategy, 'quota_aware'), created_at, updated_at + COALESCE(failover_strategy, 'quota_aware'), created_at::TEXT, updated_at::TEXT FROM model_groups ORDER BY name" ).fetch_all(db).await?; @@ -535,7 +534,7 @@ pub async fn list_model_groups(db: &PgPool) -> SaasResult> { pub async fn get_model_group(db: &PgPool, group_id: &str) -> SaasResult { let row: Option<(String, String, String, String, bool, String, String, String)> = sqlx::query_as( "SELECT id, name, display_name, COALESCE(description, ''), enabled, - COALESCE(failover_strategy, 'quota_aware'), created_at, updated_at + COALESCE(failover_strategy, 'quota_aware'), created_at::TEXT, updated_at::TEXT FROM model_groups WHERE id = $1" ).bind(group_id).fetch_optional(db).await?; @@ -566,7 +565,7 @@ pub async fn create_model_group(db: &PgPool, req: &CreateModelGroupRequest) -> S } let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); // 检查名称唯一性 let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM model_groups WHERE name = $1") @@ -598,7 +597,7 @@ pub async fn create_model_group(db: &PgPool, req: &CreateModelGroupRequest) -> S pub async fn update_model_group( db: &PgPool, group_id: &str, req: &UpdateModelGroupRequest, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE model_groups SET diff --git a/crates/zclaw-saas/src/prompt/service.rs b/crates/zclaw-saas/src/prompt/service.rs index e8495d7..5ce60d6 100644 --- a/crates/zclaw-saas/src/prompt/service.rs +++ b/crates/zclaw-saas/src/prompt/service.rs @@ -21,7 +21,7 @@ pub async fn create_template( ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); let version_id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let vars_json = variables.unwrap_or(serde_json::json!([])).to_string(); // 插入模板 @@ -53,7 +53,7 @@ pub async fn create_template( pub async fn get_template(db: &PgPool, id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, name, category, description, source, current_version, status, created_at, updated_at + "SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT FROM prompt_templates WHERE id = $1" ).bind(id).fetch_optional(db).await?; @@ -66,7 +66,7 @@ pub async fn get_template(db: &PgPool, id: &str) -> SaasResult SaasResult { let row: Option = sqlx::query_as( - "SELECT id, name, category, description, source, current_version, status, created_at, updated_at + "SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT FROM prompt_templates WHERE name = $1" ).bind(name).fetch_optional(db).await?; @@ -84,7 +84,7 @@ pub async fn list_templates( let (page, page_size, offset) = normalize_pagination(query.page, query.page_size); let count_sql = "SELECT COUNT(*) FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3)"; - let data_sql = "SELECT id, name, category, description, source, current_version, status, created_at, updated_at \ + let data_sql = "SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT \ FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3) ORDER BY updated_at DESC LIMIT $4 OFFSET $5"; let total: i64 = sqlx::query_scalar(count_sql) @@ -115,7 +115,7 @@ pub async fn update_template( description: Option<&str>, status: Option<&str>, ) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); if let Some(desc) = description { sqlx::query("UPDATE prompt_templates SET description = $1, updated_at = $2 WHERE id = $3") @@ -147,7 +147,7 @@ pub async fn create_version( let new_version = tmpl.current_version + 1; let version_id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let vars_json = variables.unwrap_or(serde_json::json!([])).to_string(); sqlx::query( @@ -170,7 +170,7 @@ pub async fn create_version( pub async fn get_version(db: &PgPool, version_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at + "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT FROM prompt_versions WHERE id = $1" ).bind(version_id).fetch_optional(db).await?; @@ -187,7 +187,7 @@ pub async fn get_current_version(db: &PgPool, template_name: &str) -> SaasResult let row: Option = sqlx::query_as( - "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at + "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT FROM prompt_versions WHERE template_id = $1 AND version = $2" ).bind(&tmpl.id).bind(tmpl.current_version).fetch_optional(db).await?; @@ -205,7 +205,7 @@ pub async fn list_versions( ) -> SaasResult> { let rows: Vec = sqlx::query_as( - "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at + "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT FROM prompt_versions WHERE template_id = $1 ORDER BY version DESC" ).bind(template_id).fetch_all(db).await?; @@ -230,7 +230,7 @@ pub async fn rollback_version( return Err(SaasError::NotFound(format!("版本 {} 不存在", target_version))); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query("UPDATE prompt_templates SET current_version = $1, updated_at = $2 WHERE id = $3") .bind(target_version).bind(&now).bind(template_id) .execute(db).await?; @@ -267,7 +267,7 @@ pub async fn check_updates( }); // 更新同步状态 - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO prompt_sync_status (device_id, template_id, synced_version, synced_at) VALUES ($1, $2, $3, $4) @@ -317,7 +317,7 @@ pub async fn get_sync_status( device_id: &str, ) -> SaasResult> { let rows = sqlx::query_as::<_, PromptSyncStatusRow>( - "SELECT device_id, template_id, synced_version, synced_at \ + "SELECT device_id, template_id, synced_version, synced_at::TEXT \ FROM prompt_sync_status \ WHERE device_id = $1 \ ORDER BY synced_at DESC \ diff --git a/crates/zclaw-saas/src/relay/key_pool.rs b/crates/zclaw-saas/src/relay/key_pool.rs index ebd718a..05d1135 100644 --- a/crates/zclaw-saas/src/relay/key_pool.rs +++ b/crates/zclaw-saas/src/relay/key_pool.rs @@ -38,7 +38,7 @@ pub struct KeySelection { /// /// 优化: 单次 JOIN 查询获取 Key + 当前分钟使用量,避免 N+1 查询 pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) -> SaasResult { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let current_minute = chrono::Utc::now().format("%Y-%m-%dT%H:%M").to_string(); // 单次查询: 活跃 Key + 当前分钟的 RPM/TPM 使用量 (LEFT JOIN) @@ -94,14 +94,14 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) if rows.is_empty() { // 检查是否有冷却中的 Key,返回预计等待时间 let cooldown_row: Option<(String,)> = sqlx::query_as( - "SELECT cooldown_until FROM provider_keys + "SELECT cooldown_until::TEXT FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE AND cooldown_until IS NOT NULL AND cooldown_until > $2 ORDER BY cooldown_until ASC LIMIT 1" ).bind(provider_id).bind(&now).fetch_optional(db).await?; if let Some((earliest,)) = cooldown_row { - let wait_secs = parse_cooldown_remaining(&earliest, &now); + let wait_secs = parse_cooldown_remaining(&earliest, &now.to_rfc3339()); return Err(SaasError::RateLimited( format!("所有 Key 均在冷却中,预计 {} 秒后可用", wait_secs) )); @@ -178,13 +178,13 @@ pub async fn mark_key_429( retry_after_seconds: Option, ) -> SaasResult<()> { let cooldown = if let Some(secs) = retry_after_seconds { - (chrono::Utc::now() + chrono::Duration::seconds(secs as i64)).to_rfc3339() + (chrono::Utc::now() + chrono::Duration::seconds(secs as i64)) } else { // 默认 5 分钟冷却 - (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339() + (chrono::Utc::now() + chrono::Duration::minutes(5)) }; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE provider_keys SET last_429_at = $1, cooldown_until = $2, updated_at = $3 @@ -210,7 +210,7 @@ pub async fn list_provider_keys( let rows: Vec = sqlx::query_as( "SELECT id, provider_id, key_label, priority, max_rpm, max_tpm, is_active, - last_429_at, cooldown_until, total_requests, total_tokens, created_at, updated_at + last_429_at::TEXT, cooldown_until::TEXT, total_requests, total_tokens, created_at::TEXT, updated_at::TEXT FROM provider_keys WHERE provider_id = $1 ORDER BY priority ASC" ).bind(provider_id).fetch_all(db).await?; @@ -244,7 +244,7 @@ pub async fn add_provider_key( max_tpm: Option, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO provider_keys (id, provider_id, key_label, key_value, priority, max_rpm, max_tpm, is_active, total_requests, total_tokens, created_at, updated_at) @@ -264,7 +264,7 @@ pub async fn toggle_key_active( key_id: &str, active: bool, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "UPDATE provider_keys SET is_active = $1, updated_at = $2 WHERE id = $3" ).bind(active).bind(&now).bind(key_id).execute(db).await?; diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index ec302e3..9af46ec 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -48,14 +48,14 @@ pub async fn create_relay_task( max_attempts: u32, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let request_hash = hash_request(request_body); let max_attempts = max_attempts.max(1).min(5); let query = sqlx::query_as::<_, RelayTaskRow>( "INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9) - RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at" + RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at::TEXT, started_at::TEXT, completed_at::TEXT, created_at::TEXT" ) .bind(&id).bind(account_id).bind(provider_id).bind(model_id) .bind(&request_hash).bind(request_body).bind(priority).bind(max_attempts as i64).bind(&now); @@ -69,7 +69,7 @@ pub async fn create_relay_task( sqlx::query_as::<_, RelayTaskRow>( "INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9) - RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at" + RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at::TEXT, started_at::TEXT, completed_at::TEXT, created_at::TEXT" ) .bind(&id).bind(account_id).bind(provider_id).bind(model_id) .bind(&request_hash).bind(request_body).bind(priority).bind(max_attempts as i64).bind(&now) @@ -91,7 +91,7 @@ pub async fn create_relay_task( pub async fn get_relay_task(db: &PgPool, task_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at::TEXT, started_at::TEXT, completed_at::TEXT, created_at::TEXT FROM relay_tasks WHERE id = $1" ) .bind(task_id) @@ -117,13 +117,13 @@ pub async fn list_relay_tasks( let (count_sql, data_sql) = if query.status.is_some() { ( "SELECT COUNT(*) FROM relay_tasks WHERE account_id = $1 AND status = $2", - "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at::TEXT, started_at::TEXT, completed_at::TEXT, created_at::TEXT FROM relay_tasks WHERE account_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4" ) } else { ( "SELECT COUNT(*) FROM relay_tasks WHERE account_id = $1", - "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at + "SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at::TEXT, started_at::TEXT, completed_at::TEXT, created_at::TEXT FROM relay_tasks WHERE account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ) }; @@ -154,7 +154,7 @@ pub async fn update_task_status( input_tokens: Option, output_tokens: Option, error_message: Option<&str>, ) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); match status { "processing" => { diff --git a/crates/zclaw-saas/src/role/service.rs b/crates/zclaw-saas/src/role/service.rs index 7453285..c2d6f14 100644 --- a/crates/zclaw-saas/src/role/service.rs +++ b/crates/zclaw-saas/src/role/service.rs @@ -8,7 +8,7 @@ use super::types::*; pub async fn list_roles(db: &PgPool) -> SaasResult> { let rows: Vec = sqlx::query_as( - "SELECT id, name, description, permissions, is_system, created_at, updated_at + "SELECT id, name, description, permissions, is_system, created_at::TEXT, updated_at::TEXT FROM roles ORDER BY CASE id WHEN 'super_admin' THEN 1 @@ -31,7 +31,7 @@ pub async fn list_roles(db: &PgPool) -> SaasResult> { pub async fn get_role(db: &PgPool, role_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, name, description, permissions, is_system, created_at, updated_at + "SELECT id, name, description, permissions, is_system, created_at::TEXT, updated_at::TEXT FROM roles WHERE id = $1" ) .bind(role_id) @@ -56,7 +56,7 @@ pub async fn create_role(db: &PgPool, req: &CreateRoleRequest) -> SaasResult SaasResult return Err(SaasError::Forbidden("系统角色不可修改".into())); } - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let name = req.name.as_ref().unwrap_or(&existing.name); let description = req.description.as_ref().or(existing.description.as_ref()); let permissions = req.permissions.as_ref().unwrap_or(&existing.permissions); @@ -113,7 +113,7 @@ pub async fn update_role(db: &PgPool, role_id: &str, req: &UpdateRoleRequest) -> permissions: permissions.clone(), is_system: false, created_at: existing.created_at, - updated_at: now, + updated_at: now.to_rfc3339(), }) } @@ -139,7 +139,7 @@ pub async fn delete_role(db: &PgPool, role_id: &str) -> SaasResult<()> { pub async fn list_templates(db: &PgPool) -> SaasResult> { let rows: Vec = sqlx::query_as( - "SELECT id, name, description, permissions, created_at, updated_at + "SELECT id, name, description, permissions, created_at::TEXT, updated_at::TEXT FROM permission_templates ORDER BY created_at DESC" ) .fetch_all(db) @@ -156,7 +156,7 @@ pub async fn list_templates(db: &PgPool) -> SaasResult> pub async fn get_template(db: &PgPool, template_id: &str) -> SaasResult { let row: Option = sqlx::query_as( - "SELECT id, name, description, permissions, created_at, updated_at + "SELECT id, name, description, permissions, created_at::TEXT, updated_at::TEXT FROM permission_templates WHERE id = $1" ) .bind(template_id) @@ -171,7 +171,7 @@ pub async fn get_template(db: &PgPool, template_id: &str) -> SaasResult SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let permissions = serde_json::to_string(&req.permissions)?; sqlx::query( @@ -191,8 +191,8 @@ pub async fn create_template(db: &PgPool, req: &CreateTemplateRequest) -> SaasRe name: req.name.clone(), description: req.description.clone(), permissions: req.permissions.clone(), - created_at: now.clone(), - updated_at: now, + created_at: now.to_rfc3339(), + updated_at: now.to_rfc3339(), }) } @@ -215,7 +215,7 @@ pub async fn apply_template_to_accounts( account_ids: &[String], ) -> SaasResult { let template = get_template(db, template_id).await?; - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let mut success_count = 0; for account_id in account_ids { diff --git a/crates/zclaw-saas/src/scheduled_task/service.rs b/crates/zclaw-saas/src/scheduled_task/service.rs index 501c6aa..b37aaf2 100644 --- a/crates/zclaw-saas/src/scheduled_task/service.rs +++ b/crates/zclaw-saas/src/scheduled_task/service.rs @@ -58,12 +58,12 @@ pub async fn create_task( req: &CreateScheduledTaskRequest, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let input_json = req.input.as_ref().map(|v| v.to_string()); + let now = chrono::Utc::now(); + let input_json: Option = req.input.as_ref().map(|v| v.to_string()); sqlx::query( "INSERT INTO scheduled_tasks (id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, input_payload, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)" + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $11)" ) .bind(&id) .bind(account_id) @@ -93,7 +93,7 @@ pub async fn create_task( last_result: None, last_error: None, last_duration_ms: None, - created_at: now, + created_at: now.to_rfc3339(), }) } @@ -105,7 +105,7 @@ pub async fn list_tasks( let rows: Vec = sqlx::query_as( "SELECT id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, last_run_at, next_run_at, - run_count, last_result, last_error, last_duration_ms, input_payload, created_at + run_count, last_result, last_error, last_duration_ms, input_payload, created_at::TEXT FROM scheduled_tasks WHERE account_id = $1 ORDER BY created_at DESC" ) .bind(account_id) @@ -124,7 +124,7 @@ pub async fn get_task( let row: Option = sqlx::query_as( "SELECT id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, last_run_at, next_run_at, - run_count, last_result, last_error, last_duration_ms, input_payload, created_at + run_count, last_result, last_error, last_duration_ms, input_payload, created_at::TEXT FROM scheduled_tasks WHERE id = $1 AND account_id = $2" ) .bind(task_id) @@ -151,7 +151,7 @@ pub async fn update_task( let schedule_type = req.schedule_type.as_deref().unwrap_or(&existing.schedule_type); let enabled = req.enabled.unwrap_or(existing.enabled); let description = req.description.as_deref().or(existing.description.as_deref()); - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let (target_type, target_id) = if let Some(ref target) = req.target { (target.target_type.as_str(), target.id.as_str()) diff --git a/crates/zclaw-saas/src/scheduler.rs b/crates/zclaw-saas/src/scheduler.rs index eeb0a0c..b9328bf 100644 --- a/crates/zclaw-saas/src/scheduler.rs +++ b/crates/zclaw-saas/src/scheduler.rs @@ -92,7 +92,7 @@ pub fn start_db_cleanup_tasks(db: PgPool) { "DELETE FROM devices WHERE last_seen_at < $1" ) .bind({ - let cutoff = (chrono::Utc::now() - chrono::Duration::days(90)).to_rfc3339(); + let cutoff = (chrono::Utc::now() - chrono::Duration::days(90)); cutoff }) .execute(&db_devices) diff --git a/crates/zclaw-saas/src/tasks/mod.rs b/crates/zclaw-saas/src/tasks/mod.rs index bc26dba..9ebc529 100644 --- a/crates/zclaw-saas/src/tasks/mod.rs +++ b/crates/zclaw-saas/src/tasks/mod.rs @@ -77,7 +77,7 @@ impl Task for CleanupDevicesTask { .and_then(|v| v.parse().ok()) .unwrap_or(90); - let cutoff = (chrono::Utc::now() - chrono::Duration::days(cutoff_days)).to_rfc3339(); + let cutoff = (chrono::Utc::now() - chrono::Duration::days(cutoff_days)); let result = sqlx::query("DELETE FROM devices WHERE last_seen_at < $1") .bind(&cutoff) .execute(db) diff --git a/crates/zclaw-saas/src/telemetry/service.rs b/crates/zclaw-saas/src/telemetry/service.rs index 3f5c25b..7bac6ea 100644 --- a/crates/zclaw-saas/src/telemetry/service.rs +++ b/crates/zclaw-saas/src/telemetry/service.rs @@ -16,7 +16,7 @@ pub async fn ingest_telemetry( entries: &[TelemetryEntry], ) -> SaasResult { // 预验证所有条目,分离有效/无效 - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let mut rejected = 0usize; let valid: Vec<&TelemetryEntry> = entries.iter().filter(|e| { if e.input_tokens < 0 || e.output_tokens < 0 || e.model_id.is_empty() { @@ -237,8 +237,7 @@ pub async fn get_daily_stats( let from_ts = (chrono::Utc::now() - chrono::Duration::days(days)) .date_naive() .and_hms_opt(0, 0, 0).unwrap() - .and_utc() - .to_rfc3339(); + .and_utc(); let sql = "SELECT reported_at::date::text as day, diff --git a/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs b/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs index 42d15f8..1525f72 100644 --- a/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs +++ b/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs @@ -20,7 +20,7 @@ impl Worker for CleanupRefreshTokensWorker { } async fn perform(&self, db: &PgPool, _args: Self::Args) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); let result = sqlx::query( "DELETE FROM refresh_tokens WHERE expires_at < $1 OR used_at IS NOT NULL" ) diff --git a/crates/zclaw-saas/src/workers/log_operation.rs b/crates/zclaw-saas/src/workers/log_operation.rs index 9f278cd..fa91fc7 100644 --- a/crates/zclaw-saas/src/workers/log_operation.rs +++ b/crates/zclaw-saas/src/workers/log_operation.rs @@ -27,7 +27,7 @@ impl Worker for LogOperationWorker { } async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)" diff --git a/crates/zclaw-saas/src/workers/record_usage.rs b/crates/zclaw-saas/src/workers/record_usage.rs index 80afd4d..1f4c280 100644 --- a/crates/zclaw-saas/src/workers/record_usage.rs +++ b/crates/zclaw-saas/src/workers/record_usage.rs @@ -29,7 +29,7 @@ impl Worker for RecordUsageWorker { } async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query( "INSERT INTO usage_records (account_id, provider_id, model_id, input_tokens, output_tokens, latency_ms, status, error_message, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" diff --git a/crates/zclaw-saas/src/workers/update_last_used.rs b/crates/zclaw-saas/src/workers/update_last_used.rs index 292223e..ae870d5 100644 --- a/crates/zclaw-saas/src/workers/update_last_used.rs +++ b/crates/zclaw-saas/src/workers/update_last_used.rs @@ -23,7 +23,7 @@ impl Worker for UpdateLastUsedWorker { } async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> { - let now = chrono::Utc::now().to_rfc3339(); + let now = chrono::Utc::now(); sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE token_hash = $2") .bind(&now) .bind(&args.token_hash) diff --git a/crates/zclaw-saas/tests/billing_test.rs b/crates/zclaw-saas/tests/billing_test.rs new file mode 100644 index 0000000..624e613 --- /dev/null +++ b/crates/zclaw-saas/tests/billing_test.rs @@ -0,0 +1,344 @@ +//! 计费模块集成测试 +//! +//! 覆盖 billing 模块的 plan/subscription/usage/payment/invoice 端点。 + +mod common; +use common::*; +use axum::http::StatusCode; + +// ── Plans(公开路由,不强制 auth) ────────────────────────────── + +#[tokio::test] +async fn test_list_plans() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_plan_user").await; + + let (status, body) = send(&app, get("/api/v1/billing/plans", &token)).await; + assert_eq!(status, StatusCode::OK, "list_plans failed: {body}"); + + let arr = body.as_array().expect("plans should be array"); + assert!(arr.len() >= 3, "expected >= 3 seed plans, got {}", arr.len()); + + let names: Vec<&str> = arr.iter().filter_map(|p| p["name"].as_str()).collect(); + assert!(names.contains(&"free"), "missing free plan"); + assert!(names.contains(&"pro"), "missing pro plan"); + assert!(names.contains(&"team"), "missing team plan"); +} + +#[tokio::test] +async fn test_get_plan_by_id() { + let (app, pool) = build_test_app().await; + let token = register_token(&app, "billing_plan_get").await; + + // 获取 free plan 的真实 ID + let plan_id: String = sqlx::query_scalar( + "SELECT id FROM billing_plans WHERE name = 'free' LIMIT 1" + ) + .fetch_one(&pool) + .await + .expect("no free plan seeded"); + + let (status, body) = send(&app, get(&format!("/api/v1/billing/plans/{}", plan_id), &token)).await; + assert_eq!(status, StatusCode::OK, "get_plan failed: {body}"); + assert_eq!(body["name"], "free"); + assert_eq!(body["price_cents"], 0); +} + +#[tokio::test] +async fn test_get_plan_not_found() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_plan_404").await; + + let (status, _body) = send(&app, get("/api/v1/billing/plans/nonexistent-id", &token)).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +// ── Subscription / Usage(需认证) ───────────────────────────── + +#[tokio::test] +async fn test_get_subscription() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_sub_user").await; + + let (status, body) = send(&app, get("/api/v1/billing/subscription", &token)).await; + assert_eq!(status, StatusCode::OK, "get_subscription failed: {body}"); + + // 新用户应获得 free plan + assert_eq!(body["plan"]["name"], "free"); + // 无活跃订阅 + assert!(body["subscription"].is_null()); + // 用量应为零 + assert_eq!(body["usage"]["input_tokens"], 0); + assert_eq!(body["usage"]["relay_requests"], 0); +} + +#[tokio::test] +async fn test_get_usage() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_usage_user").await; + + let (status, body) = send(&app, get("/api/v1/billing/usage", &token)).await; + assert_eq!(status, StatusCode::OK, "get_usage failed: {body}"); + + // 首次访问自动创建,所有计数为 0 + assert_eq!(body["input_tokens"], 0); + assert_eq!(body["output_tokens"], 0); + assert_eq!(body["relay_requests"], 0); + assert_eq!(body["hand_executions"], 0); + assert_eq!(body["pipeline_runs"], 0); + // max 值来自 free plan limits + assert!(body["max_relay_requests"].is_number()); +} + +#[tokio::test] +async fn test_increment_usage() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_incr_user").await; + + // 递增 hand_executions + let (status, body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "hand_executions", "count": 3 }), + )).await; + assert_eq!(status, StatusCode::OK, "increment hand_executions failed: {body}"); + assert_eq!(body["dimension"], "hand_executions"); + assert_eq!(body["incremented"], 3); + assert_eq!(body["usage"]["hand_executions"], 3); + + // 递增 relay_requests + let (status, body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "relay_requests", "count": 10 }), + )).await; + assert_eq!(status, StatusCode::OK, "increment relay_requests failed: {body}"); + assert_eq!(body["usage"]["relay_requests"], 10); + + // 递增 pipeline_runs + let (status, body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "pipeline_runs", "count": 1 }), + )).await; + assert_eq!(status, StatusCode::OK, "increment pipeline_runs failed: {body}"); + assert_eq!(body["usage"]["pipeline_runs"], 1); +} + +#[tokio::test] +async fn test_increment_usage_invalid_dimension() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_incr_invaliddim").await; + + let (status, body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "invalid_dim", "count": 1 }), + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject invalid dimension: {body}"); +} + +#[tokio::test] +async fn test_increment_usage_invalid_count() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_incr_invalidcount").await; + + // count = 0 + let (status, _body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "hand_executions", "count": 0 }), + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject count=0"); + + // count = 101 + let (status, _body) = send(&app, post( + "/api/v1/billing/usage/increment", + &token, + serde_json::json!({ "dimension": "hand_executions", "count": 101 }), + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject count=101"); +} + +// ── Payments(需认证) ───────────────────────────────────────── + +#[tokio::test] +async fn test_create_payment() { + let (app, pool) = build_test_app().await; + let token = register_token(&app, "billing_pay_user").await; + + // 获取 pro plan ID + let plan_id: String = sqlx::query_scalar( + "SELECT id FROM billing_plans WHERE name = 'pro' LIMIT 1" + ) + .fetch_one(&pool) + .await + .expect("no pro plan seeded"); + + let (status, body) = send(&app, post( + "/api/v1/billing/payments", + &token, + serde_json::json!({ "plan_id": plan_id, "payment_method": "alipay" }), + )).await; + assert_eq!(status, StatusCode::OK, "create_payment failed: {body}"); + + // 应返回支付信息 + assert!(body["payment_id"].is_string(), "missing payment_id"); + assert!(body["trade_no"].is_string(), "missing trade_no"); + assert!(body["pay_url"].is_string(), "missing pay_url"); + assert!(body["amount_cents"].is_number(), "missing amount_cents"); +} + +#[tokio::test] +async fn test_create_payment_invalid_plan() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "billing_pay_invalidplan").await; + + let (status, body) = send(&app, post( + "/api/v1/billing/payments", + &token, + serde_json::json!({ "plan_id": "nonexistent-plan", "payment_method": "alipay" }), + )).await; + assert_eq!(status, StatusCode::NOT_FOUND, "should 404 for invalid plan: {body}"); +} + +#[tokio::test] +async fn test_get_payment_status() { + let (app, pool) = build_test_app().await; + let token = register_token(&app, "billing_paystatus_user").await; + + // 先创建支付 + let plan_id: String = sqlx::query_scalar( + "SELECT id FROM billing_plans WHERE name = 'pro' LIMIT 1" + ) + .fetch_one(&pool) + .await + .expect("no pro plan"); + + let (_, create_body) = send(&app, post( + "/api/v1/billing/payments", + &token, + serde_json::json!({ "plan_id": plan_id, "payment_method": "alipay" }), + )).await; + + let payment_id = create_body["payment_id"].as_str().expect("missing payment_id"); + + // 查询支付状态 + let (status, body) = send(&app, get( + &format!("/api/v1/billing/payments/{}", payment_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "get_payment_status failed: {body}"); + assert_eq!(body["status"], "pending"); +} + +#[tokio::test] +async fn test_mock_pay_flow() { + let (app, pool) = build_test_app().await; + let token = register_token(&app, "billing_mockpay_user").await; + + let plan_id: String = sqlx::query_scalar( + "SELECT id FROM billing_plans WHERE name = 'pro' LIMIT 1" + ) + .fetch_one(&pool) + .await + .expect("no pro plan"); + + // 1. 创建支付 + let (_, create_body) = send(&app, post( + "/api/v1/billing/payments", + &token, + serde_json::json!({ "plan_id": plan_id, "payment_method": "alipay" }), + )).await; + + let trade_no = create_body["trade_no"].as_str().expect("missing trade_no"); + let amount = create_body["amount_cents"].as_i64().expect("missing amount_cents") as i32; + + // 2. Mock 支付确认(返回 HTML,不能用 JSON 解析) + let csrf_token = generate_test_csrf_token(trade_no); + let form_body = format!( + "trade_no={}&action=success&csrf_token={}", + urlencoding::encode(trade_no), + urlencoding::encode(&csrf_token), + ); + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/v1/billing/mock-pay/confirm") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(axum::body::Body::from(form_body)) + .unwrap(); + + let (status, body) = send_raw(&app, req).await; + assert!(status == StatusCode::OK, "mock pay confirm should succeed: status={}, body={}", status, body); + assert!(body.contains("支付成功"), "expected success message in HTML: {}", body); +} + +#[tokio::test] +async fn test_invoice_pdf_requires_paid() { + let (app, pool) = build_test_app().await; + let token = register_token(&app, "billing_invoice_user").await; + + let plan_id: String = sqlx::query_scalar( + "SELECT id FROM billing_plans WHERE name = 'pro' LIMIT 1" + ) + .fetch_one(&pool) + .await + .expect("no pro plan"); + + // 创建支付 → 产生 pending 发票 + let (_, create_body) = send(&app, post( + "/api/v1/billing/payments", + &token, + serde_json::json!({ "plan_id": plan_id, "payment_method": "alipay" }), + )).await; + + let payment_id = create_body["payment_id"].as_str().expect("missing payment_id"); + + // 查找关联发票 + let invoice_id: Option = sqlx::query_scalar( + "SELECT invoice_id FROM billing_payments WHERE id = $1" + ) + .bind(payment_id) + .fetch_optional(&pool) + .await + .expect("db error") + .flatten(); + + if let Some(inv_id) = invoice_id { + // 发票未支付,应返回 400 + let (status, _body) = send(&app, get( + &format!("/api/v1/billing/invoices/{}/pdf", inv_id), + &token, + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "unpaid invoice should reject PDF download"); + } +} + +#[tokio::test] +async fn test_payment_callback() { + let (app, _pool) = build_test_app().await; + + // 模拟支付宝回调(开发模式,不验签) + let callback_body = "out_trade_no=ZCLAW-INVALID-TEST&trade_status=TRADE_SUCCESS&total_amount=0.01"; + let req = axum::http::Request::builder() + .method("POST") + .uri("/api/v1/billing/callback/alipay") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(axum::body::Body::from(callback_body)) + .unwrap(); + + let (status, body) = send_raw(&app, req).await; + // 回调返回纯文本 "success" 或 "fail",不是 JSON + assert!( + status == StatusCode::OK || status == StatusCode::BAD_REQUEST, + "callback should be processed or rejected gracefully: status={}, body={}", status, body + ); +} + +/// 生成测试用 CSRF token(复制 handlers.rs 中的逻辑) +fn generate_test_csrf_token(trade_no: &str) -> String { + use sha2::{Sha256, Digest}; + let message = format!("ZCLAW_MOCK:{}:", trade_no); + let hash = Sha256::digest(message.as_bytes()); + hex::encode(hash) +} diff --git a/crates/zclaw-saas/tests/common/mod.rs b/crates/zclaw-saas/tests/common/mod.rs index d419f7d..7c3eb5f 100644 --- a/crates/zclaw-saas/tests/common/mod.rs +++ b/crates/zclaw-saas/tests/common/mod.rs @@ -149,7 +149,10 @@ fn build_router(state: AppState) -> Router { use tower_http::trace::TraceLayer; let public_routes = zclaw_saas::auth::routes() - .route("/api/health", axum::routing::get(health_handler)); + .route("/api/health", axum::routing::get(health_handler)) + .merge(zclaw_saas::billing::callback_routes()) + .merge(zclaw_saas::billing::mock_routes()) + .merge(zclaw_saas::billing::plan_routes()); let protected_routes = zclaw_saas::auth::protected_routes() .merge(zclaw_saas::account::routes()) @@ -160,6 +163,9 @@ fn build_router(state: AppState) -> Router { .merge(zclaw_saas::prompt::routes()) .merge(zclaw_saas::agent_template::routes()) .merge(zclaw_saas::telemetry::routes()) + .merge(zclaw_saas::billing::protected_routes()) + .merge(zclaw_saas::knowledge::routes()) + .merge(zclaw_saas::scheduled_task::routes()) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::middleware::api_version_middleware, @@ -313,6 +319,14 @@ pub async fn send(app: &Router, req: Request) -> (StatusCode, serde_json:: (status, json) } +/// Send request and return (status, body_string). For non-JSON responses (HTML, plain text). +pub async fn send_raw(app: &Router, req: Request) -> (StatusCode, String) { + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = body_bytes(resp.into_body()).await; + (status, String::from_utf8_lossy(&bytes).to_string()) +} + // ── Auth helpers ───────────────────────────────────────────────── /// Register a new user. Returns (access_token, refresh_token, response_json). @@ -332,7 +346,7 @@ pub async fn register( .unwrap(); let status = resp.status(); let json = body_json(resp.into_body()).await; - assert_eq!(status, StatusCode::CREATED, "register failed: {json}"); + assert_eq!(status, StatusCode::OK, "register failed: {json}"); let token = json["token"].as_str().unwrap().to_string(); let refresh = json["refresh_token"].as_str().unwrap().to_string(); (token, refresh, json) diff --git a/crates/zclaw-saas/tests/knowledge_test.rs b/crates/zclaw-saas/tests/knowledge_test.rs new file mode 100644 index 0000000..3281ef5 --- /dev/null +++ b/crates/zclaw-saas/tests/knowledge_test.rs @@ -0,0 +1,433 @@ +//! 知识库模块集成测试 +//! +//! 覆盖 knowledge 模块的分类/条目/版本/检索/分析端点。 +//! 需要 super_admin 权限(knowledge:read/write/admin/search)。 + +mod common; +use common::*; +use axum::http::StatusCode; +use axum::Router; +use sqlx::PgPool; + +/// 辅助:创建 super_admin token(知识库需要 knowledge:* 权限,仅 super_admin 有 admin:full) +async fn setup_admin(app: &Router, pool: &sqlx::PgPool) -> String { + super_admin_token(app, pool, "kb_admin").await +} + +/// 辅助:创建一个分类 +async fn create_category(app: &Router, token: &str, name: &str) -> String { + let (status, body) = send(app, post("/api/v1/knowledge/categories", token, + serde_json::json!({ "name": name, "description": "测试分类" }) + )).await; + assert_eq!(status, StatusCode::OK, "create_category failed: {body}"); + body["id"].as_str().unwrap().to_string() +} + +/// 辅助:创建一个知识条目 +async fn create_item(app: &Router, token: &str, category_id: &str, title: &str) -> String { + let (status, body) = send(app, post("/api/v1/knowledge/items", token, + serde_json::json!({ + "category_id": category_id, + "title": title, + "content": format!("这是 {} 的内容", title), + "keywords": ["测试"], + "tags": ["test"] + }) + )).await; + assert_eq!(status, StatusCode::OK, "create_item failed: {body}"); + body["id"].as_str().unwrap().to_string() +} + +// ── 分类管理 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_create_category() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let (status, body) = send(&app, post("/api/v1/knowledge/categories", &token, + serde_json::json!({ "name": "技术文档", "description": "技术相关文档", "icon": "📚" }) + )).await; + assert_eq!(status, StatusCode::OK, "create_category failed: {body}"); + assert!(body["id"].is_string(), "missing id"); + assert_eq!(body["name"], "技术文档"); +} + +#[tokio::test] +async fn test_create_category_empty_name() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let (status, _) = send(&app, post("/api/v1/knowledge/categories", &token, + serde_json::json!({ "name": " ", "description": "test" }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty name"); +} + +#[tokio::test] +async fn test_list_categories() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + // 创建两个分类 + create_category(&app, &token, "分类A").await; + create_category(&app, &token, "分类B").await; + + let (status, body) = send(&app, get("/api/v1/knowledge/categories", &token)).await; + assert_eq!(status, StatusCode::OK, "list_categories failed: {body}"); + + let arr = body.as_array().expect("should be array"); + assert!(arr.len() >= 2, "expected >= 2 categories, got {}", arr.len()); +} + +#[tokio::test] +async fn test_update_category() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "旧名称").await; + + let (status, body) = send(&app, put( + &format!("/api/v1/knowledge/categories/{}", cat_id), + &token, + serde_json::json!({ "name": "新名称", "description": "更新后" }), + )).await; + assert_eq!(status, StatusCode::OK, "update_category failed: {body}"); + assert_eq!(body["name"], "新名称"); + assert_eq!(body["updated"], true); +} + +#[tokio::test] +async fn test_delete_category() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "待删除分类").await; + + let (status, body) = send(&app, delete( + &format!("/api/v1/knowledge/categories/{}", cat_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "delete_category failed: {body}"); + assert_eq!(body["deleted"], true); +} + +#[tokio::test] +async fn test_reorder_categories() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_a = create_category(&app, &token, "分类A").await; + let cat_b = create_category(&app, &token, "分类B").await; + + let (status, body) = send(&app, patch( + "/api/v1/knowledge/categories/reorder", + &token, + serde_json::json!([ + { "id": cat_a, "sort_order": 2 }, + { "id": cat_b, "sort_order": 1 } + ]), + )).await; + assert_eq!(status, StatusCode::OK, "reorder failed: {body}"); + assert_eq!(body["reordered"], true); + assert_eq!(body["count"], 2); +} + +// ── 知识条目 CRUD ────────────────────────────────────────────── + +#[tokio::test] +async fn test_create_item() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "条目测试分类").await; + let item_id = create_item(&app, &token, &cat_id, "测试条目").await; + + assert!(!item_id.is_empty(), "item id should not be empty"); +} + +#[tokio::test] +async fn test_create_item_validation() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "验证分类").await; + + // 空标题 + let (status, _) = send(&app, post("/api/v1/knowledge/items", &token, + serde_json::json!({ + "category_id": cat_id, + "title": " ", + "content": "有内容" + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty title"); + + // 空内容 + let (status, _) = send(&app, post("/api/v1/knowledge/items", &token, + serde_json::json!({ + "category_id": cat_id, + "title": "有标题", + "content": "" + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty content"); +} + +#[tokio::test] +async fn test_list_items() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "列表分类").await; + create_item(&app, &token, &cat_id, "条目1").await; + create_item(&app, &token, &cat_id, "条目2").await; + + let (status, body) = send(&app, get( + &format!("/api/v1/knowledge/items?category_id={}&page=1&page_size=10", cat_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "list_items failed: {body}"); + + let items = body["items"].as_array().expect("items should be array"); + assert!(items.len() >= 2, "expected >= 2 items"); + assert!(body["total"].is_number(), "missing total"); +} + +#[tokio::test] +async fn test_get_item() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "获取分类").await; + let item_id = create_item(&app, &token, &cat_id, "获取测试条目").await; + + let (status, body) = send(&app, get( + &format!("/api/v1/knowledge/items/{}", item_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "get_item failed: {body}"); + assert_eq!(body["id"], item_id); + assert_eq!(body["title"], "获取测试条目"); + assert!(body["content"].is_string()); +} + +#[tokio::test] +async fn test_update_item() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "更新分类").await; + let item_id = create_item(&app, &token, &cat_id, "原始标题").await; + + let (status, body) = send(&app, put( + &format!("/api/v1/knowledge/items/{}", item_id), + &token, + serde_json::json!({ + "title": "更新后标题", + "content": "更新后的内容", + "change_summary": "修改标题和内容" + }), + )).await; + assert_eq!(status, StatusCode::OK, "update_item failed: {body}"); + assert_eq!(body["id"], item_id); + // 更新后 version 应该增加 + assert!(body["version"].as_i64().unwrap() >= 2, "version should increment after update"); +} + +#[tokio::test] +async fn test_delete_item() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "删除分类").await; + let item_id = create_item(&app, &token, &cat_id, "待删除条目").await; + + let (status, body) = send(&app, delete( + &format!("/api/v1/knowledge/items/{}", item_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "delete_item failed: {body}"); + assert_eq!(body["deleted"], true); +} + +// ── 批量操作 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_batch_create_items() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "批量分类").await; + + let items: Vec = (1..=3).map(|i| { + serde_json::json!({ + "category_id": cat_id, + "title": format!("批量条目{}", i), + "content": format!("批量内容{}", i), + "keywords": ["batch"] + }) + }).collect(); + + let (status, body) = send(&app, post( + "/api/v1/knowledge/items/batch", + &token, + serde_json::json!(items), + )).await; + assert_eq!(status, StatusCode::OK, "batch_create failed: {body}"); + assert_eq!(body["created_count"], 3); + assert!(body["ids"].as_array().unwrap().len() == 3); +} + +#[tokio::test] +async fn test_import_items() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "导入分类").await; + + let (status, body) = send(&app, post( + "/api/v1/knowledge/items/import", + &token, + serde_json::json!({ + "category_id": cat_id, + "files": [ + { + "content": "# 导入文档1\n这是第一个文档的内容", + "keywords": ["import"], + "tags": ["docs"] + }, + { + "title": "自定义标题", + "content": "第二个文档的内容", + } + ] + }), + )).await; + assert_eq!(status, StatusCode::OK, "import failed: {body}"); + assert_eq!(body["created_count"], 2); +} + +// ── 版本控制 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_list_versions() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "版本分类").await; + let item_id = create_item(&app, &token, &cat_id, "版本测试").await; + + // 更新一次产生 v2 + let _ = send(&app, put( + &format!("/api/v1/knowledge/items/{}", item_id), + &token, + serde_json::json!({ "content": "v2 content", "change_summary": "第二次修改" }), + )).await; + + let (status, body) = send(&app, get( + &format!("/api/v1/knowledge/items/{}/versions", item_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "list_versions failed: {body}"); + + let versions = body["versions"].as_array().expect("versions should be array"); + assert!(versions.len() >= 2, "expected >= 2 versions, got {}", versions.len()); +} + +#[tokio::test] +async fn test_rollback_version() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "回滚分类").await; + let item_id = create_item(&app, &token, &cat_id, "回滚测试").await; + + // 更新一次产生 v2 + let _ = send(&app, put( + &format!("/api/v1/knowledge/items/{}", item_id), + &token, + serde_json::json!({ "content": "v2 content" }), + )).await; + + // 回滚到 v1 + let (status, body) = send(&app, post( + &format!("/api/v1/knowledge/items/{}/rollback/1", item_id), + &token, + serde_json::json!({}), + )).await; + assert_eq!(status, StatusCode::OK, "rollback failed: {body}"); + assert_eq!(body["rolled_back_to"], 1); +} + +// ── 检索 ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_search() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + // 搜索应返回空结果(无 embedding),但不应报错 + let (status, body) = send(&app, post( + "/api/v1/knowledge/search", + &token, + serde_json::json!({ "query": "测试搜索", "limit": 5 }), + )).await; + // 搜索可能返回 200(空结果)或 500(pgvector 不可用) + // 不强制要求 200,只要不是 panic + assert!( + status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR, + "search should not panic: status={}, body={}", status, body + ); +} + +// ── 分析看板 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_analytics_overview() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let cat_id = create_category(&app, &token, "分析分类").await; + create_item(&app, &token, &cat_id, "分析条目").await; + + let (status, body) = send(&app, get( + "/api/v1/knowledge/analytics/overview", + &token, + )).await; + assert_eq!(status, StatusCode::OK, "analytics_overview failed: {body}"); + + assert!(body["total_items"].is_number()); + assert!(body["active_items"].is_number()); + assert!(body["total_categories"].is_number()); +} + +#[tokio::test] +async fn test_analytics_trends() { + let (app, pool) = build_test_app().await; + let token = setup_admin(&app, &pool).await; + + let (status, body) = send(&app, get( + "/api/v1/knowledge/analytics/trends", + &token, + )).await; + assert_eq!(status, StatusCode::OK, "analytics_trends failed: {body}"); + assert!(body["trends"].is_array()); +} + +// ── 权限验证 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_permission_read_only_user() { + let (app, _pool) = build_test_app().await; + // 普通用户没有 knowledge:read 权限 + let token = register_token(&app, "kb_noperm_user").await; + + let (status, _) = send(&app, get("/api/v1/knowledge/categories", &token)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "普通用户不应访问知识库"); + + let (status, _) = send(&app, post("/api/v1/knowledge/categories", &token, + serde_json::json!({ "name": "不应成功" }) + )).await; + assert_eq!(status, StatusCode::FORBIDDEN, "普通用户不应创建分类"); +} diff --git a/crates/zclaw-saas/tests/scheduled_task_test.rs b/crates/zclaw-saas/tests/scheduled_task_test.rs new file mode 100644 index 0000000..28205f6 --- /dev/null +++ b/crates/zclaw-saas/tests/scheduled_task_test.rs @@ -0,0 +1,319 @@ +//! 定时任务模块集成测试 +//! +//! 覆盖 scheduled_task 模块的 CRUD 端点(5 端点)。 + +mod common; +use common::*; +use axum::http::StatusCode; + +/// 创建 cron 类型任务的请求体 +fn cron_task_body(name: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "schedule": "0 8 * * *", + "schedule_type": "cron", + "target": { + "type": "agent", + "id": "test-agent-1" + }, + "description": "测试定时任务", + "enabled": true + }) +} + +/// 创建 interval 类型任务的请求体 +fn interval_task_body(name: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "schedule": "30m", + "schedule_type": "interval", + "target": { + "type": "hand", + "id": "collector" + } + }) +} + +// ── 创建任务 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_create_cron_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_cron_user").await; + + let (status, body) = send(&app, post( + "/api/v1/scheduler/tasks", + &token, + cron_task_body("每日早报"), + )).await; + assert_eq!(status, StatusCode::CREATED, "create cron task failed: {body}"); + + assert!(body["id"].is_string(), "missing id"); + assert_eq!(body["name"], "每日早报"); + assert_eq!(body["schedule"], "0 8 * * *"); + assert_eq!(body["schedule_type"], "cron"); + assert_eq!(body["target"]["type"], "agent"); + assert_eq!(body["target"]["id"], "test-agent-1"); + assert_eq!(body["enabled"], true); + assert!(body["created_at"].is_string()); + assert_eq!(body["run_count"], 0); +} + +#[tokio::test] +async fn test_create_interval_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_interval_user").await; + + let (status, body) = send(&app, post( + "/api/v1/scheduler/tasks", + &token, + interval_task_body("定时采集"), + )).await; + assert_eq!(status, StatusCode::CREATED, "create interval task failed: {body}"); + + assert_eq!(body["schedule_type"], "interval"); + assert_eq!(body["schedule"], "30m"); + assert_eq!(body["target"]["type"], "hand"); +} + +#[tokio::test] +async fn test_create_once_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_once_user").await; + + let body = serde_json::json!({ + "name": "一次性任务", + "schedule": "2026-12-31T00:00:00Z", + "schedule_type": "once", + "target": { + "type": "workflow", + "id": "wf-1" + } + }); + + let (status, resp) = send(&app, post("/api/v1/scheduler/tasks", &token, body)).await; + assert_eq!(status, StatusCode::CREATED, "create once task failed: {resp}"); + assert_eq!(resp["schedule_type"], "once"); + assert_eq!(resp["target"]["type"], "workflow"); +} + +#[tokio::test] +async fn test_create_task_validation() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_valid_user").await; + + // 空名称 + let (status, _) = send(&app, post("/api/v1/scheduler/tasks", &token, + serde_json::json!({ + "name": "", + "schedule": "0 * * * *", + "schedule_type": "cron", + "target": { "type": "agent", "id": "a1" } + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty name"); + + // 空 schedule + let (status, _) = send(&app, post("/api/v1/scheduler/tasks", &token, + serde_json::json!({ + "name": "valid", + "schedule": "", + "schedule_type": "cron", + "target": { "type": "agent", "id": "a1" } + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject empty schedule"); + + // 无效 schedule_type + let (status, _) = send(&app, post("/api/v1/scheduler/tasks", &token, + serde_json::json!({ + "name": "valid", + "schedule": "0 * * * *", + "schedule_type": "invalid", + "target": { "type": "agent", "id": "a1" } + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject invalid schedule_type"); + + // 无效 target_type + let (status, _) = send(&app, post("/api/v1/scheduler/tasks", &token, + serde_json::json!({ + "name": "valid", + "schedule": "0 * * * *", + "schedule_type": "cron", + "target": { "type": "invalid_type", "id": "a1" } + }) + )).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "should reject invalid target_type"); +} + +// ── 列出任务 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_list_tasks() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_list_user").await; + + // 创建 2 个任务 + let _ = send(&app, post("/api/v1/scheduler/tasks", &token, + cron_task_body("任务A") + )).await; + let _ = send(&app, post("/api/v1/scheduler/tasks", &token, + interval_task_body("任务B") + )).await; + + let (status, body) = send(&app, get("/api/v1/scheduler/tasks", &token)).await; + assert_eq!(status, StatusCode::OK, "list_tasks failed: {body}"); + + let arr = body.as_array().expect("should be array"); + assert_eq!(arr.len(), 2, "expected 2 tasks, got {}", arr.len()); +} + +#[tokio::test] +async fn test_list_tasks_isolation() { + let (app, _pool) = build_test_app().await; + let token_a = register_token(&app, "sched_iso_user_a").await; + let token_b = register_token(&app, "sched_iso_user_b").await; + + // 用户 A 创建任务 + let _ = send(&app, post("/api/v1/scheduler/tasks", &token_a, + cron_task_body("A的任务") + )).await; + + // 用户 B 创建任务 + let _ = send(&app, post("/api/v1/scheduler/tasks", &token_b, + cron_task_body("B的任务") + )).await; + + // 用户 A 只能看到自己的任务 + let (_, body_a) = send(&app, get("/api/v1/scheduler/tasks", &token_a)).await; + let arr_a = body_a.as_array().unwrap(); + assert_eq!(arr_a.len(), 1, "user A should see 1 task"); + assert_eq!(arr_a[0]["name"], "A的任务"); + + // 用户 B 只能看到自己的任务 + let (_, body_b) = send(&app, get("/api/v1/scheduler/tasks", &token_b)).await; + let arr_b = body_b.as_array().unwrap(); + assert_eq!(arr_b.len(), 1, "user B should see 1 task"); + assert_eq!(arr_b[0]["name"], "B的任务"); +} + +// ── 获取单个任务 ─────────────────────────────────────────────── + +#[tokio::test] +async fn test_get_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_get_user").await; + + let (_, create_body) = send(&app, post("/api/v1/scheduler/tasks", &token, + cron_task_body("获取测试") + )).await; + let task_id = create_body["id"].as_str().unwrap(); + + let (status, body) = send(&app, get( + &format!("/api/v1/scheduler/tasks/{}", task_id), + &token, + )).await; + assert_eq!(status, StatusCode::OK, "get_task failed: {body}"); + assert_eq!(body["id"], task_id); + assert_eq!(body["name"], "获取测试"); +} + +#[tokio::test] +async fn test_get_task_not_found() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_404_user").await; + + let (status, _) = send(&app, get( + "/api/v1/scheduler/tasks/nonexistent-id", + &token, + )).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_task_wrong_account() { + let (app, _pool) = build_test_app().await; + let token_a = register_token(&app, "sched_wa_user_a").await; + let token_b = register_token(&app, "sched_wa_user_b").await; + + // 用户 A 创建任务 + let (_, create_body) = send(&app, post("/api/v1/scheduler/tasks", &token_a, + cron_task_body("A私有任务") + )).await; + let task_id = create_body["id"].as_str().unwrap(); + + // 用户 B 不应看到用户 A 的任务 + let (status, _) = send(&app, get( + &format!("/api/v1/scheduler/tasks/{}", task_id), + &token_b, + )).await; + assert_eq!(status, StatusCode::NOT_FOUND, "should not see other user's task"); +} + +// ── 更新任务 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_update_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_update_user").await; + + let (_, create_body) = send(&app, post("/api/v1/scheduler/tasks", &token, + cron_task_body("原始名称") + )).await; + let task_id = create_body["id"].as_str().unwrap(); + + let (status, body) = send(&app, patch( + &format!("/api/v1/scheduler/tasks/{}", task_id), + &token, + serde_json::json!({ + "name": "更新后名称", + "enabled": false, + "description": "已禁用" + }), + )).await; + assert_eq!(status, StatusCode::OK, "update_task failed: {body}"); + assert_eq!(body["name"], "更新后名称"); + assert_eq!(body["enabled"], false); + assert_eq!(body["description"], "已禁用"); + // 未更新的字段应保持不变 + assert_eq!(body["schedule"], "0 8 * * *"); +} + +// ── 删除任务 ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_delete_task() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_del_user").await; + + let (_, create_body) = send(&app, post("/api/v1/scheduler/tasks", &token, + cron_task_body("待删除任务") + )).await; + let task_id = create_body["id"].as_str().unwrap(); + + let (status, _) = send(&app, delete( + &format!("/api/v1/scheduler/tasks/{}", task_id), + &token, + )).await; + assert_eq!(status, StatusCode::NO_CONTENT, "delete should return 204"); + + // 确认已删除 + let (status, _) = send(&app, get( + &format!("/api/v1/scheduler/tasks/{}", task_id), + &token, + )).await; + assert_eq!(status, StatusCode::NOT_FOUND, "deleted task should be 404"); +} + +#[tokio::test] +async fn test_delete_task_not_found() { + let (app, _pool) = build_test_app().await; + let token = register_token(&app, "sched_del404_user").await; + + let (status, _) = send(&app, delete( + "/api/v1/scheduler/tasks/nonexistent-id", + &token, + )).await; + assert_eq!(status, StatusCode::NOT_FOUND, "delete nonexistent should return 404"); +}