Merge branch 'feat/jimeng-sites-ui' into feat/embed-desktop
# Conflicts: # src/services/jimeng/mod.rs
This commit is contained in:
@@ -56,6 +56,19 @@ pub struct CreateAccountRequest {
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(alias = "tokenExpiresAt")]
|
||||
pub token_expires_at: Option<i64>,
|
||||
/// Jimeng only: the Volcengine secret key (SK), paired with `access_token`
|
||||
/// = AK. Stored (server-side only) in `extra_config.jimeng.secretKey`.
|
||||
#[serde(default, alias = "secretKey")]
|
||||
pub secret_key: Option<String>,
|
||||
/// Jimeng only: signing region override.
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
/// Jimeng only: API host override.
|
||||
#[serde(default)]
|
||||
pub host: Option<String>,
|
||||
/// Jimeng only: optional STS session token.
|
||||
#[serde(default, alias = "sessionToken")]
|
||||
pub session_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -88,8 +101,39 @@ pub struct VerifyTokenRequest {
|
||||
pub platform_user_id: Option<i64>,
|
||||
#[serde(alias = "credentialMode")]
|
||||
pub credential_mode: Option<String>,
|
||||
/// Jimeng only: the Volcengine secret key (paired with `access_token` = AK).
|
||||
#[serde(default, alias = "secretKey")]
|
||||
pub secret_key: Option<String>,
|
||||
/// Jimeng only: signing region override (defaults to `cn-north-1`).
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
/// Jimeng only: API host override (defaults to `visual.volcengineapi.com`).
|
||||
#[serde(default)]
|
||||
pub host: Option<String>,
|
||||
/// Jimeng only: optional STS session token.
|
||||
#[serde(default, alias = "sessionToken")]
|
||||
pub session_token: Option<String>,
|
||||
}
|
||||
|
||||
/// The `platform` value under which Jimeng connections are recorded in the
|
||||
/// existing `sites`/`accounts` tables. Jimeng is *not* an OpenAI-compatible
|
||||
/// provider (Volcengine V4 HMAC signing, async two-step generation, no
|
||||
/// `/models` endpoint), so the OpenAI-specific code paths (verify-token model
|
||||
/// probe, per-model site probe, proxy-log runtime health, route rebuild) branch
|
||||
/// on this constant and take a dedicated path for Jimeng, leaving every other
|
||||
/// platform's behaviour untouched.
|
||||
pub const JIMENG_PLATFORM: &str = "jimeng";
|
||||
|
||||
/// True when `platform` denotes a Jimeng connection (case-insensitive).
|
||||
pub fn is_jimeng_platform(platform: &str) -> bool {
|
||||
platform.trim().eq_ignore_ascii_case(JIMENG_PLATFORM)
|
||||
}
|
||||
|
||||
/// Sentinel returned (and accepted) in place of a stored Jimeng secret so the
|
||||
/// raw secret key / session token are never echoed back to the browser. Mirrors
|
||||
/// the Pixelle connection mask convention.
|
||||
pub const JIMENG_SECRET_MASK: &str = "••••";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshAccountHealthRequest {
|
||||
@@ -232,11 +276,29 @@ pub async fn list_accounts(
|
||||
)
|
||||
.collect();
|
||||
|
||||
// Jimeng accounts don't appear in proxy_logs (they use the dedicated
|
||||
// /api/jimeng/* job pipeline), so derive a platform-level health signal
|
||||
// from the most recent jimeng background_tasks row. Computed once and shared
|
||||
// by every jimeng account row. `None` when no jimeng account is listed.
|
||||
let jimeng_health: Option<Value> = if accounts
|
||||
.iter()
|
||||
.any(|a| site_by_id.get(&a.site_id).is_some_and(|s| is_jimeng_platform(&s.platform)))
|
||||
{
|
||||
derive_jimeng_runtime_health(&state).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let enriched: Vec<Value> = accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
let site = site_by_id.get(&account.site_id).copied();
|
||||
account_response_value(account, site, health_by_account.get(&account.id))
|
||||
account_response_value_with_jimeng(
|
||||
account,
|
||||
site,
|
||||
health_by_account.get(&account.id),
|
||||
jimeng_health.as_ref(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -264,9 +326,44 @@ fn account_response_value(
|
||||
i64,
|
||||
)>,
|
||||
) -> Value {
|
||||
account_response_value_with_jimeng(account, site, runtime_health, None)
|
||||
}
|
||||
|
||||
/// Like [`account_response_value`] but, for Jimeng accounts, replaces the
|
||||
/// proxy-log-derived `runtimeHealth` with the supplied platform-level Jimeng
|
||||
/// health (see `derive_jimeng_runtime_health`). `jimeng_health` is ignored for
|
||||
/// every other platform, so non-Jimeng behaviour is identical.
|
||||
fn account_response_value_with_jimeng(
|
||||
account: &Account,
|
||||
site: Option<&Site>,
|
||||
runtime_health: Option<&(
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
i64,
|
||||
)>,
|
||||
jimeng_health: Option<&Value>,
|
||||
) -> Value {
|
||||
let is_jimeng = site.is_some_and(|s| is_jimeng_platform(&s.platform));
|
||||
let credential_mode = resolve_account_credential_mode(account);
|
||||
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
|
||||
if let Value::Object(map) = &mut row {
|
||||
// Never echo Jimeng secret material (SK / session token) back to the
|
||||
// browser — replace it with the mask sentinel in the serialized
|
||||
// `extraConfig` field. No-op for every other platform.
|
||||
if let Some(extra) = account
|
||||
.extra_config
|
||||
.as_deref()
|
||||
.map(mask_account_extra_config)
|
||||
{
|
||||
map.insert("extraConfig".to_string(), json!(extra));
|
||||
}
|
||||
map.insert("credentialMode".to_string(), json!(credential_mode));
|
||||
map.insert(
|
||||
"capabilities".to_string(),
|
||||
@@ -294,6 +391,16 @@ fn account_response_value(
|
||||
"state": "unhealthy",
|
||||
"reason": "连接凭证已过期,请更新凭证",
|
||||
})
|
||||
} else if is_jimeng {
|
||||
// Jimeng never writes proxy_logs; use the platform-level signal
|
||||
// derived from recent jimeng background_tasks, falling back to a
|
||||
// descriptive "unknown" when there are no tasks yet.
|
||||
jimeng_health.cloned().unwrap_or_else(|| {
|
||||
json!({
|
||||
"state": "unknown",
|
||||
"reason": "尚无即梦生成任务记录,凭证验证通过后即可使用",
|
||||
})
|
||||
})
|
||||
} else if let Some((
|
||||
status,
|
||||
http_status,
|
||||
@@ -396,6 +503,27 @@ pub async fn create_account(
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating account for site_id: {}", payload.site_id);
|
||||
|
||||
let site_platform: Option<(String,)> =
|
||||
sqlx::query_as("SELECT platform FROM sites WHERE id = ?")
|
||||
.bind(payload.site_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let Some((site_platform,)) = site_platform else {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Site with id {} not found",
|
||||
payload.site_id
|
||||
)));
|
||||
};
|
||||
|
||||
// Jimeng connections store a Volcengine AK/SK pair (not a single OpenAI
|
||||
// Bearer token) and never join the OpenAI route table, so they take a
|
||||
// dedicated insert path that skips the token-batch loop and the route
|
||||
// rebuild. Every other platform keeps the original behaviour below.
|
||||
if is_jimeng_platform(&site_platform) {
|
||||
return create_jimeng_account(&state, &payload).await;
|
||||
}
|
||||
|
||||
let mut tokens = payload
|
||||
.access_tokens
|
||||
.iter()
|
||||
@@ -515,6 +643,194 @@ pub async fn create_account(
|
||||
))
|
||||
}
|
||||
|
||||
/// Insert a single Jimeng connection. The Volcengine access key (AK) lands in
|
||||
/// `accounts.access_token`; the secret key (SK) plus region/host/session token
|
||||
/// are tucked into `extra_config.jimeng.*` (SK/session token are masked on
|
||||
/// read-back, see [`mask_account_extra_config`]). Jimeng never joins the OpenAI
|
||||
/// route table, so no route rebuild is fired.
|
||||
async fn create_jimeng_account(
|
||||
state: &AppState,
|
||||
payload: &CreateAccountRequest,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let access_key = payload
|
||||
.access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|ak| !ak.is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("即梦连接需要 Access Key(AK)".into()))?;
|
||||
let secret_key = payload
|
||||
.secret_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|sk| !sk.is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("即梦连接需要 Secret Key(SK)".into()))?;
|
||||
if secret_key == JIMENG_SECRET_MASK {
|
||||
return Err(AppError::BadRequest(
|
||||
"请填写真实的 Secret Key(SK),而不是掩码".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let extra_config = build_jimeng_extra_config(
|
||||
payload.extra_config.as_deref(),
|
||||
secret_key,
|
||||
payload.region.as_deref(),
|
||||
payload.host.as_deref(),
|
||||
payload.session_token.as_deref(),
|
||||
)?;
|
||||
let status = payload.status.clone().unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config) VALUES (?, ?, ?, NULL, ?, ?, ?)"
|
||||
)
|
||||
.bind(payload.site_id)
|
||||
.bind(&payload.username)
|
||||
.bind(access_key)
|
||||
.bind(&status)
|
||||
.bind(payload.checkin_enabled.unwrap_or(false))
|
||||
.bind(&extra_config)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account_id = result.last_insert_rowid();
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(account_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let site: Option<Site> = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(account.site_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
// NB: deliberately NO spawn_route_rebuild — Jimeng does not enter the route table.
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"id": account.id,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"data": account_response_value(&account, site.as_ref(), None),
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the `extra_config` JSON for a Jimeng account: forces
|
||||
/// `credentialMode = "apikey"` (so it lists under the API Key segment, never
|
||||
/// the session segment) and nests the secret material under a `jimeng` object.
|
||||
/// Any caller-supplied `extra_config` object is preserved and merged.
|
||||
fn build_jimeng_extra_config(
|
||||
raw_extra_config: Option<&str>,
|
||||
secret_key: &str,
|
||||
region: Option<&str>,
|
||||
host: Option<&str>,
|
||||
session_token: Option<&str>,
|
||||
) -> AppResult<Option<String>> {
|
||||
let mut value = match raw_extra_config
|
||||
.map(str::trim)
|
||||
.filter(|raw| !raw.is_empty())
|
||||
{
|
||||
Some(raw) => serde_json::from_str::<Value>(raw)
|
||||
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {}", err)))?,
|
||||
None => json!({}),
|
||||
};
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
return Err(AppError::BadRequest(
|
||||
"extra_config must be a JSON object".into(),
|
||||
));
|
||||
};
|
||||
obj.insert("credentialMode".to_string(), json!("apikey"));
|
||||
|
||||
let mut jimeng = serde_json::Map::new();
|
||||
jimeng.insert("secretKey".to_string(), json!(secret_key));
|
||||
if let Some(region) = region.map(str::trim).filter(|r| !r.is_empty()) {
|
||||
jimeng.insert("region".to_string(), json!(region));
|
||||
}
|
||||
if let Some(host) = host.map(str::trim).filter(|h| !h.is_empty()) {
|
||||
jimeng.insert("host".to_string(), json!(host));
|
||||
}
|
||||
if let Some(token) = session_token.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
jimeng.insert("sessionToken".to_string(), json!(token));
|
||||
}
|
||||
obj.insert("jimeng".to_string(), Value::Object(jimeng));
|
||||
|
||||
serde_json::to_string(&value)
|
||||
.map(Some)
|
||||
.map_err(|err| AppError::Internal(err.into()))
|
||||
}
|
||||
|
||||
/// Return a copy of a Jimeng `extra_config` string with the secret key and
|
||||
/// session token replaced by [`JIMENG_SECRET_MASK`] so they are never sent to
|
||||
/// the browser. Non-Jimeng configs (no `jimeng` object) are returned unchanged.
|
||||
fn mask_account_extra_config(extra_config: &str) -> String {
|
||||
let Ok(mut value) = serde_json::from_str::<Value>(extra_config) else {
|
||||
return extra_config.to_string();
|
||||
};
|
||||
let Some(jimeng) = value
|
||||
.get_mut("jimeng")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
return extra_config.to_string();
|
||||
};
|
||||
for key in ["secretKey", "sessionToken"] {
|
||||
if jimeng.get(key).is_some() {
|
||||
jimeng.insert(key.to_string(), json!(JIMENG_SECRET_MASK));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| extra_config.to_string())
|
||||
}
|
||||
|
||||
/// Derive a platform-level `runtimeHealth` for Jimeng accounts from the most
|
||||
/// recent jimeng `background_tasks` row. Jimeng jobs run through the dedicated
|
||||
/// `/api/jimeng/*` pipeline and never touch `proxy_logs`, so the OpenAI
|
||||
/// proxy-log derivation would leave every Jimeng account perpetually `unknown`.
|
||||
/// Returns `None` when there are no jimeng tasks yet (caller falls back to a
|
||||
/// descriptive "no tasks" message).
|
||||
async fn derive_jimeng_runtime_health(state: &AppState) -> Option<Value> {
|
||||
let row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT status, message, error, updated_at
|
||||
FROM background_tasks
|
||||
WHERE task_type IN ('jimeng-image-generation', 'jimeng-video-generation')
|
||||
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&*state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (status, message, error, updated_at) = row?;
|
||||
let health = match status.as_str() {
|
||||
"succeeded" => json!({
|
||||
"state": "healthy",
|
||||
"reason": "最近一次即梦生成任务成功",
|
||||
"checkedAt": updated_at,
|
||||
}),
|
||||
"failed" => json!({
|
||||
"state": "unhealthy",
|
||||
"reason": error
|
||||
.filter(|e| !e.trim().is_empty())
|
||||
.or(message)
|
||||
.unwrap_or_else(|| "最近一次即梦生成任务失败".to_string()),
|
||||
"checkedAt": updated_at,
|
||||
}),
|
||||
"running" | "pending" => json!({
|
||||
"state": "unknown",
|
||||
"reason": "即梦生成任务进行中",
|
||||
"checkedAt": updated_at,
|
||||
}),
|
||||
"cancelled" => json!({
|
||||
"state": "unknown",
|
||||
"reason": "最近一次即梦生成任务已取消",
|
||||
"checkedAt": updated_at,
|
||||
}),
|
||||
other => json!({
|
||||
"state": "unknown",
|
||||
"reason": format!("最近即梦任务状态:{other}"),
|
||||
"checkedAt": updated_at,
|
||||
}),
|
||||
};
|
||||
Some(health)
|
||||
}
|
||||
|
||||
/// Fire a route rebuild in the background after account creation. We don't
|
||||
/// block the HTTP response on this — `model_availability` is filled by the
|
||||
/// separate `POST /api/accounts/{id}/models/refresh` call the frontend makes
|
||||
@@ -562,6 +878,14 @@ pub async fn verify_token(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", payload.site_id)))?;
|
||||
|
||||
// Jimeng connections are not OpenAI-compatible: there is no `/models`
|
||||
// endpoint and auth is a Volcengine V4 HMAC signature, not a Bearer token.
|
||||
// Verify by signing a minimal probe request (see `JimengProvider::verify`)
|
||||
// instead of the GET /models path used for every other platform.
|
||||
if is_jimeng_platform(&site.platform) {
|
||||
return verify_jimeng_token(&state, &site, &payload).await;
|
||||
}
|
||||
|
||||
let base_url = resolve_site_api_base_url(&state, &site).await?;
|
||||
let credential_mode = payload
|
||||
.credential_mode
|
||||
@@ -650,6 +974,72 @@ pub async fn verify_token(
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a Jimeng connection by performing a Volcengine V4 signature probe
|
||||
/// with the supplied AK/SK (+ optional region/host/session token). This never
|
||||
/// touches `/models` (Jimeng has none) and never triggers a generation — it
|
||||
/// only proves the gateway accepts the signature. `access_token` carries the
|
||||
/// access key (AK); `secret_key` the secret key (SK).
|
||||
async fn verify_jimeng_token(
|
||||
state: &AppState,
|
||||
site: &Site,
|
||||
payload: &VerifyTokenRequest,
|
||||
) -> AppResult<Json<Value>> {
|
||||
use crate::services::jimeng::{JimengProvider, JimengVerifyOutcome};
|
||||
|
||||
let access_key = payload.access_token.trim();
|
||||
let secret_key = payload.secret_key.as_deref().map(str::trim).unwrap_or("");
|
||||
if secret_key.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
"success": false,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"siteId": site.id,
|
||||
"message": "请填写 Secret Key(SK)",
|
||||
})));
|
||||
}
|
||||
|
||||
let provider = match JimengProvider::from_credentials(
|
||||
access_key,
|
||||
secret_key,
|
||||
payload.region.as_deref(),
|
||||
payload.host.as_deref(),
|
||||
payload.session_token.as_deref(),
|
||||
) {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
return Ok(Json(json!({
|
||||
"success": false,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"siteId": site.id,
|
||||
"message": format!("凭据无效:{err}"),
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = provider.verify(&state.http).await;
|
||||
let body = match outcome {
|
||||
JimengVerifyOutcome::Authenticated => json!({
|
||||
"success": true,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"siteId": site.id,
|
||||
"siteName": site.name,
|
||||
"message": "即梦签名验证成功(AK/SK 可用)",
|
||||
}),
|
||||
JimengVerifyOutcome::AuthFailed { code, message } => json!({
|
||||
"success": false,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"siteId": site.id,
|
||||
"message": format!("即梦签名验证失败({code}):{message}"),
|
||||
}),
|
||||
JimengVerifyOutcome::Indeterminate { detail } => json!({
|
||||
"success": false,
|
||||
"tokenType": JIMENG_PLATFORM,
|
||||
"siteId": site.id,
|
||||
"message": format!("无法判定即梦凭据有效性:{detail}"),
|
||||
}),
|
||||
};
|
||||
Ok(Json(body))
|
||||
}
|
||||
|
||||
/// POST /api/accounts/health/refresh
|
||||
///
|
||||
/// Runtime health is derived from recent proxy logs in `list_accounts`.
|
||||
@@ -681,7 +1071,7 @@ pub async fn get_account(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
Ok(Json(json!({ "data": account_data_masked(&account) })))
|
||||
}
|
||||
|
||||
/// PUT /api/accounts/{id}
|
||||
@@ -698,6 +1088,19 @@ pub async fn update_account(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||
|
||||
// Guard against persisting a masked Jimeng secret: the list/get responses
|
||||
// return `extra_config` with SK/session-token replaced by the mask
|
||||
// sentinel, so an edit round-trip that echoes that string back must not
|
||||
// overwrite the real secret. Reject it and let the caller omit the field
|
||||
// (which preserves the stored value) instead.
|
||||
if let Some(extra) = payload.extra_config.as_deref() {
|
||||
if extra.contains(JIMENG_SECRET_MASK) {
|
||||
return Err(AppError::BadRequest(
|
||||
"extra_config 含掩码占位符,请留空以保留原有 SK,或提交真实凭据".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the final status before the UPDATE so we can compare it against
|
||||
// the previous value and decide whether routing needs to be rebuilt.
|
||||
// `rebuild_routes` filters on `accounts.status = 'active'`, so any flip
|
||||
@@ -738,7 +1141,24 @@ pub async fn update_account(
|
||||
spawn_route_rebuild(state.clone());
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
Ok(Json(json!({ "data": account_data_masked(&account) })))
|
||||
}
|
||||
|
||||
/// Serialize an [`Account`] to JSON with its `extraConfig` masked (Jimeng SK /
|
||||
/// session token replaced by the sentinel). Used by the get/update endpoints
|
||||
/// which return the bare account object rather than [`account_response_value`].
|
||||
fn account_data_masked(account: &Account) -> Value {
|
||||
let mut value = serde_json::to_value(account).unwrap_or_else(|_| json!({}));
|
||||
if let Some(map) = value.as_object_mut() {
|
||||
if let Some(extra) = account
|
||||
.extra_config
|
||||
.as_deref()
|
||||
.map(mask_account_extra_config)
|
||||
{
|
||||
map.insert("extraConfig".to_string(), json!(extra));
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// DELETE /api/accounts/{id}
|
||||
@@ -1512,4 +1932,131 @@ mod tests {
|
||||
Some(7928)
|
||||
);
|
||||
}
|
||||
|
||||
fn jimeng_site_fixture(status: &str) -> Site {
|
||||
let mut site = site_fixture(status);
|
||||
site.platform = "jimeng".to_string();
|
||||
site.url = "https://visual.volcengineapi.com".to_string();
|
||||
site
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_jimeng_platform_matches_case_insensitively() {
|
||||
assert!(is_jimeng_platform("jimeng"));
|
||||
assert!(is_jimeng_platform("JIMENG"));
|
||||
assert!(is_jimeng_platform(" Jimeng "));
|
||||
assert!(!is_jimeng_platform("openai"));
|
||||
assert!(!is_jimeng_platform("comfy_cloud"));
|
||||
assert!(!is_jimeng_platform(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_jimeng_extra_config_nests_secret_and_forces_apikey() {
|
||||
let raw = build_jimeng_extra_config(
|
||||
None,
|
||||
"the-secret-key",
|
||||
Some("cn-north-1"),
|
||||
Some("visual.volcengineapi.com"),
|
||||
Some("sts-token"),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(value["credentialMode"], "apikey");
|
||||
assert_eq!(value["jimeng"]["secretKey"], "the-secret-key");
|
||||
assert_eq!(value["jimeng"]["region"], "cn-north-1");
|
||||
assert_eq!(value["jimeng"]["host"], "visual.volcengineapi.com");
|
||||
assert_eq!(value["jimeng"]["sessionToken"], "sts-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_jimeng_extra_config_omits_blank_optionals() {
|
||||
let raw = build_jimeng_extra_config(None, "sk", Some(" "), None, Some(""))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(&raw).unwrap();
|
||||
let jimeng = value["jimeng"].as_object().unwrap();
|
||||
assert!(jimeng.contains_key("secretKey"));
|
||||
assert!(!jimeng.contains_key("region"));
|
||||
assert!(!jimeng.contains_key("host"));
|
||||
assert!(!jimeng.contains_key("sessionToken"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_account_extra_config_hides_secret_and_session_token() {
|
||||
let raw = build_jimeng_extra_config(None, "supersecret", None, None, Some("stoken"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let masked = mask_account_extra_config(&raw);
|
||||
let value: Value = serde_json::from_str(&masked).unwrap();
|
||||
assert_eq!(value["jimeng"]["secretKey"], JIMENG_SECRET_MASK);
|
||||
assert_eq!(value["jimeng"]["sessionToken"], JIMENG_SECRET_MASK);
|
||||
assert!(!masked.contains("supersecret"));
|
||||
assert!(!masked.contains("stoken"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_account_extra_config_leaves_non_jimeng_config_untouched() {
|
||||
let raw = json!({ "credentialMode": "apikey", "platformUserId": 42 }).to_string();
|
||||
assert_eq!(mask_account_extra_config(&raw), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jimeng_account_uses_platform_health_override_not_proxy_logs() {
|
||||
let mut account = account_fixture("active", "apikey");
|
||||
account.extra_config = build_jimeng_extra_config(None, "sk", None, None, None).unwrap();
|
||||
let site = jimeng_site_fixture("active");
|
||||
let jimeng_health = json!({
|
||||
"state": "healthy",
|
||||
"reason": "最近一次即梦生成任务成功",
|
||||
});
|
||||
|
||||
// A stale proxy-log tuple is passed too; the jimeng override must win.
|
||||
let value = account_response_value_with_jimeng(
|
||||
&account,
|
||||
Some(&site),
|
||||
None,
|
||||
Some(&jimeng_health),
|
||||
);
|
||||
let rh = value.get("runtimeHealth").unwrap();
|
||||
assert_eq!(rh.get("state").and_then(Value::as_str), Some("healthy"));
|
||||
// SK must be masked in the response.
|
||||
let extra = value.get("extraConfig").and_then(Value::as_str).unwrap();
|
||||
assert!(extra.contains(JIMENG_SECRET_MASK));
|
||||
assert!(!extra.contains("\"sk\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jimeng_account_without_tasks_reports_descriptive_unknown() {
|
||||
let mut account = account_fixture("active", "apikey");
|
||||
account.extra_config = build_jimeng_extra_config(None, "sk", None, None, None).unwrap();
|
||||
let site = jimeng_site_fixture("active");
|
||||
|
||||
let value = account_response_value_with_jimeng(&account, Some(&site), None, None);
|
||||
let rh = value.get("runtimeHealth").unwrap();
|
||||
assert_eq!(rh.get("state").and_then(Value::as_str), Some("unknown"));
|
||||
assert_eq!(
|
||||
rh.get("reason").and_then(Value::as_str),
|
||||
Some("尚无即梦生成任务记录,凭证验证通过后即可使用")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_jimeng_account_ignores_jimeng_health_override() {
|
||||
// Regression guard: passing a jimeng_health value must NOT alter an
|
||||
// OpenAI account's proxy-log-derived health.
|
||||
let account = account_fixture("active", "apikey");
|
||||
let site = site_fixture("active"); // platform = openai
|
||||
let stray = json!({ "state": "healthy", "reason": "should be ignored" });
|
||||
|
||||
let value =
|
||||
account_response_value_with_jimeng(&account, Some(&site), None, Some(&stray));
|
||||
let rh = value.get("runtimeHealth").unwrap();
|
||||
// Falls through to the normal "no proxy logs yet" message, not the override.
|
||||
assert_eq!(rh.get("state").and_then(Value::as_str), Some("unknown"));
|
||||
assert_eq!(
|
||||
rh.get("reason").and_then(Value::as_str),
|
||||
Some("尚无代理请求记录,首次调用后自动更新")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
pub(crate) mod accounts;
|
||||
mod cli_templates;
|
||||
mod compat;
|
||||
mod downstream_keys;
|
||||
|
||||
@@ -642,6 +642,14 @@ async fn build_probe_plan(
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("site not found".into()))?;
|
||||
// Jimeng has no `/models` endpoint and no per-model chat semantics, so the
|
||||
// per-model probe is meaningless for it. Short-circuit with a clear message
|
||||
// (covers both probe-now and probe-stream, which funnel through here).
|
||||
if crate::routes::admin::accounts::is_jimeng_platform(&site.platform) {
|
||||
return Err(AppError::BadRequest(
|
||||
"即梦平台无需逐模型探测,请在连接管理用「验证」做签名连通性检查".into(),
|
||||
));
|
||||
}
|
||||
if site.status != "active" {
|
||||
return Err(AppError::BadRequest("站点未启用,无法执行探测".into()));
|
||||
}
|
||||
|
||||
+242
-2
@@ -18,6 +18,99 @@ use types::{
|
||||
};
|
||||
use volc_visual_sdk::VisualClient;
|
||||
|
||||
/// Sentinel task id used by [`JimengProvider::verify`]. It is intentionally not
|
||||
/// a real Volcengine task id: the gateway validates the V4 signature *before*
|
||||
/// the business layer ever looks up the task, so a valid AK/SK is accepted
|
||||
/// (and the business layer simply reports "not found"), while a bad AK/SK is
|
||||
/// rejected at the gateway with an auth-class error. We only care which side
|
||||
/// of that line the response lands on — never about the task itself.
|
||||
const VERIFY_SENTINEL_TASK_ID: &str = "metapi-credential-probe";
|
||||
|
||||
/// Outcome of a credential signature probe (see [`JimengProvider::verify`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JimengVerifyOutcome {
|
||||
/// The gateway accepted the V4 signature (AK/SK valid). The business layer
|
||||
/// may still have rejected the sentinel task — that is expected and means
|
||||
/// "authenticated".
|
||||
Authenticated,
|
||||
/// The gateway rejected the signature: bad AK/SK, region/host mismatch or
|
||||
/// an expired session token.
|
||||
AuthFailed {
|
||||
/// Volcengine gateway error `Code` (e.g. `SignatureDoesNotMatch`).
|
||||
code: String,
|
||||
/// Human-readable gateway message.
|
||||
message: String,
|
||||
},
|
||||
/// Could not determine the result (network/transport error, or an
|
||||
/// unparseable response). Carries a short detail for surfacing to the UI.
|
||||
Indeterminate {
|
||||
/// Short human-readable detail.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Returns true when a Volcengine gateway error `Code` denotes an
|
||||
/// authentication / signature failure (as opposed to a business or
|
||||
/// request-shape error, which still proves the signature was accepted).
|
||||
fn is_auth_error_code(code: &str) -> bool {
|
||||
let lc = code.to_ascii_lowercase();
|
||||
[
|
||||
"signature",
|
||||
"credential",
|
||||
"accesskey",
|
||||
"securitytoken",
|
||||
"denied",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"authentication",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lc.contains(needle))
|
||||
}
|
||||
|
||||
/// Classify the raw response of a signature-probe call into a
|
||||
/// [`JimengVerifyOutcome`]. Pure (no IO) so it is unit-testable.
|
||||
///
|
||||
/// Decision order:
|
||||
/// 1. A gateway `ResponseMetadata.Error` with an auth-class `Code` → auth
|
||||
/// failed. Any *other* gateway error (e.g. `InvalidParameter` triggered by
|
||||
/// our sentinel task id) still means the signature was accepted.
|
||||
/// 2. A flat business envelope (top-level integer `code`, as the CV actions
|
||||
/// return) → the gateway routed us to the business layer, so the signature
|
||||
/// was accepted.
|
||||
/// 3. Otherwise fall back to the HTTP status: `2xx` → authenticated.
|
||||
pub fn classify_verify_response(http_status: u16, body: &str) -> JimengVerifyOutcome {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body) {
|
||||
if let Some(err) = value
|
||||
.get("ResponseMetadata")
|
||||
.and_then(|meta| meta.get("Error"))
|
||||
{
|
||||
let code = err.get("Code").and_then(Value::as_str).unwrap_or_default();
|
||||
if is_auth_error_code(code) {
|
||||
let message = err
|
||||
.get("Message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("signature rejected")
|
||||
.to_string();
|
||||
return JimengVerifyOutcome::AuthFailed {
|
||||
code: code.to_string(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
return JimengVerifyOutcome::Authenticated;
|
||||
}
|
||||
if value.get("code").and_then(Value::as_i64).is_some() {
|
||||
return JimengVerifyOutcome::Authenticated;
|
||||
}
|
||||
}
|
||||
if (200..300).contains(&http_status) {
|
||||
return JimengVerifyOutcome::Authenticated;
|
||||
}
|
||||
JimengVerifyOutcome::Indeterminate {
|
||||
detail: format!("HTTP {http_status}"),
|
||||
}
|
||||
}
|
||||
|
||||
use self::types::VideoRequest;
|
||||
|
||||
/// Errors produced by the Jimeng provider.
|
||||
@@ -88,6 +181,58 @@ impl JimengProvider {
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Build a provider from an explicit credential set (the site/account layer
|
||||
/// path — each jimeng "connection" carries its own AK/SK). Unlike
|
||||
/// [`Self::from_config`] this is not gated on the global `enabled` switch:
|
||||
/// it exists so the admin console can validate and store credentials before
|
||||
/// the provider is turned on. `region`/`host` fall back to the Volcengine
|
||||
/// defaults when blank.
|
||||
pub fn from_credentials(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
region: Option<&str>,
|
||||
host: Option<&str>,
|
||||
session_token: Option<&str>,
|
||||
) -> Result<Self, JimengError> {
|
||||
if access_key.trim().is_empty() {
|
||||
return Err(JimengError::Invalid("access key is empty".into()));
|
||||
}
|
||||
if secret_key.trim().is_empty() {
|
||||
return Err(JimengError::Invalid("secret key is empty".into()));
|
||||
}
|
||||
let mut client = VisualClient::new(access_key.to_string(), secret_key.to_string());
|
||||
if let Some(region) = region.map(str::trim).filter(|r| !r.is_empty()) {
|
||||
client = client.with_region(region.to_string());
|
||||
}
|
||||
if let Some(host) = host.map(str::trim).filter(|h| !h.is_empty()) {
|
||||
client = client.with_host(host.to_string());
|
||||
}
|
||||
if let Some(token) = session_token.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
client = client.with_security_token(token.to_string());
|
||||
}
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Probe whether the configured credentials can sign a request the
|
||||
/// Volcengine gateway accepts. Issues a single GetResult call for a sentinel
|
||||
/// task id: the gateway checks the V4 signature before the business layer,
|
||||
/// so a valid AK/SK passes (business layer reports "not found") and a bad
|
||||
/// one is rejected with an auth-class error. No generation is triggered and
|
||||
/// nothing is stored. See [`classify_verify_response`].
|
||||
pub async fn verify(&self, http: &reqwest::Client) -> JimengVerifyOutcome {
|
||||
let body = build_get_result_body(JimengCapability::Image, VERIFY_SENTINEL_TASK_ID);
|
||||
match self.post_signed_raw(http, ACTION_GET_RESULT, &body).await {
|
||||
Ok((status, text)) => classify_verify_response(status, &text),
|
||||
Err(JimengError::Sign(detail)) => JimengVerifyOutcome::AuthFailed {
|
||||
code: "SigningError".into(),
|
||||
message: detail,
|
||||
},
|
||||
Err(other) => JimengVerifyOutcome::Indeterminate {
|
||||
detail: other.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a request body for `capability` and return the Volcengine task id.
|
||||
pub async fn submit(
|
||||
&self,
|
||||
@@ -133,6 +278,20 @@ impl JimengProvider {
|
||||
action: &str,
|
||||
body: &Value,
|
||||
) -> Result<String, JimengError> {
|
||||
self.post_signed_raw(http, action, body)
|
||||
.await
|
||||
.map(|(_status, text)| text)
|
||||
}
|
||||
|
||||
/// Like [`Self::post_signed`] but also returns the HTTP status code, which
|
||||
/// the credential probe ([`Self::verify`]) needs to distinguish a gateway
|
||||
/// rejection from a business response.
|
||||
async fn post_signed_raw(
|
||||
&self,
|
||||
http: &reqwest::Client,
|
||||
action: &str,
|
||||
body: &Value,
|
||||
) -> Result<(u16, String), JimengError> {
|
||||
let prepared = self
|
||||
.client
|
||||
.build_signed_request(action, API_VERSION, body)
|
||||
@@ -154,9 +313,12 @@ impl JimengProvider {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JimengError::Http(e.to_string()))?;
|
||||
resp.text()
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| JimengError::Http(e.to_string()))
|
||||
.map_err(|e| JimengError::Http(e.to_string()))?;
|
||||
Ok((status, text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +563,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_credentials_requires_both_keys() {
|
||||
assert!(matches!(
|
||||
JimengProvider::from_credentials("", "sk", None, None, None),
|
||||
Err(JimengError::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
JimengProvider::from_credentials("ak", " ", None, None, None),
|
||||
Err(JimengError::Invalid(_))
|
||||
));
|
||||
assert!(JimengProvider::from_credentials("ak", "sk", None, None, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_classifies_signature_error_as_auth_failed() {
|
||||
// Gateway rejects the V4 signature before the business layer.
|
||||
let body = r#"{"ResponseMetadata":{"RequestId":"x","Error":{"Code":"SignatureDoesNotMatch","Message":"The request signature we calculated does not match"}}}"#;
|
||||
match classify_verify_response(403, body) {
|
||||
JimengVerifyOutcome::AuthFailed { code, .. } => {
|
||||
assert_eq!(code, "SignatureDoesNotMatch");
|
||||
}
|
||||
other => panic!("expected AuthFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_accepts_both_frame_counts() {
|
||||
for frames in ALLOWED_FRAMES {
|
||||
@@ -481,4 +668,57 @@ mod tests {
|
||||
assert!(cfg.video_poll_interval_secs > cfg.image_poll_interval_secs);
|
||||
assert!(cfg.video_timeout_secs > cfg.image_timeout_secs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_classifies_invalid_access_key_as_auth_failed() {
|
||||
let body = r#"{"ResponseMetadata":{"Error":{"Code":"InvalidAccessKeyId","Message":"bad ak"}}}"#;
|
||||
assert!(matches!(
|
||||
classify_verify_response(403, body),
|
||||
JimengVerifyOutcome::AuthFailed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_treats_non_auth_gateway_error_as_authenticated() {
|
||||
// Signature accepted; the gateway only complains about our sentinel
|
||||
// task id shape. That still proves the credentials are valid.
|
||||
let body = r#"{"ResponseMetadata":{"Error":{"Code":"InvalidParameter","Message":"task_id invalid"}}}"#;
|
||||
assert_eq!(
|
||||
classify_verify_response(400, body),
|
||||
JimengVerifyOutcome::Authenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_treats_business_envelope_as_authenticated() {
|
||||
// The CV actions return a flat `{code, message, data}` envelope; reaching
|
||||
// it means the gateway routed us past signature validation.
|
||||
let body = r#"{"code":50404,"message":"task not found","data":null}"#;
|
||||
assert_eq!(
|
||||
classify_verify_response(200, body),
|
||||
JimengVerifyOutcome::Authenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_falls_back_to_http_status_when_unparseable() {
|
||||
assert_eq!(
|
||||
classify_verify_response(200, "not json"),
|
||||
JimengVerifyOutcome::Authenticated
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_verify_response(500, "<html>oops</html>"),
|
||||
JimengVerifyOutcome::Indeterminate { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_error_code_matcher_is_case_insensitive() {
|
||||
assert!(is_auth_error_code("SignatureDoesNotMatch"));
|
||||
assert!(is_auth_error_code("InvalidCredential"));
|
||||
assert!(is_auth_error_code("AccessDenied"));
|
||||
assert!(is_auth_error_code("InvalidSecurityToken"));
|
||||
assert!(!is_auth_error_code("InvalidParameter"));
|
||||
assert!(!is_auth_error_code("QuotaExceeded"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1861,6 +1861,12 @@ export const api = {
|
||||
accessToken: string;
|
||||
platformUserId?: number;
|
||||
credentialMode?: "auto" | "session" | "apikey";
|
||||
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||
// plus optional region/host/session token instead of a single Bearer token.
|
||||
secretKey?: string;
|
||||
region?: string;
|
||||
host?: string;
|
||||
sessionToken?: string;
|
||||
}) =>
|
||||
request("/api/accounts/verify-token", {
|
||||
method: "POST",
|
||||
|
||||
+149
-13
@@ -94,6 +94,12 @@ function createTokenForm(credentialMode: "session" | "apikey" = "session") {
|
||||
tokenExpiresAt: "",
|
||||
credentialMode,
|
||||
skipModelFetch: false,
|
||||
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||
// plus optional region/host/session token. accessToken doubles as the AK.
|
||||
jimengSecretKey: "",
|
||||
jimengRegion: "",
|
||||
jimengHost: "",
|
||||
jimengSessionToken: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,6 +296,8 @@ export default function Accounts() {
|
||||
);
|
||||
const isSub2ApiSelected =
|
||||
(selectedTokenSite?.platform || "").toLowerCase() === "sub2api";
|
||||
const isJimengSelected =
|
||||
(selectedTokenSite?.platform || "").toLowerCase() === "jimeng";
|
||||
const activeAddCredentialMode =
|
||||
activeSegment === "apikey" ? "apikey" : "session";
|
||||
const createIntentPreset = useMemo(
|
||||
@@ -456,7 +464,7 @@ export default function Accounts() {
|
||||
|
||||
const handleVerifyToken = async () => {
|
||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||
if (isBatchApiKeyInput) {
|
||||
if (!isJimengSelected && isBatchApiKeyInput) {
|
||||
toast.info(
|
||||
`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`,
|
||||
);
|
||||
@@ -473,10 +481,20 @@ export default function Accounts() {
|
||||
? parseInt(tokenForm.platformUserId)
|
||||
: undefined,
|
||||
credentialMode,
|
||||
...(isJimengSelected
|
||||
? {
|
||||
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||
region: tokenForm.jimengRegion.trim() || undefined,
|
||||
host: tokenForm.jimengHost.trim() || undefined,
|
||||
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
setVerifyResult(result);
|
||||
if (result.success) {
|
||||
if (result.tokenType === "apikey") {
|
||||
if (result.tokenType === "jimeng") {
|
||||
toast.success("即梦签名验证成功(AK/SK 可用)");
|
||||
} else if (result.tokenType === "apikey") {
|
||||
toast.success(
|
||||
`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`,
|
||||
);
|
||||
@@ -500,7 +518,12 @@ export default function Accounts() {
|
||||
|
||||
const handleTokenAdd = async () => {
|
||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||
if (isJimengSelected && !verifyResult?.success) {
|
||||
toast.error("请先验证即梦 AK/SK 成功后再添加连接");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isJimengSelected &&
|
||||
!isBatchApiKeyInput &&
|
||||
!verifyResult?.success &&
|
||||
!tokenForm.skipModelFetch
|
||||
@@ -516,7 +539,8 @@ export default function Accounts() {
|
||||
siteId: tokenForm.siteId,
|
||||
username: tokenForm.username.trim() || undefined,
|
||||
accessToken: tokenForm.accessToken,
|
||||
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
|
||||
accessTokens:
|
||||
!isJimengSelected && isBatchApiKeyInput ? parsedApiKeys : undefined,
|
||||
platformUserId: tokenForm.platformUserId
|
||||
? parseInt(tokenForm.platformUserId)
|
||||
: undefined,
|
||||
@@ -530,7 +554,23 @@ export default function Accounts() {
|
||||
: undefined,
|
||||
credentialMode,
|
||||
skipModelFetch: tokenForm.skipModelFetch,
|
||||
...(isJimengSelected
|
||||
? {
|
||||
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||
region: tokenForm.jimengRegion.trim() || undefined,
|
||||
host: tokenForm.jimengHost.trim() || undefined,
|
||||
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Jimeng connections never join the OpenAI route table: no model sync,
|
||||
// no route rebuild. Just close, toast, refresh and bail.
|
||||
if (isJimengSelected) {
|
||||
closeAddPanel();
|
||||
toast.success("即梦连接已添加(AK/SK 已保存,SK 已掩码)");
|
||||
load(true);
|
||||
return;
|
||||
}
|
||||
const createdAccountIds = result?.batch
|
||||
? Array.isArray(result?.data)
|
||||
? result.data
|
||||
@@ -1510,14 +1550,18 @@ export default function Accounts() {
|
||||
|
||||
const canAddVerifiedConnection = Boolean(
|
||||
verifyResult?.success &&
|
||||
((activeSegment === "apikey" && verifyResult.tokenType === "apikey") ||
|
||||
((activeSegment === "apikey" &&
|
||||
(verifyResult.tokenType === "apikey" ||
|
||||
verifyResult.tokenType === "jimeng")) ||
|
||||
(activeSegment === "session" && verifyResult.tokenType === "session")),
|
||||
);
|
||||
const canSubmitApiKeyConnection =
|
||||
activeSegment === "apikey"
|
||||
? isBatchApiKeyInput ||
|
||||
canAddVerifiedConnection ||
|
||||
!!tokenForm.skipModelFetch
|
||||
? isJimengSelected
|
||||
? canAddVerifiedConnection
|
||||
: isBatchApiKeyInput ||
|
||||
canAddVerifiedConnection ||
|
||||
!!tokenForm.skipModelFetch
|
||||
: canAddVerifiedConnection;
|
||||
const accountsListBusy =
|
||||
!loaded ||
|
||||
@@ -2449,7 +2493,11 @@ export default function Accounts() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="粘贴 API Key"
|
||||
placeholder={
|
||||
isJimengSelected
|
||||
? "粘贴 Access Key(AK)"
|
||||
: "粘贴 API Key"
|
||||
}
|
||||
value={tokenForm.accessToken}
|
||||
onChange={(e) => {
|
||||
setTokenForm((f) => ({
|
||||
@@ -2466,7 +2514,67 @@ export default function Accounts() {
|
||||
resize: "none" as const,
|
||||
}}
|
||||
/>
|
||||
{parsedApiKeys.length > 0 && (
|
||||
{isJimengSelected && (
|
||||
<>
|
||||
<input
|
||||
placeholder="Secret Key(SK,必填)"
|
||||
type="password"
|
||||
value={tokenForm.jimengSecretKey}
|
||||
onChange={(e) => {
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengSecretKey: e.target.value,
|
||||
credentialMode: "apikey",
|
||||
}));
|
||||
setVerifyResult(null);
|
||||
}}
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input
|
||||
placeholder="region(默认 cn-north-1)"
|
||||
value={tokenForm.jimengRegion}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengRegion: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="host(默认 visual.volcengineapi.com)"
|
||||
value={tokenForm.jimengHost}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengHost: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="session token(STS,可选)"
|
||||
value={tokenForm.jimengSessionToken}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengSessionToken: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<div
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
即梦走火山 V4 签名(非 OpenAI 协议):填 AK/SK
|
||||
后点「验证」做签名连通性探测,不会调用 /models,也不进路由表。SK
|
||||
保存后以 •••• 掩码回显。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isJimengSelected && parsedApiKeys.length > 0 && (
|
||||
<div
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
@@ -2476,9 +2584,12 @@ export default function Accounts() {
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
支持换行、空格、逗号批量粘贴多个 API Key。
|
||||
</div>
|
||||
{!isJimengSelected && (
|
||||
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
支持换行、空格、逗号批量粘贴多个 API Key。
|
||||
</div>
|
||||
)}
|
||||
{!isJimengSelected && (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
@@ -2501,6 +2612,8 @@ export default function Accounts() {
|
||||
若站点要求 New-Api-User / User-ID,请在这里提前填写。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isJimengSelected && (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -2524,6 +2637,25 @@ export default function Accounts() {
|
||||
/>
|
||||
<span>跳过模型验证(直接添加 API Key)</span>
|
||||
</label>
|
||||
)}
|
||||
{verifyResult &&
|
||||
verifyResult.success &&
|
||||
verifyResult.tokenType === "jimeng" && (
|
||||
<div className="alert alert-info animate-scale-in">
|
||||
<div className="alert-title">
|
||||
即梦签名验证成功(AK/SK 可用)
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
火山网关已接受 V4 签名。保存后 SK 将以 •••• 掩码回显。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{verifyResult &&
|
||||
verifyResult.success &&
|
||||
verifyResult.tokenType === "apikey" && (
|
||||
@@ -2610,7 +2742,9 @@ export default function Accounts() {
|
||||
verifying ||
|
||||
!tokenForm.siteId ||
|
||||
!tokenForm.accessToken ||
|
||||
isBatchApiKeyInput
|
||||
(isJimengSelected
|
||||
? !tokenForm.jimengSecretKey.trim()
|
||||
: isBatchApiKeyInput)
|
||||
}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
@@ -2623,6 +2757,8 @@ export default function Accounts() {
|
||||
<span className="spinner spinner-sm" />
|
||||
验证中...
|
||||
</>
|
||||
) : isJimengSelected ? (
|
||||
"验证 AK/SK 签名"
|
||||
) : isBatchApiKeyInput ? (
|
||||
"批量添加时校验"
|
||||
) : (
|
||||
|
||||
+19
-2
@@ -707,6 +707,7 @@ const SITE_PLATFORM_OPTIONS = [
|
||||
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
||||
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
||||
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||
{ value: 'jimeng', label: '即梦 (火山视觉)', description: '火山引擎即梦 AIGC,V4 签名调用,异步生成(无 /models 探测)' },
|
||||
];
|
||||
|
||||
export default function Sites() {
|
||||
@@ -1942,7 +1943,21 @@ export default function Sites() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({ ...prev, platform: value }));
|
||||
setForm((prev) => {
|
||||
// Jimeng's real requests carry Action/Version query params the
|
||||
// URL field can't express; prefill the Volcengine endpoint as a
|
||||
// placeholder when the field is empty so operators aren't asked
|
||||
// to invent one.
|
||||
if (value === 'jimeng') {
|
||||
const currentUrl = prev.url.trim();
|
||||
return {
|
||||
...prev,
|
||||
platform: value,
|
||||
url: currentUrl ? prev.url : 'https://visual.volcengineapi.com',
|
||||
};
|
||||
}
|
||||
return { ...prev, platform: value };
|
||||
});
|
||||
setSelectedInitializationPresetId(null);
|
||||
}}
|
||||
options={platformOptions}
|
||||
@@ -1995,6 +2010,7 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{form.platform.trim() !== 'jimeng' && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -2086,6 +2102,7 @@ export default function Sites() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -2288,7 +2305,7 @@ export default function Sites() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
{isEditing && form.platform.trim() !== 'jimeng' && (
|
||||
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>刷新后自动测试请求</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
|
||||
Reference in New Issue
Block a user