feat(saas): P2 增强 — TOTP 2FA、Relay 重试、配置同步升级
- TOTP 2FA: totp-rs v5.7.1 + data-encoding Base32, setup/verify/disable 流程, 登录时 TOTP 验证集成, SaasError::Totp 返回 400 - Relay 重试: 指数退避 (base_delay_ms * 2^attempt), 错误分类 (4xx 不重试), Admin POST /tasks/:id/retry 端点 - 配置同步: push (客户端覆盖) / merge (SaaS 优先) / diff (只读对比), 实际写入 config_items 表 - 集成测试: 27 个测试全部通过 (新增 6 个 P2 测试) - 文档: 更新 SaaS 平台总览 (模块完成度 + API 端点列表)
This commit is contained in:
@@ -54,18 +54,22 @@ pub async fn chat_completions(
|
||||
let request_body = serde_json::to_string(&req)?;
|
||||
|
||||
// 创建中转任务
|
||||
let config = state.config.read().await;
|
||||
let task = service::create_relay_task(
|
||||
&state.db, &ctx.account_id, &target_model.provider_id,
|
||||
&target_model.model_id, &request_body, 0,
|
||||
config.relay.max_attempts,
|
||||
).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "relay.request", "relay_task", &task.id,
|
||||
Some(serde_json::json!({"model": model_name, "stream": stream})), ctx.client_ip.as_deref()).await?;
|
||||
|
||||
// 执行中转
|
||||
// 执行中转 (带重试)
|
||||
let response = service::execute_relay(
|
||||
&state.db, &task.id, &provider.base_url,
|
||||
provider_api_key.as_deref(), &request_body, stream,
|
||||
config.relay.max_attempts,
|
||||
config.relay.retry_delay_ms,
|
||||
).await;
|
||||
|
||||
match response {
|
||||
@@ -168,3 +172,78 @@ pub async fn list_available_models(
|
||||
|
||||
Ok(Json(available))
|
||||
}
|
||||
|
||||
/// POST /api/v1/relay/tasks/:id/retry (admin only)
|
||||
/// 重试失败的中转任务
|
||||
pub async fn retry_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "relay:admin")?;
|
||||
|
||||
let task = service::get_relay_task(&state.db, &id).await?;
|
||||
if task.status != "failed" {
|
||||
return Err(SaasError::InvalidInput(format!(
|
||||
"只能重试失败的任务,当前状态: {}", task.status
|
||||
)));
|
||||
}
|
||||
|
||||
// 获取 provider 信息
|
||||
let provider = model_service::get_provider(&state.db, &task.provider_id).await?;
|
||||
let provider_api_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT api_key FROM providers WHERE id = ?1"
|
||||
)
|
||||
.bind(&task.provider_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// 读取原始请求体
|
||||
let request_body: Option<String> = sqlx::query_scalar(
|
||||
"SELECT request_body FROM relay_tasks WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let body = request_body.ok_or_else(|| SaasError::Internal("任务请求体丢失".into()))?;
|
||||
|
||||
// 从 request body 解析 stream 标志
|
||||
let stream: bool = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("stream").and_then(|s| s.as_bool()))
|
||||
.unwrap_or(false);
|
||||
|
||||
let max_attempts = task.max_attempts as u32;
|
||||
let config = state.config.read().await;
|
||||
let base_delay_ms = config.relay.retry_delay_ms;
|
||||
|
||||
// 重置任务状态为 queued 以允许新的 processing
|
||||
sqlx::query(
|
||||
"UPDATE relay_tasks SET status = 'queued', error_message = NULL, started_at = NULL, completed_at = NULL WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// 异步执行重试
|
||||
let db = state.db.clone();
|
||||
let task_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
match service::execute_relay(
|
||||
&db, &task_id, &provider.base_url,
|
||||
provider_api_key.as_deref(), &body, stream,
|
||||
max_attempts, base_delay_ms,
|
||||
).await {
|
||||
Ok(_) => tracing::info!("Relay task {} 重试成功", task_id),
|
||||
Err(e) => tracing::warn!("Relay task {} 重试失败: {}", task_id, e),
|
||||
}
|
||||
});
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "relay.retry", "relay_task", &id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "task_id": id})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user