forked from boos/BoosAPI
feat: improve agent workbench and digital human flows
This commit is contained in:
+103
-3
@@ -443,9 +443,21 @@ pub async fn delete_user(
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting user: {}", id);
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM downstream_api_keys WHERE user_id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE invitations SET invited_by = NULL WHERE invited_by = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
@@ -453,6 +465,8 @@ pub async fn delete_user(
|
||||
return Err(AppError::NotFound(format!("User {} not found", id)));
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
@@ -953,6 +967,11 @@ mod tests {
|
||||
.await
|
||||
.expect("in-memory database");
|
||||
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("enable foreign keys");
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
@@ -989,7 +1008,7 @@ mod tests {
|
||||
excluded_site_ids TEXT,
|
||||
excluded_credential_refs TEXT,
|
||||
last_used_at TEXT,
|
||||
user_id INTEGER,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
@@ -998,6 +1017,23 @@ mod tests {
|
||||
.await
|
||||
.expect("downstream keys table");
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
org_id INTEGER NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
invited_by INTEGER REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("invitations table");
|
||||
|
||||
AppState::new(
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
@@ -1079,6 +1115,70 @@ mod tests {
|
||||
assert_eq!(role, "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_user_clears_owned_keys_and_nullable_references() {
|
||||
let state = test_state().await;
|
||||
let user_id = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, status)
|
||||
VALUES (?, ?, ?, 'user', 'active')",
|
||||
)
|
||||
.bind("Delete Me")
|
||||
.bind("delete-me@example.test")
|
||||
.bind("hash")
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert user")
|
||||
.last_insert_rowid();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id)
|
||||
VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind("default")
|
||||
.bind("sk-delete-me")
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert key");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO invitations (org_id, email, token, invited_by, expires_at)
|
||||
VALUES (1, 'invitee@example.test', 'tok-delete-me', ?, datetime('now', '+1 day'))",
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert invitation");
|
||||
|
||||
let Json(value) = delete_user(State(state.clone()), Path(user_id))
|
||||
.await
|
||||
.expect("delete user");
|
||||
|
||||
assert_eq!(value["data"]["deleted"], true);
|
||||
|
||||
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.expect("count users");
|
||||
assert_eq!(user_count, 0);
|
||||
|
||||
let key_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.expect("count keys");
|
||||
assert_eq!(key_count, 0);
|
||||
|
||||
let invited_by: Option<i64> =
|
||||
sqlx::query_scalar("SELECT invited_by FROM invitations WHERE token = 'tok-delete-me'")
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.expect("read invitation");
|
||||
assert_eq!(invited_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
||||
let state = test_state().await;
|
||||
@@ -1109,7 +1209,7 @@ mod tests {
|
||||
.await
|
||||
.expect("insert key");
|
||||
|
||||
adjust_user_balance(
|
||||
let _ = adjust_user_balance(
|
||||
State(state.clone()),
|
||||
Path(user_id),
|
||||
Json(AdjustBalanceRequest { delta: 5.0 }),
|
||||
|
||||
+132
-1
@@ -32,7 +32,7 @@ use crate::services::agent_workbench::{
|
||||
};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, State},
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::sse::{Event, Sse},
|
||||
routing::{get, post},
|
||||
@@ -73,6 +73,9 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/agent/chat", post(chat_handler))
|
||||
.route("/api/agent/approve", post(approve_handler))
|
||||
.route("/api/agent/answer", post(answer_handler))
|
||||
.route("/api/agent/models", get(models_handler))
|
||||
.route("/api/agent/sessions", get(list_sessions_handler))
|
||||
.route("/api/agent/sessions/{id}", get(get_session_handler))
|
||||
.route("/api/agent/status", get(status_handler))
|
||||
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||
}
|
||||
@@ -105,6 +108,12 @@ struct AnswerRequest {
|
||||
answer: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SessionListQuery {
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// Resolved caller identity for an agent request.
|
||||
#[derive(Debug, Clone)]
|
||||
struct AgentAuth {
|
||||
@@ -363,6 +372,16 @@ async fn chat_handler(
|
||||
{
|
||||
tracing::warn!(%e, "failed to ensure agent session row");
|
||||
}
|
||||
if let Err(e) = session_store::set_title_if_empty(
|
||||
&state.db,
|
||||
&session_id,
|
||||
auth.user_id,
|
||||
&body.message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%e, "failed to set agent session title");
|
||||
}
|
||||
|
||||
// Load any prior conversation for this session so the agent sees the full
|
||||
// multi-turn history (scoped to the caller — never another tenant's).
|
||||
@@ -624,6 +643,118 @@ async fn answer_handler(
|
||||
Ok(Json(json!({ "ok": delivered })))
|
||||
}
|
||||
|
||||
async fn list_sessions_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<SessionListQuery>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let sessions = session_store::list_sessions(&state.db, auth.user_id, query.limit.unwrap_or(30))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(json!({ "ok": true, "sessions": sessions })))
|
||||
}
|
||||
|
||||
async fn get_session_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let session = session_store::get_session(&state.db, &id, auth.user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "Agent session not found" })),
|
||||
)
|
||||
})?;
|
||||
let transcript = session_store::load_transcript(&state.db, &id, auth.user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?
|
||||
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||
.unwrap_or_else(|| json!([]));
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"session": session,
|
||||
"transcript": transcript,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn load_routable_models(state: &AppState) -> Result<Vec<Value>, sqlx::Error> {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT
|
||||
tr.model_pattern,
|
||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||
FROM token_routes tr
|
||||
WHERE tr.enabled = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM route_channels rc
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE rc.route_id = tr.id
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND s.url IS NOT NULL
|
||||
AND s.url <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
)
|
||||
ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(name, model)| {
|
||||
json!({
|
||||
"id": model,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": name,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn models_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let _auth = resolve_auth(&headers, &state).await?;
|
||||
let models = load_routable_models(&state).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": models,
|
||||
"default_model": state.config.comfyui.agent_model,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn status_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
+16
-12
@@ -20,11 +20,10 @@ use std::net::{IpAddr, SocketAddr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Default spending cap (USD) on a newly created user's auto-generated default
|
||||
/// API key. Set to `0.0` so a freshly registered user has **zero** spend
|
||||
/// allowance until they top up or an admin raises the cap. This must always be
|
||||
/// bound explicitly on INSERT: a NULL `max_cost` is treated as *unlimited* by
|
||||
/// the proxy, so leaving it unbound silently grants free unlimited usage.
|
||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 0.0;
|
||||
/// API key. This must always be bound explicitly on INSERT: a NULL `max_cost`
|
||||
/// is treated as *unlimited* by the proxy, so leaving it unbound silently grants
|
||||
/// free unlimited usage.
|
||||
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
@@ -1422,15 +1421,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_user_default_quota_is_zero() {
|
||||
// New users must start with zero spend allowance, not unlimited.
|
||||
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 0.0);
|
||||
fn new_user_default_quota_matches_starter_credit() {
|
||||
// New users receive a small starter credit, while still avoiding NULL
|
||||
// (NULL means unlimited in the proxy path).
|
||||
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registration_default_key_has_non_null_zero_max_cost() {
|
||||
async fn registration_default_key_has_non_null_starter_max_cost() {
|
||||
// Regression guard for the billing leak: the default downstream key
|
||||
// created at registration must persist max_cost = 0.0, never NULL
|
||||
// created at registration must persist a finite max_cost, never NULL
|
||||
// (NULL is treated as unlimited by the proxy). This runs the exact
|
||||
// INSERT the register handler uses.
|
||||
let state = test_state().await;
|
||||
@@ -1462,7 +1462,7 @@ mod tests {
|
||||
.await
|
||||
.expect("insert default key");
|
||||
|
||||
// max_cost must be present (non-NULL) and exactly zero.
|
||||
// max_cost must be present (non-NULL) and match starter credit.
|
||||
let max_cost: Option<f64> =
|
||||
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE key = ?")
|
||||
.bind("sk-user-quota-test")
|
||||
@@ -1470,6 +1470,10 @@ mod tests {
|
||||
.await
|
||||
.expect("select max_cost");
|
||||
assert!(max_cost.is_some(), "max_cost must not be NULL (NULL = unlimited)");
|
||||
assert_eq!(max_cost.unwrap(), 0.0, "new user default key must have zero quota");
|
||||
assert_eq!(
|
||||
max_cost.unwrap(),
|
||||
DEFAULT_NEW_USER_MAX_COST_USD,
|
||||
"new user default key must receive starter quota",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+277
-17
@@ -15,6 +15,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use mime_guess::MimeGuess;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{
|
||||
@@ -140,6 +141,18 @@ fn pixelle_backend_label(state: &AppState, provider: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_pixelle_media_workflow_for_api(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower == "runninghub/af_scail.json"
|
||||
|| lower == "runninghub/af_scail_2_action_transfer.json"
|
||||
{
|
||||
"runninghub/video_SCAIL-2-_.json".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn pixelle_base_url(state: &AppState) -> AppResult<String> {
|
||||
state
|
||||
.config
|
||||
@@ -237,6 +250,36 @@ fn pixelle_project_root(state: &AppState) -> PathBuf {
|
||||
PathBuf::from("/opt/Pixelle-Video")
|
||||
}
|
||||
|
||||
fn workflow_media_prefix(media_type: &str) -> &'static str {
|
||||
match media_type.trim().to_ascii_lowercase().as_str() {
|
||||
"video" => "video_",
|
||||
"action_transfer" | "action-transfer" | "af" => "af_",
|
||||
_ => "image_",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_pixelle_workflow_source(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"runninghub" | "running_hub" | "rh" => Some("runninghub"),
|
||||
"selfhost" | "self_host" | "comfy" | "comfyui" | "local" => Some("selfhost"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_workflow_filename(file_name: &str, media_type: &str) -> String {
|
||||
let stem = FsPath::new(file_name)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(safe_pixelle_filename)
|
||||
.unwrap_or_else(|| "custom".to_string());
|
||||
let mut stem = stem.trim_end_matches(".json").to_string();
|
||||
let prefix = workflow_media_prefix(media_type);
|
||||
if !stem.starts_with("image_") && !stem.starts_with("video_") && !stem.starts_with("af_") {
|
||||
stem = format!("{prefix}{stem}");
|
||||
}
|
||||
format!("{stem}.json")
|
||||
}
|
||||
|
||||
async fn persist_pixelle_upload_resource(
|
||||
state: &AppState,
|
||||
file_name: &str,
|
||||
@@ -357,6 +400,10 @@ pub fn routes() -> Router<AppState> {
|
||||
"/api/digital-human/pixelle/workflows/media",
|
||||
get(pixelle_list_media_workflows),
|
||||
)
|
||||
.route(
|
||||
"/api/digital-human/pixelle/workflows/upload",
|
||||
post(pixelle_upload_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/digital-human/pixelle/templates",
|
||||
get(pixelle_list_templates),
|
||||
@@ -2031,7 +2078,35 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
||||
"videoUrl",
|
||||
"video_url",
|
||||
],
|
||||
);
|
||||
)
|
||||
.map(validate_pixelle_input_media_reference)
|
||||
.transpose()?;
|
||||
let reference_image_url = optional_string(
|
||||
body,
|
||||
&[
|
||||
"referenceImageUrl",
|
||||
"reference_image_url",
|
||||
"imagePath",
|
||||
"image_path",
|
||||
"imageUrl",
|
||||
"image_url",
|
||||
],
|
||||
)
|
||||
.map(validate_pixelle_input_media_reference)
|
||||
.transpose()?;
|
||||
let reference_video_url = optional_string(
|
||||
body,
|
||||
&[
|
||||
"referenceVideoUrl",
|
||||
"reference_video_url",
|
||||
"videoPath",
|
||||
"video_path",
|
||||
"videoUrl",
|
||||
"video_url",
|
||||
],
|
||||
)
|
||||
.map(validate_pixelle_input_media_reference)
|
||||
.transpose()?;
|
||||
let reference_audio_url = optional_string(
|
||||
body,
|
||||
&[
|
||||
@@ -2049,6 +2124,16 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
||||
let mut template_params = Map::new();
|
||||
template_params.insert("source".to_string(), json!("boosapi-digital-human"));
|
||||
template_params.insert("avatar_url".to_string(), json!(avatar_url.clone()));
|
||||
template_params.insert("image_url".to_string(), json!(reference_image_url.clone()));
|
||||
template_params.insert(
|
||||
"reference_image_url".to_string(),
|
||||
json!(reference_image_url.clone()),
|
||||
);
|
||||
template_params.insert("video_url".to_string(), json!(reference_video_url.clone()));
|
||||
template_params.insert(
|
||||
"reference_video_url".to_string(),
|
||||
json!(reference_video_url.clone()),
|
||||
);
|
||||
template_params.insert(
|
||||
"reference_audio_url".to_string(),
|
||||
json!(reference_audio_url.clone()),
|
||||
@@ -2076,6 +2161,15 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
||||
let bgm_path = optional_string(body, &["bgmPath", "bgm_path"])
|
||||
.map(validate_pixelle_input_media_reference)
|
||||
.transpose()?;
|
||||
let tts_inference_mode = optional_string(body, &["ttsInferenceMode", "tts_inference_mode", "inferenceMode", "inference_mode"])
|
||||
.map(|value| {
|
||||
if value.eq_ignore_ascii_case("comfyui") || value.eq_ignore_ascii_case("comfy") {
|
||||
"comfyui".to_string()
|
||||
} else {
|
||||
"local".to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "local".to_string());
|
||||
let mut request_body = json!({
|
||||
"text": script,
|
||||
"mode": optional_string(body, &["pixelleMode", "pixelle_mode"]).unwrap_or_else(|| state.config.pixelle_video.mode.clone()),
|
||||
@@ -2087,29 +2181,62 @@ fn pixelle_request_body(state: &AppState, body: &Value) -> AppResult<(String, Va
|
||||
"frame_template": optional_string(body, &["frameTemplate", "frame_template"]).unwrap_or_else(|| state.config.pixelle_video.frame_template.clone()),
|
||||
"template_params": Value::Object(template_params),
|
||||
"prompt_prefix": prompt_text,
|
||||
"bgm_volume": body.get("bgmVolume").or_else(|| body.get("bgm_volume")).and_then(Value::as_f64).unwrap_or(0.3).clamp(0.0, 1.0)
|
||||
"bgm_volume": body.get("bgmVolume").or_else(|| body.get("bgm_volume")).and_then(Value::as_f64).unwrap_or(0.3).clamp(0.0, 1.0),
|
||||
"tts_inference_mode": tts_inference_mode,
|
||||
"inference_mode": tts_inference_mode
|
||||
});
|
||||
if let Some(value) = bgm_path {
|
||||
request_body["bgm_path"] = json!(value);
|
||||
}
|
||||
if let Some(value) = avatar_url {
|
||||
request_body["avatar_url"] = json!(value);
|
||||
}
|
||||
if let Some(value) = reference_image_url {
|
||||
request_body["image_url"] = json!(value);
|
||||
request_body["image_path"] = json!(value);
|
||||
request_body["reference_image_url"] = json!(value);
|
||||
}
|
||||
if let Some(value) = reference_video_url {
|
||||
request_body["video_url"] = json!(value);
|
||||
request_body["video_path"] = json!(value);
|
||||
request_body["reference_video_url"] = json!(value);
|
||||
}
|
||||
if let Some(value) = optional_string(body, &["mediaWorkflow", "media_workflow"])
|
||||
.or_else(|| default_pixelle_media_workflow(state, backend_provider))
|
||||
{
|
||||
request_body["media_workflow"] = json!(value);
|
||||
request_body["media_workflow"] = json!(normalize_pixelle_media_workflow_for_api(&value));
|
||||
}
|
||||
if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
||||
if tts_inference_mode == "local" {
|
||||
request_body["tts_voice"] = json!(
|
||||
optional_string(body, &["ttsVoice", "tts_voice", "voice"])
|
||||
.unwrap_or_else(|| "zh-CN-YunjianNeural".to_string())
|
||||
);
|
||||
request_body["voice"] = request_body["tts_voice"].clone();
|
||||
request_body["voice_id"] = request_body["tts_voice"].clone();
|
||||
let speed = body
|
||||
.get("ttsSpeed")
|
||||
.or_else(|| body.get("tts_speed"))
|
||||
.or_else(|| body.get("speed"))
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(1.2)
|
||||
.clamp(0.5, 2.0);
|
||||
request_body["tts_speed"] = json!(speed);
|
||||
request_body["speed"] = json!(speed);
|
||||
} else if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
||||
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
|
||||
{
|
||||
request_body["tts_workflow"] = json!(value);
|
||||
}
|
||||
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||
request_body["api_video_workflow"] = json!(value);
|
||||
request_body["api_video_workflow"] = json!(normalize_pixelle_media_workflow_for_api(&value));
|
||||
}
|
||||
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
|
||||
request_body["llm_model"] = json!(value);
|
||||
}
|
||||
if let Some(value) = reference_audio_url {
|
||||
request_body["ref_audio"] = json!(value);
|
||||
if tts_inference_mode == "comfyui" {
|
||||
if let Some(value) = reference_audio_url {
|
||||
request_body["ref_audio"] = json!(value);
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
request_body
|
||||
@@ -3145,6 +3272,83 @@ async fn upload_asset(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn pixelle_upload_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<Value>> {
|
||||
resolve_auth(&headers, &state).await?;
|
||||
let mut file_name = "workflow.json".to_string();
|
||||
let mut source = "runninghub".to_string();
|
||||
let mut media_type = "image".to_string();
|
||||
let mut bytes: Option<Bytes> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
file_name = field.file_name().unwrap_or(&file_name).to_string();
|
||||
bytes = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))?,
|
||||
);
|
||||
}
|
||||
"source" => source = field.text().await.unwrap_or_else(|_| source.clone()),
|
||||
"media_type" | "mediaType" => {
|
||||
media_type = field.text().await.unwrap_or_else(|_| media_type.clone())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(bytes) = bytes else {
|
||||
return Err(AppError::BadRequest("file is required".into()));
|
||||
};
|
||||
if filename_extension(&file_name).as_deref() != Some("json") {
|
||||
return Err(AppError::BadRequest("工作流文件必须是 .json".into()));
|
||||
}
|
||||
let json_value: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| AppError::BadRequest(format!("工作流 JSON 无效: {err}")))?;
|
||||
if !json_value.is_object() {
|
||||
return Err(AppError::BadRequest("工作流 JSON 必须是对象".into()));
|
||||
}
|
||||
let Some(source) = normalize_pixelle_workflow_source(&source) else {
|
||||
return Err(AppError::BadRequest("source must be runninghub or selfhost".into()));
|
||||
};
|
||||
let file_name = normalize_workflow_filename(&file_name, &media_type);
|
||||
let relative_path = format!("data/workflows/{source}/{file_name}");
|
||||
let absolute_path = pixelle_project_root(&state).join(&relative_path);
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|err| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to create Pixelle workflow dir: {err}"))
|
||||
})?;
|
||||
}
|
||||
tokio::fs::write(&absolute_path, &bytes).await.map_err(|err| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to persist Pixelle workflow: {err}"))
|
||||
})?;
|
||||
|
||||
let _ = crate::services::pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||
|
||||
let key = format!("{source}/{file_name}");
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"workflow": {
|
||||
"name": file_name,
|
||||
"display_name": format!("{} - {}", file_name, source_label(source)),
|
||||
"source": source,
|
||||
"path": absolute_path.to_string_lossy(),
|
||||
"key": key,
|
||||
"media_type": if workflow_media_prefix(&media_type) == "image_" { "image" } else { "video" }
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Pixelle WebUI parity: thin proxy + auth wrapper ─────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -3180,15 +3384,23 @@ async fn pixelle_list_media_workflows(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
resolve_auth(&headers, &state).await?;
|
||||
Ok(Json(
|
||||
pixelle_json(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/api/resources/workflows/media",
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
let value = pixelle_json(
|
||||
&state,
|
||||
Method::GET,
|
||||
"/api/resources/workflows/media",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
fn source_label(source: &str) -> &'static str {
|
||||
match source {
|
||||
"runninghub" => "Runninghub",
|
||||
"selfhost" => "Selfhost",
|
||||
"comfy" => "Comfy",
|
||||
_ => "Workflow",
|
||||
}
|
||||
}
|
||||
|
||||
async fn pixelle_list_templates(
|
||||
@@ -3254,9 +3466,40 @@ async fn pixelle_preview_image(
|
||||
async fn pixelle_preview_tts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
resolve_auth(&headers, &state).await?;
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
if !object.contains_key("inference_mode") {
|
||||
if let Some(value) = object.get("tts_inference_mode").cloned() {
|
||||
object.insert("inference_mode".to_string(), value);
|
||||
}
|
||||
}
|
||||
if !object.contains_key("voice") {
|
||||
if let Some(value) = object.get("tts_voice").cloned() {
|
||||
object.insert("voice".to_string(), value);
|
||||
}
|
||||
}
|
||||
if !object.contains_key("voice_id") {
|
||||
if let Some(value) = object.get("voice").or_else(|| object.get("tts_voice")).cloned() {
|
||||
object.insert("voice_id".to_string(), value);
|
||||
}
|
||||
}
|
||||
if object
|
||||
.get("inference_mode")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("local"))
|
||||
{
|
||||
object.remove("workflow");
|
||||
object.remove("tts_workflow");
|
||||
object.remove("ref_audio");
|
||||
}
|
||||
if !object.contains_key("speed") {
|
||||
if let Some(value) = object.get("tts_speed").cloned() {
|
||||
object.insert("speed".to_string(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(
|
||||
pixelle_json(&state, Method::POST, "/api/tts/synthesize", Some(&body)).await?,
|
||||
))
|
||||
@@ -3303,6 +3546,23 @@ async fn pixelle_proxy_file(
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::BadRequest("file path is required".into()));
|
||||
}
|
||||
let project_root = pixelle_project_root(&state);
|
||||
let local_path = project_root.join(trimmed);
|
||||
if local_path.starts_with(&project_root) && local_path.is_file() {
|
||||
let bytes = tokio::fs::read(&local_path).await.map_err(|err| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to read Pixelle local file: {err}"))
|
||||
})?;
|
||||
let content_type = MimeGuess::from_path(&local_path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string();
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
// Pixelle path-encodes segments separately; rebuild safely.
|
||||
let encoded = trimmed
|
||||
.split('/')
|
||||
|
||||
@@ -219,11 +219,15 @@ async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<Ev
|
||||
l.id AS related_id,
|
||||
'proxy_log' AS related_type,
|
||||
l.created_at AS created_at
|
||||
FROM proxy_logs l
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM proxy_logs
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
) l
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ?
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
|
||||
+85
-17
@@ -248,12 +248,22 @@ async fn is_job_cancelled(state: &AppState, id: &str) -> bool {
|
||||
struct ImageJobRequest {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
image_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
width: Option<i64>,
|
||||
#[serde(default)]
|
||||
height: Option<i64>,
|
||||
#[serde(default)]
|
||||
size: Option<i64>,
|
||||
#[serde(default)]
|
||||
scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
min_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
max_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
force_single: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -262,6 +272,8 @@ struct VideoJobRequest {
|
||||
#[serde(default)]
|
||||
prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
image_urls: Vec<String>,
|
||||
#[serde(default = "default_video_frames")]
|
||||
frames: i64,
|
||||
@@ -285,9 +297,14 @@ async fn create_image_job(
|
||||
|
||||
let image_req = ImageRequest {
|
||||
prompt: req.prompt,
|
||||
model: req.model,
|
||||
image_urls: req.image_urls,
|
||||
width: req.width,
|
||||
height: req.height,
|
||||
size: req.size,
|
||||
scale: req.scale,
|
||||
min_ratio: req.min_ratio,
|
||||
max_ratio: req.max_ratio,
|
||||
force_single: req.force_single,
|
||||
};
|
||||
// Validate eagerly so the caller gets a 400 instead of a failed job.
|
||||
@@ -301,7 +318,17 @@ async fn create_image_job(
|
||||
JimengCapability::Image,
|
||||
body,
|
||||
title,
|
||||
json!({ "prompt": image_req.prompt }),
|
||||
json!({
|
||||
"prompt": image_req.prompt,
|
||||
"model": image_req.model,
|
||||
"imageUrls": image_req.image_urls,
|
||||
"width": image_req.width,
|
||||
"height": image_req.height,
|
||||
"size": image_req.size,
|
||||
"scale": image_req.scale,
|
||||
"minRatio": image_req.min_ratio,
|
||||
"maxRatio": image_req.max_ratio,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -319,6 +346,7 @@ async fn create_video_job(
|
||||
|
||||
let video_req = VideoRequest {
|
||||
prompt: req.prompt,
|
||||
model: req.model,
|
||||
image_urls: req.image_urls,
|
||||
frames: req.frames,
|
||||
aspect_ratio: req.aspect_ratio,
|
||||
@@ -342,8 +370,10 @@ async fn create_video_job(
|
||||
title,
|
||||
json!({
|
||||
"prompt": video_req.prompt,
|
||||
"model": video_req.model,
|
||||
"imageUrls": video_req.image_urls,
|
||||
"frames": video_req.frames,
|
||||
"aspectRatio": video_req.aspect_ratio,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -432,12 +462,17 @@ async fn spawn_job(
|
||||
) -> AppResult<String> {
|
||||
let task_id = format!("{}_{}", capability.id_prefix(), uuid::Uuid::new_v4().simple());
|
||||
let task_type = capability.task_type();
|
||||
let model = body
|
||||
.get("req_key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| capability.req_key())
|
||||
.to_string();
|
||||
let initial_result = json!({
|
||||
"userId": auth.user_id,
|
||||
"role": auth.role,
|
||||
"keyId": auth.key_id,
|
||||
"keyName": auth.key_name,
|
||||
"capability": capability.req_key(),
|
||||
"capability": model,
|
||||
"request": request_snapshot,
|
||||
});
|
||||
sqlx::query(
|
||||
@@ -450,7 +485,7 @@ async fn spawn_job(
|
||||
.bind(if title.trim().is_empty() { "即梦生成任务" } else { title.trim() })
|
||||
.bind(format!(
|
||||
"jimeng:{}:{}",
|
||||
capability.req_key(),
|
||||
model,
|
||||
auth.user_id.map(|id| id.to_string()).unwrap_or_else(|| "admin".into())
|
||||
))
|
||||
.bind(initial_result.to_string())
|
||||
@@ -488,17 +523,38 @@ async fn run_worker(
|
||||
task_id: String,
|
||||
base_result: Value,
|
||||
) {
|
||||
let model = capability.req_key().to_string();
|
||||
let model = body
|
||||
.get("req_key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| capability.req_key())
|
||||
.to_string();
|
||||
let outcome = drive(&state, &provider, capability, &body, &task_id).await;
|
||||
|
||||
match outcome {
|
||||
Ok(output) => {
|
||||
update_job_progress(&state, &task_id, 3, "生成完成,正在转存到工作空间").await;
|
||||
let assets = store_outputs(&state, &auth, capability, &output).await;
|
||||
let assets = store_outputs(&state, &auth, capability, &model, &output).await;
|
||||
let mut final_result = base_result.clone();
|
||||
if let Some(obj) = final_result.as_object_mut() {
|
||||
obj.insert("assets".into(), json!(assets));
|
||||
}
|
||||
if !assets_have_completed(&assets) {
|
||||
let error = "产物转存失败,未生成可用工作空间文件";
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', message = '生成完成但转存失败', error = ?,
|
||||
result_json = ?, updated_at = datetime('now'), finished_at = datetime('now')
|
||||
WHERE id = ? AND status IN ('pending', 'running')",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(final_result.to_string())
|
||||
.bind(&task_id)
|
||||
.execute(&*state.db)
|
||||
.await;
|
||||
record_billing(&state, &auth, capability, &model, false, Some(error.to_string()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = '生成完成', result_json = ?, progress = 4,
|
||||
@@ -583,29 +639,29 @@ async fn store_outputs(
|
||||
state: &AppState,
|
||||
auth: &JimengAuth,
|
||||
capability: JimengCapability,
|
||||
model: &str,
|
||||
output: &JimengOutput,
|
||||
) -> Vec<Value> {
|
||||
let model = capability.req_key().to_string();
|
||||
let mut assets = Vec::new();
|
||||
match output {
|
||||
JimengOutput::Image { urls, base64 } => {
|
||||
for url in urls {
|
||||
let input = asset_input(auth, capability, &model, Some(url.clone()));
|
||||
let input = asset_input(auth, capability, model, Some(url.clone()));
|
||||
let stored = record_remote_asset(state, input).await;
|
||||
assets.push(asset_to_json(stored));
|
||||
assets.push(asset_to_json(stored, auth.user_id.is_some()));
|
||||
}
|
||||
for data in base64 {
|
||||
let input = asset_input(auth, capability, &model, None);
|
||||
let input = asset_input(auth, capability, model, None);
|
||||
match record_base64_asset(state, input, data).await {
|
||||
Ok(stored) => assets.push(asset_to_json(Ok(stored))),
|
||||
Ok(stored) => assets.push(asset_to_json(Ok(stored), auth.user_id.is_some())),
|
||||
Err(err) => assets.push(json!({ "status": "failed", "error": err.to_string() })),
|
||||
}
|
||||
}
|
||||
}
|
||||
JimengOutput::Video { url } => {
|
||||
let input = asset_input(auth, capability, &model, Some(url.clone()));
|
||||
let input = asset_input(auth, capability, model, Some(url.clone()));
|
||||
let stored = record_remote_asset(state, input).await;
|
||||
assets.push(asset_to_json(stored));
|
||||
assets.push(asset_to_json(stored, auth.user_id.is_some()));
|
||||
}
|
||||
}
|
||||
assets
|
||||
@@ -625,19 +681,34 @@ fn asset_input(
|
||||
prompt: None,
|
||||
model: Some(model.to_string()),
|
||||
remote_url,
|
||||
metadata: json!({ "jimeng": { "reqKey": model } }),
|
||||
metadata: json!({
|
||||
"workspaceAsset": { "role": "output" },
|
||||
"jimeng": { "reqKey": model },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn assets_have_completed(assets: &[Value]) -> bool {
|
||||
assets
|
||||
.iter()
|
||||
.any(|asset| asset.get("status").and_then(Value::as_str) == Some("completed"))
|
||||
}
|
||||
|
||||
fn asset_to_json(
|
||||
stored: Result<crate::services::workspace::StoredWorkspaceAsset, AppError>,
|
||||
is_user_asset: bool,
|
||||
) -> Value {
|
||||
match stored {
|
||||
Ok(asset) => json!({
|
||||
Ok(asset) if is_user_asset => json!({
|
||||
"publicId": asset.public_id,
|
||||
"status": asset.status,
|
||||
"fileUrl": format!("/api/user/workspace/assets/{}/file", asset.public_id),
|
||||
}),
|
||||
Ok(asset) => json!({
|
||||
"publicId": asset.public_id,
|
||||
"status": asset.status,
|
||||
"fileUrl": format!("/api/workspace/assets/{}/file", asset.public_id),
|
||||
}),
|
||||
Err(err) => json!({ "status": "failed", "error": err.to_string() }),
|
||||
}
|
||||
}
|
||||
@@ -680,6 +751,3 @@ async fn record_billing(
|
||||
tracing::warn!(%err, "jimeng billing record failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+32
-22
@@ -281,21 +281,26 @@ async fn gemini_list_models(
|
||||
let _ = check_gemini_auth(&state, &headers, &query).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT DISTINCT
|
||||
"SELECT
|
||||
tr.model_pattern,
|
||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||
FROM token_routes tr
|
||||
JOIN route_channels rc ON rc.route_id = tr.id
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE tr.enabled = 1
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND TRIM(COALESCE(s.url, '')) <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM route_channels rc
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE rc.route_id = tr.id
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND s.url IS NOT NULL
|
||||
AND s.url <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
)
|
||||
ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
@@ -775,21 +780,26 @@ async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppRe
|
||||
let _ = check_auth(&state, &headers).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT DISTINCT
|
||||
"SELECT
|
||||
tr.model_pattern,
|
||||
COALESCE(NULLIF(TRIM(tr.model_mapping), ''), tr.model_pattern)
|
||||
FROM token_routes tr
|
||||
JOIN route_channels rc ON rc.route_id = tr.id
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE tr.enabled = 1
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND TRIM(COALESCE(s.url, '')) <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM route_channels rc
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE rc.route_id = tr.id
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND s.url IS NOT NULL
|
||||
AND s.url <> ''
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
)
|
||||
ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
|
||||
Reference in New Issue
Block a user