This commit is contained in:
2026-06-30 15:02:20 +08:00
commit 3948b5a48a
306 changed files with 77275 additions and 0 deletions
@@ -0,0 +1,375 @@
'use strict';
/**
* 即梦2角色认证 — 业务侧「素材管理」HTTP API(与官方路径一致,如 /api/business/v1/assets)。
* 网关 URL 与 Token 从 AI 配置(service_type = jimeng2_character_auth)读取;可选兼容旧版 config 中的 jimeng_material_hub / silvamux_hub。
* 参考:https://83zi.com/sd2realperson.html
*/
function loadAiJimeng2AuthRow(db) {
if (!db) return null;
try {
return db
.prepare(
`SELECT id, name, base_url, api_key FROM ai_service_configs
WHERE deleted_at IS NULL AND service_type = ? AND is_active = 1
ORDER BY is_default DESC, priority DESC, id ASC LIMIT 1`
)
.get('jimeng2_character_auth');
} catch (_) {
return null;
}
}
function legacyYamlHubSection(cfg) {
return cfg?.jimeng_material_hub || cfg?.silvamux_hub || {};
}
/** 与 routes/aiConfig.js listJimeng2MaterialAssets 一致:存库/环境变量里若含「Bearer 」前缀,hubJson 会再拼 Bearer,需先去重 */
function normalizeMaterialHubToken(raw) {
let s = String(raw || '').trim();
if (/^bearer\s+/i.test(s)) s = s.replace(/^bearer\s+/i, '').trim();
// 兼容误填为 "token" / 'token' 的场景
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
// 去除不可见空白,避免网关把 header 判定为无效
s = s.replace(/[\r\n\t\u200b-\u200d\ufeff]/g, '').trim();
// 全角空格等
s = s.replace(/\u00a0/g, ' ').trim();
return s;
}
/** 日志/报错用:首尾片段,便于与 curl 测试密钥对照(不含完整密钥) */
function tokenFingerprint(tok) {
const s = String(tok || '').trim();
if (!s) return '';
if (s.length <= 12) return '(过短)';
return `${s.slice(0, 7)}${s.slice(-4)}`;
}
/**
* 解析即梦2角色认证调用上下文(供素材注册 API 使用)
* @param {object} cfg - 应用 config.yaml
* @param {object|null} db - better-sqlite3(可选,用于读 AI 配置表)
* @param {object|null} [log] - 可选 logger;传入时打一条不含密钥原文的鉴权诊断
* @returns {{ baseUrl: string, token: string, poll_max_ms?: number, poll_interval_ms?: number, hubAuthDiag?: object }}
*/
function buildHubContext(cfg, db, log) {
const row = loadAiJimeng2AuthRow(db);
let base_url = (row?.base_url || '').toString().trim();
let token = (row?.api_key || '').toString().trim();
let poll_max_ms;
let poll_interval_ms;
if (!base_url || !token) {
const y = legacyYamlHubSection(cfg);
if (!base_url) base_url = (y.base_url || '').toString().trim();
if (!token) token = (y.token || '').toString().trim();
if (poll_max_ms == null && y.poll_max_ms != null) poll_max_ms = Number(y.poll_max_ms);
if (poll_interval_ms == null && y.poll_interval_ms != null) poll_interval_ms = Number(y.poll_interval_ms);
}
const baseUrl = (
process.env.JIMENG2_CHARACTER_AUTH_URL ||
base_url ||
process.env.JIMENG_MATERIAL_HUB_BASE_URL ||
process.env.SILVAMUX_HUB_BASE_URL ||
'https://silvamux.tingyutech.com'
)
.toString()
.trim()
.replace(/\/$/, '');
const rawTokJoined = (
process.env.JIMENG2_CHARACTER_AUTH_TOKEN ||
token ||
process.env.JIMENG_MATERIAL_HUB_TOKEN ||
process.env.SILVAMUX_HUB_TOKEN ||
process.env.HUB_TOKEN ||
''
)
.toString()
.trim();
const hadLeadingBearer = /^bearer\s+/i.test(rawTokJoined);
const tok = normalizeMaterialHubToken(rawTokJoined);
const env2 = !!String(process.env.JIMENG2_CHARACTER_AUTH_TOKEN || '').trim();
const envMat = !!String(process.env.JIMENG_MATERIAL_HUB_TOKEN || '').trim();
const envSilva = !!String(process.env.SILVAMUX_HUB_TOKEN || '').trim();
const envHub = !!String(process.env.HUB_TOKEN || '').trim();
const dbKeyLen = String(row?.api_key || '').trim().length;
let winningTokenSource = 'none';
if (env2) winningTokenSource = 'env:JIMENG2_CHARACTER_AUTH_TOKEN';
else if (String(token || '').trim()) {
winningTokenSource = dbKeyLen ? 'db:ai_service_configs(jimeng2_character_auth.api_key)' : 'yaml:jimeng_material_hub|silvamux_hub.token';
} else if (envMat) winningTokenSource = 'env:JIMENG_MATERIAL_HUB_TOKEN';
else if (envSilva) winningTokenSource = 'env:SILVAMUX_HUB_TOKEN';
else if (envHub) winningTokenSource = 'env:HUB_TOKEN';
const hubAuthDiag = {
winning_token_source: winningTokenSource,
raw_token_chars_before_normalize: rawTokJoined.length,
token_chars_in_bearer_payload: tok.length,
raw_had_leading_bearer_prefix: hadLeadingBearer,
leading_bearer_prefix_stripped: hadLeadingBearer,
env_token_flags: {
JIMENG2_CHARACTER_AUTH_TOKEN: env2,
JIMENG_MATERIAL_HUB_TOKEN: envMat,
SILVAMUX_HUB_TOKEN: envSilva,
HUB_TOKEN: envHub,
},
db_jimeng2_active_row_found: !!row,
db_config_id: row?.id ?? null,
db_config_name: row?.name ?? null,
db_api_key_field_chars: dbKeyLen,
token_fingerprint: tokenFingerprint(tok),
request_header_shape: 'Authorization: Bearer <token>',
note:
'若 raw_had_leading_bearer_prefix 为 true,旧版会发出 Bearer Bearer…;现已规范化。环境变量 JIMENG2_CHARACTER_AUTH_TOKEN 优先于数据库 api_key。请求头仅发送 Authorization(勿重复 authorization,部分 model_ark 网关会判为无效 Token)。',
};
if (log && typeof log.info === 'function') {
log.info('[JimengMaterialHub] buildHubContext 鉴权诊断(不含密钥原文)', {
hub_gateway: baseUrl,
token_present: !!tok,
...hubAuthDiag,
});
}
return { baseUrl, token: tok, poll_max_ms, poll_interval_ms, hubAuthDiag, tokenFingerprint: tokenFingerprint(tok) };
}
/** model_ark 等网关在拉取图片失败时仍返回 HTTP 200 + { error: "..." },无 id */
function hubBusinessErrorMessage(json) {
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
const err = json.error ?? json.Error;
if (typeof err === 'string' && err.trim()) return err.trim();
if (json.success === false) {
return String(json.message || json.msg || json.detail || '网关业务失败').slice(0, 2000);
}
return null;
}
function pickAssetId(obj) {
if (!obj || typeof obj !== 'object') return '';
const id = obj.id ?? obj.asset_id ?? obj.assetId;
return id != null ? String(id).trim() : '';
}
function looksLikeAssetView(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
const id = pickAssetId(obj);
if (!id) return false;
return (
obj.status != null ||
obj.asset_url != null ||
obj.asset_type != null ||
obj.url != null ||
obj.name != null
);
}
/** 兼容顶层 AssetView、{ data: {...} }、{ items: [one] } 等包裹格式 */
function unwrapMaterialHubAssetView(payload, depth = 0) {
if (depth > 5 || payload == null || typeof payload !== 'object') return null;
if (looksLikeAssetView(payload)) {
const id = pickAssetId(payload);
return {
...payload,
id,
asset_url: payload.asset_url ?? payload.assetUrl ?? null,
status: payload.status ?? null,
};
}
if (Array.isArray(payload)) {
for (const item of payload) {
const found = unwrapMaterialHubAssetView(item, depth + 1);
if (found) return found;
}
return null;
}
for (const key of ['data', 'result', 'asset', 'item', 'record', 'body', 'payload']) {
if (payload[key] != null) {
const found = unwrapMaterialHubAssetView(payload[key], depth + 1);
if (found) return found;
}
}
if (Array.isArray(payload.items) && payload.items.length === 1) {
return unwrapMaterialHubAssetView(payload.items[0], depth + 1);
}
return null;
}
async function hubJson(path, ctx, { method, body, log } = {}) {
const base = ctx.baseUrl;
const token = ctx.token;
if (!token) {
return {
ok: false,
error:
'未配置即梦2角色认证:请在「AI 配置」中添加类型为「即梦2角色认证」的一条配置,填写网关 URL 与 Token(或设置环境变量 JIMENG2_CHARACTER_AUTH_*;兼容旧 config / SILVAMUX_*',
};
}
const url = `${base}/api/business/v1${path}`;
const init = {
method: method || 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
};
if (body != null) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
if (log && typeof log.info === 'function' && method === 'POST' && path === '/assets' && body?.url) {
log.info('[JimengMaterialHub] POST /api/business/v1/assets', {
hub_gateway: base,
register_image_url: body.url,
asset_name: body.name,
asset_type: body.asset_type,
bearer_token_payload_chars: token.length,
});
}
if (log && typeof log.info === 'function' && method === 'GET' && String(path || '').startsWith('/assets')) {
log.info('[JimengMaterialHub] GET /api/business/v1/assets', {
hub_gateway: base,
path_query: String(path).includes('?') ? String(path).split('?')[1]?.slice(0, 120) : '',
bearer_token_payload_chars: token.length,
});
}
const res = await fetch(url, init);
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : {};
} catch (_) {
json = { _raw: text };
}
if (!res.ok) {
const detail = json?.detail || json?.title || json?.message || text || res.statusText;
const detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail);
if (log && typeof log.warn === 'function') {
const baseWarn = {
path,
method: method || 'GET',
httpStatus: res.status,
hub_gateway: base,
register_image_url: body && body.url ? body.url : undefined,
response_preview: detailStr.slice(0, 2000),
bearer_token_payload_chars: token.length,
};
if (res.status === 401) {
baseWarn.hint401 =
'invalid token 常见原因:密钥与网关不匹配;机器上 JIMENG2_CHARACTER_AUTH_TOKEN 等环境变量覆盖数据库配置;配置里写了「Bearer xxx」导致旧版双重 Bearer(请看 buildHubContext 日志 raw_had_leading_bearer_prefix';
}
log.warn('[JimengMaterialHub] HTTP 错误', baseWarn);
}
return { ok: false, status: res.status, error: detailStr };
}
const bizErr = hubBusinessErrorMessage(json);
if (bizErr) {
if (log && typeof log.warn === 'function') {
log.warn('[JimengMaterialHub] HTTP 200 但业务失败(常见于图片 URL 无法被网关拉取)', {
path,
method: method || 'GET',
httpStatus: res.status,
hub_gateway: base,
register_image_url: body && body.url ? body.url : undefined,
response_preview: bizErr.slice(0, 2000),
});
}
return { ok: false, status: res.status, error: bizErr };
}
return { ok: true, data: json, status: res.status };
}
async function createImageAsset(ctx, params, log) {
const name = String(params.name || 'c').replace(/\s+/g, '').slice(0, 12) || 'c';
const r = await hubJson('/assets', ctx, {
method: 'POST',
body: { url: params.url, asset_type: 'Image', name },
log,
});
if (!r.ok) return r;
const asset = unwrapMaterialHubAssetView(r.data);
if (asset?.id) return { ok: true, data: asset, status: r.status };
const keys =
r.data && typeof r.data === 'object' && !Array.isArray(r.data) ? Object.keys(r.data).join(', ') : typeof r.data;
if (log && typeof log.warn === 'function') {
log.warn('[JimengMaterialHub] POST 成功但无法解析素材 id', {
response_keys: keys,
response_preview: JSON.stringify(r.data).slice(0, 800),
});
}
return {
ok: false,
status: r.status,
error: `素材库未返回素材 id(响应字段:${keys || '空'}`,
};
}
/**
* 列出组织下素材(分页)
* @see https://83zi.com/sd2realperson.html
*/
async function listAssets(ctx, opts = {}, log) {
const limitRaw = opts.limit != null ? Number(opts.limit) : 20;
const limit = Math.min(100, Math.max(1, Number.isFinite(limitRaw) ? limitRaw : 20));
const q = new URLSearchParams();
q.set('limit', String(limit));
if (opts.cursor) q.set('cursor', String(opts.cursor));
const path = `/assets?${q.toString()}`;
return hubJson(path, ctx, { method: 'GET', log });
}
async function getAsset(ctx, assetId, log) {
const id = encodeURIComponent(String(assetId || '').trim());
if (!id) return { ok: false, error: '缺少 asset id' };
const r = await hubJson(`/assets/${id}`, ctx, { method: 'GET', log });
if (!r.ok) return r;
const asset = unwrapMaterialHubAssetView(r.data);
if (asset?.id) return { ok: true, data: asset, status: r.status };
return { ok: true, data: r.data, status: r.status };
}
async function pollAssetUntilSettled(ctx, assetId, options = {}) {
const maxMs = options.maxMs ?? 120000;
const intervalMs = options.intervalMs ?? 2000;
const log = options.log;
const deadline = Date.now() + maxMs;
let last;
while (Date.now() < deadline) {
const r = await getAsset(ctx, assetId, log);
if (!r.ok) return { ok: false, error: r.error };
last = r.data;
const st = (last && last.status) || '';
if (st === 'active' || st === 'failed') {
return { ok: true, asset: last };
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return { ok: true, asset: last, timedOut: true };
}
function hubToken(cfg, db) {
return buildHubContext(cfg, db).token;
}
module.exports = {
buildHubContext,
hubToken,
normalizeMaterialHubToken,
tokenFingerprint,
hubBusinessErrorMessage,
unwrapMaterialHubAssetView,
createImageAsset,
listAssets,
getAsset,
pollAssetUntilSettled,
};