Files
uno-click/bff/services/result.service.js
2026-05-13 14:20:41 +00:00

310 lines
9.1 KiB
JavaScript

import { env } from '../config/env.js';
import {
getGenerationByUuidForUser,
getGenerationSteps,
updateGenerationResult,
getGenerationMetaByUuid,
} from '../repositories/scenario.repository.js';
/**
* Валидация формата результата от n8n
*
* Ожидаемый формат body (массив файлов):
* {
* files: [
* {
* contentType: 'image' | 'video',
* url: string, // ссылка на файл (GET запрос)
* thumbnailUrl?: string,
* duration?: number,
* width?: number,
* height?: number,
* size?: number,
* format?: string
* }
* ]
* }
*/
function validateResultBody(body) {
const errors = [];
if (!body || typeof body !== 'object') {
errors.push('Body must be an object');
return { valid: false, errors };
}
// files: обязательный массив
if (!body.files) {
errors.push('files is required');
} else if (!Array.isArray(body.files)) {
errors.push('files must be an array');
} else if (body.files.length === 0) {
errors.push('files array cannot be empty');
} else {
// Валидация каждого файла
body.files.forEach((file, index) => {
const prefix = `files[${index}]`;
// contentType
if (!file.contentType) {
errors.push(`${prefix}.contentType is required`);
} else if (!['image', 'video'].includes(file.contentType)) {
errors.push(`${prefix}.contentType must be 'image' or 'video'`);
}
// url: обязателен
if (!file.url) {
errors.push(`${prefix}.url is required`);
} else if (typeof file.url !== 'string' || file.url.trim() === '') {
errors.push(`${prefix}.url must be a non-empty string`);
} else {
try {
new URL(file.url);
} catch {
errors.push(`${prefix}.url must be a valid URL`);
}
}
// thumbnailUrl: опционален
if (file.thumbnailUrl !== undefined && file.thumbnailUrl !== null) {
if (typeof file.thumbnailUrl !== 'string') {
errors.push(`${prefix}.thumbnailUrl must be a string`);
} else {
try {
new URL(file.thumbnailUrl);
} catch {
errors.push(`${prefix}.thumbnailUrl must be a valid URL`);
}
}
}
// duration: опционален, только для видео
if (file.duration !== undefined && file.duration !== null) {
if (typeof file.duration !== 'number') {
errors.push(`${prefix}.duration must be a number`);
} else if (file.duration < 0) {
errors.push(`${prefix}.duration must be >= 0`);
}
}
// width/height: опциональны
if (file.width !== undefined && file.width !== null) {
if (!Number.isInteger(file.width) || file.width <= 0) {
errors.push(`${prefix}.width must be a positive integer`);
}
}
if (file.height !== undefined && file.height !== null) {
if (!Number.isInteger(file.height) || file.height <= 0) {
errors.push(`${prefix}.height must be a positive integer`);
}
}
// size: опционален
if (file.size !== undefined && file.size !== null) {
if (typeof file.size !== 'number' || file.size < 0) {
errors.push(`${prefix}.size must be a non-negative number`);
}
}
// format: опционален
if (file.format !== undefined && file.format !== null) {
if (typeof file.format !== 'string' || file.format.trim() === '') {
errors.push(`${prefix}.format must be a non-empty string`);
}
}
});
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Получить результат генерации для пользователя
* Формат ответа для фронта:
* {
* generationUuid: string,
* scenarioId: string,
* scenarioName: string,
* status: 'running' | 'completed' | 'failed' | 'waiting_for_input',
* files: Array<{
* id: string,
* contentType: 'image' | 'video',
* url: string,
* thumbnailUrl?: string,
* duration?: number,
* width?: number,
* height?: number,
* size?: number,
* format?: string
* }>,
* steps: [...],
* startedAt: date,
* finishedAt: date
* }
*/
export async function getResultForUser({ userId, generationUuid }) {
const generation = await getGenerationByUuidForUser(generationUuid, userId);
if (!generation) {
return null;
}
const steps = await getGenerationSteps(generationUuid);
// Преобразуем result_payload в формат для фронта
const files = parseResultPayload(generation.result_payload);
return {
generationUuid: generation.generation_uuid,
scenarioId: generation.scenario_id,
scenarioName: generation.scenario_name,
status: generation.status,
files,
steps,
startedAt: generation.started_at,
finishedAt: generation.finished_at,
requestPayload: generation.request_payload,
};
}
/**
* Сохранить результат в БД
*/
export async function saveResultToDB(generationUuid, files) {
const resultPayload = { files };
return updateGenerationResult(generationUuid, resultPayload, 'completed');
}
/**
* Получить метаданные генерации (только scenarioId и scenarioName)
*/
export async function getGenerationMeta({ userId, generationUuid }) {
const generation = await getGenerationMetaByUuid(generationUuid, userId);
if (!generation) {
return null;
}
return {
scenarioId: generation.scenario_id,
scenarioName: generation.scenario_name,
};
}
/**
* Парсит result_payload в массив файлов
*/
function parseResultPayload(resultPayload) {
if (!resultPayload || !resultPayload.files) {
return [];
}
return resultPayload.files.map((file, index) => ({
id: `file-${index}`,
contentType: file.contentType,
url: file.url,
thumbnailUrl: file.thumbnailUrl,
duration: file.duration,
width: file.width,
height: file.height,
size: file.size,
format: file.format,
}));
}
/**
* Обработать результат от n8n
*/
export async function processResultFromN8n({ generationUuid, userId, scenarioId, resultBody }) {
// Проверяем, что generation существует и принадлежит пользователю
const generation = await getGenerationByUuidForUser(generationUuid, userId);
if (!generation) {
const error = new Error(`Generation '${generationUuid}' not found or does not belong to user '${userId}'`);
error.code = 'GENERATION_NOT_FOUND';
error.status = 404;
throw error;
}
// Валидация результата
const validation = validateResultBody(resultBody);
if (!validation.valid) {
const error = new Error(`Invalid result format: ${validation.errors.join(', ')}`);
error.code = 'INVALID_RESULT_FORMAT';
error.status = 400;
error.details = validation.errors;
throw error;
}
// Сохраняем результат в БД
const updatedGeneration = await updateGenerationResult(generationUuid, resultBody, 'completed');
return {
generationUuid: updatedGeneration.generation_uuid,
status: updatedGeneration.status,
files: parseResultPayload(updatedGeneration.result_payload),
};
}
/**
* Вызов n8n webhook для получения результата
* stepData - данные из response_payload шага (taskId, recordId и т.д.)
*/
export async function fetchResultFromN8n({ generationUuid, userId, scenarioId, stepData = {} }) {
// Вызываем webhook без generationUuid в пути (передаём в meta/body)
const n8nUrl = `${env.N8N_BASE_URL}/webhook/result`;
const payload = {
meta: {
generationUuid,
userId,
scenarioId,
stepData,
},
body: stepData,
};
console.log('[fetchResultFromN8n] Calling:', n8nUrl);
console.log('[fetchResultFromN8n] Payload:', JSON.stringify(payload, null, 2));
try {
const response = await fetch(n8nUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
console.log('[fetchResultFromN8n] Response status:', response.status, response.statusText);
const responseText = await response.text();
console.log('[fetchResultFromN8n] Response body:', responseText);
if (!response.ok) {
const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`);
error.code = 'N8N_WEBHOOK_ERROR';
error.status = response.status;
error.n8nResponse = responseText;
throw error;
}
const data = JSON.parse(responseText);
console.log('[fetchResultFromN8n] Parsed data:', data);
return data;
} catch (err) {
console.error('[fetchResultFromN8n] Error:', err.message);
if (err.code === 'N8N_WEBHOOK_ERROR') {
throw err;
}
const error = new Error(`Failed to call n8n webhook: ${err.message}`);
error.code = 'N8N_CONNECTION_ERROR';
error.status = 503;
throw error;
}
}