v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

View File

@@ -0,0 +1,149 @@
"use strict";
const express = require("express");
const { parseRegion } = require("../../core/codex/prompts");
const { isHeic, convertHeicToJpeg } = require("../../core/images/heic");
const { sniffImageType } = require("../../core/images/sniff");
const { BadRequestError } = require("../../errors");
const { JOB_ID_PATTERN } = require("./history.routes");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createEditRoutes({ jobService, historyService, upload, logger }) {
const router = express.Router();
router.post(
"/edit",
upload.fields([
{ name: "photo", maxCount: 1 },
{ name: "reference", maxCount: 1 },
]),
async (req, res, next) => {
try {
const photoFile = req.files?.photo?.[0];
const referenceFile = req.files?.reference?.[0];
let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null;
if (sourceId) {
if (!JOB_ID_PATTERN.test(sourceId)) {
throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId).");
}
const version = historyService.getVersion(sourceId);
if (!version) {
throw new BadRequestError("Исходная версия не найдена в истории.");
}
} else if (!photoFile || !photoFile.buffer) {
throw new BadRequestError("Выберите фотографию или укажите sourceId из истории.");
}
const prompt = String(req.body?.prompt || "").trim();
if (!prompt) {
throw new BadRequestError("Введите промпт для редактирования фотографии.");
}
if (prompt.length > 1000) {
throw new BadRequestError("Промпт не должен превышать 1000 символов.");
}
let referenceId = req.body?.referenceId ? String(req.body.referenceId).trim() : null;
if (referenceId) {
if (!JOB_ID_PATTERN.test(referenceId)) {
throw new BadRequestError("Некорректный идентификатор референса (referenceId).");
}
const refVersion = historyService.getVersion(referenceId);
if (!refVersion) {
throw new BadRequestError("Референс не найден в истории.");
}
}
const region = parseRegion(req.body?.region);
if (region && !referenceFile && !referenceId) {
throw new BadRequestError("Область указана без изображения референса.");
}
// Process photo file buffer if uploaded
let inputBuffer = photoFile ? photoFile.buffer : undefined;
let inputMimetype = photoFile ? photoFile.mimetype : undefined;
let inputOriginalname = photoFile ? photoFile.originalname : undefined;
if (photoFile) {
if (isHeic(photoFile) || sniffImageType(inputBuffer) === "heic") {
inputBuffer = await convertHeicToJpeg(inputBuffer);
inputMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(inputBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженная фотография не является поддерживаемым изображением."
);
}
}
}
// Process reference file buffer if uploaded
let referenceBuffer = referenceFile ? referenceFile.buffer : undefined;
let referenceMimetype = referenceFile ? referenceFile.mimetype : undefined;
let referenceOriginalname = referenceFile ? referenceFile.originalname : undefined;
if (referenceFile) {
if (isHeic(referenceFile) || sniffImageType(referenceBuffer) === "heic") {
referenceBuffer = await convertHeicToJpeg(referenceBuffer);
referenceMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(referenceBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженный референс не является поддерживаемым изображением."
);
}
}
}
const payload = {
sourceId,
inputBuffer,
inputMimetype,
inputOriginalname,
prompt,
referenceId,
referenceBuffer,
referenceMimetype,
referenceOriginalname,
region,
};
const result = await jobService.createJob({
type: "edit",
payload,
requestId: req.id,
});
logger.info("Постановка задания edit в очередь", {
jobId: result.jobId,
sourceId,
prompt: prompt.slice(0, 80),
queuePosition: result.queuePosition,
});
res.json({
ok: true,
jobId: result.jobId,
status: result.status,
queuePosition: result.queuePosition,
});
} catch (err) {
next(err);
}
}
);
return router;
}
module.exports = {
createEditRoutes,
};