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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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 }}>