diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 78fba6f7..dab6824a 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -12,9 +12,7 @@ const claudeCodeHeadersService = require('./claudeCodeHeadersService') const redis = require('../models/redis') const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const { formatDateWithTimezone } = require('../utils/dateHelper') -const runtimeAddon = require('../utils/runtimeAddon') - -const RUNTIME_EVENT_FMT_CLAUDE_REQ = 'fmtClaudeReq' +const requestIdentityService = require('./requestIdentityService') class ClaudeRelayService { constructor() { @@ -941,7 +939,7 @@ class ClaudeRelayService { return filteredHeaders } - _applyLocalRequestFormatters(body, headers, context = {}) { + _applyRequestIdentityTransform(body, headers, context = {}) { const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {} try { @@ -951,7 +949,7 @@ class ClaudeRelayService { ...context } - const result = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_CLAUDE_REQ, payload) + const result = requestIdentityService.transform(payload) if (!result || typeof result !== 'object') { return { body, headers: normalizedHeaders } } @@ -966,7 +964,7 @@ class ClaudeRelayService { return { body: nextBody, headers: nextHeaders, abortResponse } } catch (error) { - logger.warn('⚠️ 应用本地 fmtClaudeReq 插件失败:', error) + logger.warn('⚠️ 应用请求身份转换失败:', error) return { body, headers: normalizedHeaders } } } @@ -1012,7 +1010,7 @@ class ClaudeRelayService { }) } - const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, { + const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, { account, accountId, clientHeaders, @@ -1332,7 +1330,7 @@ class ClaudeRelayService { }) } - const extensionResult = this._applyLocalRequestFormatters(requestPayload, finalHeaders, { + const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, { account, accountId, accountType, diff --git a/src/services/requestIdentityService.js b/src/services/requestIdentityService.js new file mode 100644 index 00000000..48d87450 --- /dev/null +++ b/src/services/requestIdentityService.js @@ -0,0 +1,416 @@ +/** + * Request Identity Service + * + * 处理 Claude 请求的身份信息规范化: + * 1. Stainless 指纹管理 - 收集、持久化和应用 x-stainless-* 系列请求头 + * 2. User ID 规范化 - 重写 metadata.user_id,使其与真实账户保持一致 + */ + +const crypto = require('crypto') +const logger = require('../utils/logger') +const redisService = require('../models/redis') + +const SESSION_PREFIX = 'session_' +const ACCOUNT_MARKER = '_account_' +const STAINLESS_HEADER_KEYS = [ + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version' +] +const MIN_FINGERPRINT_FIELDS = 4 +const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:' + +function formatUuidFromSeed(seed) { + const digest = crypto.createHash('sha256').update(String(seed)).digest() + const bytes = Buffer.from(digest.subarray(0, 16)) + + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + const hex = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function safeParseJson(value) { + if (typeof value !== 'string' || !value.trim()) { + return null + } + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : null + } catch (error) { + return null + } +} + +function getRedisClient() { + if (!redisService || typeof redisService.getClientSafe !== 'function') { + throw new Error('requestIdentityService: Redis 服务未初始化') + } + + return redisService.getClientSafe() +} + +function hasFingerprintValues(fingerprint) { + return fingerprint && typeof fingerprint === 'object' && Object.keys(fingerprint).length > 0 +} + +function sanitizeFingerprint(source) { + if (!source || typeof source !== 'object') { + return {} + } + + const normalized = {} + const lowerCaseSource = {} + + Object.keys(source).forEach((key) => { + const value = source[key] + if (value === undefined || value === null || String(value).trim() === '') { + return + } + lowerCaseSource[key.toLowerCase()] = String(value) + }) + + STAINLESS_HEADER_KEYS.forEach((key) => { + if (lowerCaseSource[key]) { + normalized[key] = lowerCaseSource[key] + } + }) + + return normalized +} + +function collectFingerprintFromHeaders(headers) { + if (!headers || typeof headers !== 'object') { + return {} + } + + const subset = {} + + Object.keys(headers).forEach((key) => { + const lowerKey = key.toLowerCase() + if (STAINLESS_HEADER_KEYS.includes(lowerKey)) { + subset[lowerKey] = headers[key] + } + }) + + return sanitizeFingerprint(subset) +} + +function removeHeaderCaseInsensitive(target, key) { + if (!target || typeof target !== 'object') { + return + } + + const lowerKey = key.toLowerCase() + Object.keys(target).forEach((candidate) => { + if (candidate.toLowerCase() === lowerKey) { + delete target[candidate] + } + }) +} + +function applyFingerprintToHeaders(headers, fingerprint) { + if (!headers || typeof headers !== 'object') { + return headers + } + + if (!hasFingerprintValues(fingerprint)) { + return { ...headers } + } + + const nextHeaders = { ...headers } + + STAINLESS_HEADER_KEYS.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(fingerprint, key)) { + return + } + removeHeaderCaseInsensitive(nextHeaders, key) + nextHeaders[key] = fingerprint[key] + }) + + return nextHeaders +} + +function persistFingerprint(accountId, fingerprint) { + if (!accountId || !hasFingerprintValues(fingerprint)) { + return + } + + const client = getRedisClient() + const key = `${REDIS_KEY_PREFIX}${accountId}` + const serialized = JSON.stringify(fingerprint) + + const command = client.set(key, serialized, 'NX') + + if (command && typeof command.catch === 'function') { + command.catch((error) => { + logger.error(`requestIdentityService: Redis 持久化指纹失败 (${accountId}): ${error.message}`) + }) + } +} + +function getHeaderValueCaseInsensitive(headers, key) { + if (!headers || typeof headers !== 'object') { + return undefined + } + + const lowerKey = key.toLowerCase() + for (const candidate of Object.keys(headers)) { + if (candidate.toLowerCase() === lowerKey) { + return headers[candidate] + } + } + + return undefined +} + +function headersChanged(original, updated) { + if (original === updated) { + return false + } + + for (const key of STAINLESS_HEADER_KEYS) { + if ( + getHeaderValueCaseInsensitive(original, key) !== getHeaderValueCaseInsensitive(updated, key) + ) { + return true + } + } + + return false +} + +function resolveAccountId(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const account = payload.account && typeof payload.account === 'object' ? payload.account : null + const candidates = [ + payload.accountId, + payload.account_id, + payload.accountID, + account && (account.accountId || account.account_id || account.accountID), + account && (account.id || account.uuid), + account && (account.account_uuid || account.accountUuid), + account && (account.schedulerAccountId || account.scheduler_account_id) + ] + + for (const candidate of candidates) { + if (candidate === undefined || candidate === null) { + continue + } + + const stringified = String(candidate).trim() + if (stringified) { + return stringified + } + } + + return null +} + +function rewriteHeaders(headers, accountId) { + if (!headers || typeof headers !== 'object') { + return { nextHeaders: headers, changed: false } + } + + if (!accountId) { + return { nextHeaders: { ...headers }, changed: false } + } + + const workingHeaders = { ...headers } + const fingerprint = collectFingerprintFromHeaders(workingHeaders) + const fieldCount = Object.keys(fingerprint).length + + if (fieldCount < MIN_FINGERPRINT_FIELDS) { + logger.warn( + `requestIdentityService: 账号 ${accountId} 提供的 Stainless 指纹字段不足,已保持原样` + ) + return { nextHeaders: workingHeaders, changed: false } + } + + try { + persistFingerprint(accountId, fingerprint) + } catch (error) { + logger.error(`requestIdentityService: 持久化指纹失败 (${accountId}): ${error.message}`) + return { + abortResponse: { + statusCode: 500, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ error: 'fingerprint_persist_failed', message: '指纹信息持久化失败' }) + } + } + } + + const appliedHeaders = applyFingerprintToHeaders(workingHeaders, fingerprint) + const changed = headersChanged(workingHeaders, appliedHeaders) + + return { nextHeaders: appliedHeaders, changed } +} + +function normalizeAccountUuid(candidate) { + if (typeof candidate !== 'string') { + return null + } + + const trimmed = candidate.trim() + return trimmed || null +} + +function extractAccountUuid(account) { + if (!account || typeof account !== 'object') { + return null + } + + const extInfoRaw = account.extInfo + if (!extInfoRaw) { + return null + } + + const extInfoObject = typeof extInfoRaw === 'string' ? safeParseJson(extInfoRaw) : null + + if (!extInfoObject || typeof extInfoObject !== 'object') { + return null + } + + const extUuid = normalizeAccountUuid(extInfoObject.account_uuid) + return extUuid || null +} + +function rewriteUserId(body, accountId, accountUuid) { + if (!body || typeof body !== 'object') { + return { nextBody: body, changed: false } + } + + const { metadata } = body + if (!metadata || typeof metadata !== 'object') { + return { nextBody: body, changed: false } + } + + const userId = metadata.user_id + if (typeof userId !== 'string') { + return { nextBody: body, changed: false } + } + + const pivot = userId.lastIndexOf(SESSION_PREFIX) + if (pivot === -1) { + return { nextBody: body, changed: false } + } + + const prefixBeforeSession = userId.slice(0, pivot) + const sessionTail = userId.slice(pivot + SESSION_PREFIX.length) + + const seedTail = sessionTail || 'default' + const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler' + const hashed = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`) + + let normalizedPrefix = prefixBeforeSession + + if (accountUuid) { + const trimmedUuid = normalizeAccountUuid(accountUuid) + if (trimmedUuid) { + const accountIndex = normalizedPrefix.indexOf(ACCOUNT_MARKER) + + if (accountIndex === -1) { + const base = normalizedPrefix.replace(/_+$/, '') + const baseWithMarker = /_account$/.test(base) ? base : `${base}_account` + normalizedPrefix = `${baseWithMarker}_${trimmedUuid}_` + } else { + const valueStart = accountIndex + ACCOUNT_MARKER.length + let separatorIndex = normalizedPrefix.indexOf('_', valueStart) + if (separatorIndex === -1) { + separatorIndex = normalizedPrefix.length + } + + const head = normalizedPrefix.slice(0, valueStart) + let tail = '_' + + if (separatorIndex < normalizedPrefix.length) { + tail = normalizedPrefix.slice(separatorIndex) + if (/^_+$/.test(tail)) { + tail = '_' + } + } + + normalizedPrefix = `${head}${trimmedUuid}${tail}` + } + } + } + + const nextUserId = `${normalizedPrefix}${SESSION_PREFIX}${hashed}` + + if (nextUserId === userId) { + return { nextBody: body, changed: false } + } + + const nextBody = { + ...body, + metadata: { + ...metadata, + user_id: nextUserId + } + } + + return { nextBody, changed: true } +} + +/** + * 转换请求身份信息 + * @param {Object} payload - 请求载荷 + * @param {Object} payload.body - 请求体 + * @param {Object} payload.headers - 请求头 + * @param {string} payload.accountId - 账户ID + * @param {Object} payload.account - 账户对象 + * @returns {Object} 转换后的 { body, headers, abortResponse? } + */ +function transform(payload = {}) { + const currentBody = payload.body + const currentHeaders = payload.headers + + if (!payload.accountId) { + return { + body: currentBody, + headers: currentHeaders + } + } + + const accountUuid = extractAccountUuid(payload.account) + const accountIdForHeaders = resolveAccountId(payload) + + const { nextBody } = rewriteUserId(currentBody, payload.accountId, accountUuid) + const headerResult = rewriteHeaders(currentHeaders, accountIdForHeaders) + + const nextHeaders = headerResult ? headerResult.nextHeaders : currentHeaders + const abortResponse = + headerResult && headerResult.abortResponse ? headerResult.abortResponse : null + + return { + body: nextBody, + headers: nextHeaders, + abortResponse + } +} + +module.exports = { + transform, + // 导出内部函数供测试使用 + _internal: { + formatUuidFromSeed, + collectFingerprintFromHeaders, + rewriteHeaders, + rewriteUserId, + extractAccountUuid, + resolveAccountId + } +} diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 5364b05e..4808c560 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -362,7 +362,7 @@