feat(jimeng-ui): record Jimeng connections via sites/accounts (方案甲)

Onboard Jimeng (火山视觉) as a `platform = "jimeng"` entry in the existing
sites/accounts console, reusing the CRUD/list/status UI while branching every
OpenAI-specific path so no other platform is affected. Jimeng is not
OpenAI-compatible (Volcengine V4 HMAC signing, async two-step generation, no
`/models` endpoint), so each branch keys off the single source of truth
`routes::admin::accounts::is_jimeng_platform` (case-insensitive).

Backend (src/routes/admin, src/services/jimeng):
- create_account → create_jimeng_account: AK lands in accounts.access_token; SK
  + region/host/sessionToken nest under extra_config.jimeng.*; credentialMode
  forced to "apikey". No spawn_route_rebuild — Jimeng never enters the route
  table.
- Secret protection: SK/sessionToken masked with •••• on list/get/update
  responses (mask_account_extra_config); update_account rejects a masked
  extra_config write-back so an edit round-trip can't clobber the real secret.
- verify_token → verify_jimeng_token: signs a sentinel GetResult probe via
  JimengProvider::from_credentials(...).verify(). The Volcengine gateway checks
  the V4 signature before the business layer, so a valid AK/SK is Authenticated
  (business layer reports "not found") and a bad one yields an auth-class Code
  (classify_verify_response / is_auth_error_code). Never calls /models, never
  triggers generation.
- build_probe_plan short-circuits Jimeng (probe-now/-stream) with a BadRequest
  pointing operators at the connection "验证" flow.
- runtimeHealth: Jimeng has no proxy_logs, so derive_jimeng_runtime_health reads
  the latest jimeng background_tasks row; account_response_value_with_jimeng
  applies it only when the site is Jimeng and ignores the override otherwise.

Frontend (src/web):
- Sites.tsx: add jimeng platform option; prefill visual.volcengineapi.com;
  hide URL pool + auto-probe sections.
- Accounts.tsx: AK + SK(required) + region/host/session fields under the API Key
  segment; "验证 AK/SK 签名" gates add; skip model sync / route rebuild.
- api.ts: verify-token payload carries secretKey/region/host/sessionToken.

Tests: unit coverage for credential nesting, secret masking, verify-response
classification, and regression guards proving non-Jimeng accounts ignore the
Jimeng health override and keep their proxy-log-derived health.

Also: stop tracking the stray root package-lock.json (repo installs via pnpm)
and gitignore it.
This commit is contained in:
boos
2026-06-23 16:09:49 +08:00
parent 294ea7ccea
commit 36a69c265f
9 changed files with 990 additions and 21 deletions
+550 -3
View File
@@ -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("尚无代理请求记录,首次调用后自动更新")
);
}
}
+1 -1
View File
@@ -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;
+8
View File
@@ -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()));
}