Files
localMiniDrama/backend-node/src/services/jimengMaterialHubService.js
T
2026-06-30 15:07:31 +08:00

376 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};