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:
iven
2026-03-27 17:58:14 +08:00
parent bc12f6899a
commit 452ff45a5f
15 changed files with 876 additions and 68 deletions

View File

@@ -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})))
}