/
viktorphp
/
back-socket
Обзор
Документация
Войти
/
viktorphp
/
back-socket
Код
Запросы
0
Задачи
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/services/diary/diary.service.ts
468 строк
19 KB
Иванов Виктор Евгеньевич
[feature-diary]: добавить сохранение отредактированного OCR
12 апр 2026, 20:08
12 апр 2026, 20:08
1c5d24c
Код
Авторство
О чём код?
import { Injectable, NotFoundException, ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { DiaryEntry } from '../../entities/diary/diary-entry.entity'; import { DiaryFile, OcrStatus } from '../../entities/diary/diary-file.entity'; import { StorageService } from '../storage/storage.service'; import { OcrService } from '../ocr/ocr.service'; import { DiaryGateway } from '../../gateways/diary/diary.gateway'; import { v4 as uuidv4 } from 'uuid'; /** * DiaryService — основной сервис для работы с дневником. * * Отвечает за: * - CRUD записей дневника (DiaryEntry) * - Загрузку и удаление файлов (DiaryFile + Yandex Object Storage) * - Запуск и обработку результатов OCR асинхронно * - Уведомление клиента о завершении OCR через WebSocket (DiaryGateway) */ @Injectable() export class DiaryService { constructor( @InjectRepository(DiaryEntry) private readonly entryRepo: Repository<DiaryEntry>, @InjectRepository(DiaryFile) private readonly fileRepo: Repository<DiaryFile>, private readonly storageService: StorageService, private readonly ocrService: OcrService, private readonly diaryGateway: DiaryGateway, ) {} // ───────────────────────────────────────────── // CRUD записей дневника // ───────────────────────────────────────────── /** * Создаёт новую запись дневника. * Если вместе с записью переданы файлы — загружает их в Object Storage. * * @param userId - ID пользователя-владельца * @param date - Дата записи в формате YYYY-MM-DD * @param text - Текст заметки (опционально) * @param files - Массив файлов из multer (опционально) * @returns Созданная запись со списком прикреплённых файлов */ async createEntry( userId: string, date: string, text?: string, files?: Express.Multer.File[], ): Promise<DiaryEntry> { // Создаём запись в БД const entry = this.entryRepo.create({ userId, date, text }); const savedEntry = await this.entryRepo.save(entry); // Загружаем файлы, если они были переданы if (files && files.length > 0) { await this.uploadFiles(userId, savedEntry.id, files); } // Возвращаем запись со всеми файлами (eager: true подтянет их автоматически) return this.entryRepo.findOne({ where: { id: savedEntry.id } }); } /** * Возвращает список записей пользователя с фильтрацией по дате. * * @param userId - ID пользователя * @param from - Начало диапазона дат (YYYY-MM-DD), опционально * @param to - Конец диапазона дат (YYYY-MM-DD), опционально * @param date - Конкретная дата (YYYY-MM-DD), опционально (приоритет над from/to) * @returns Массив записей, отсортированных по дате создания (новые первые) */ async getEntries( userId: string, from?: string, to?: string, date?: string, ): Promise<DiaryEntry[]> { // Базовое условие — всегда фильтруем по userId const where: any = { userId }; if (date) { // Запрос конкретной даты — точное совпадение where.date = date; } else if (from && to) { // Диапазон дат — Between включает обе границы where.date = Between(from, to); } else if (from) { where.date = MoreThanOrEqual(from); } else if (to) { where.date = LessThanOrEqual(to); } return this.entryRepo.find({ where, order: { createdAt: 'DESC' }, }); } /** * Возвращает одну запись по ID. * Проверяет, что запись принадлежит запрашивающему пользователю. * * @param entryId - ID записи * @param userId - ID пользователя (для проверки владельца) * @returns Запись со списком файлов * @throws NotFoundException если запись не найдена * @throws ForbiddenException если запись принадлежит другому пользователю */ async getEntry(entryId: string, userId: string): Promise<DiaryEntry> { const entry = await this.entryRepo.findOne({ where: { id: entryId } }); if (!entry) { throw new NotFoundException(`Запись дневника ${entryId} не найдена`); } if (entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этой записи'); } return entry; } /** * Обновляет текст записи дневника. * * @param entryId - ID записи * @param userId - ID пользователя (для проверки владельца) * @param text - Новый текст заметки * @returns Обновлённая запись */ async updateEntry( entryId: string, userId: string, text: string, ): Promise<DiaryEntry> { const entry = await this.getEntry(entryId, userId); entry.text = text; return this.entryRepo.save(entry); } /** * Удаляет запись дневника вместе со всеми прикреплёнными файлами. * Файлы удаляются из Object Storage перед удалением записи из БД. * * @param entryId - ID записи * @param userId - ID пользователя (для проверки владельца) */ async deleteEntry(entryId: string, userId: string): Promise<void> { const entry = await this.getEntry(entryId, userId); // Удаляем каждый файл из Object Storage for (const file of entry.files) { await this.storageService.delete(file.storageKey).catch((err) => { // Логируем ошибку, но не прерываем удаление записи — // даже если файл уже не существует в хранилище, запись должна удалиться console.error( `Ошибка удаления файла из хранилища (${file.storageKey}):`, err, ); }); } await this.entryRepo.remove(entry); } // ───────────────────────────────────────────── // Работа с файлами // ───────────────────────────────────────────── /** * Загружает файлы в Object Storage и сохраняет метаданные в БД. * Путь к файлу в хранилище: diary/{userId}/{entryId}/{uuid}-{originalname} * * @param userId - ID пользователя (используется в пути хранилища) * @param entryId - ID записи дневника * @param files - Массив файлов из multer * @returns Массив созданных записей DiaryFile */ async uploadFiles( userId: string, entryId: string, files: Express.Multer.File[], ): Promise<DiaryFile[]> { const savedFiles: DiaryFile[] = []; for (const file of files) { // multer декодирует имя файла как latin1, браузер отправляет UTF-8 — // перекодируем обратно, чтобы кириллица и другие символы отображались корректно const originalName = Buffer.from(file.originalname, 'latin1').toString( 'utf8', ); // Формируем уникальный ключ файла в Object Storage // uuid в начале имени гарантирует уникальность даже для одинаковых имён const storageKey = `diary/${userId}/${entryId}/${uuidv4()}-${originalName}`; // Загружаем файл в хранилище await this.storageService.upload(storageKey, file.buffer, file.mimetype); // Сохраняем метаданные файла в БД const diaryFile = this.fileRepo.create({ entryId, originalName, mimeType: file.mimetype, storageKey, }); const saved = await this.fileRepo.save(diaryFile); savedFiles.push(saved); } return savedFiles; } /** * Заменяет файл в записи дневника. * Атомарная операция: удаляет старый файл из Object Storage, * загружает новый, обновляет метаданные в БД. * Статус OCR сбрасывается в 'none', так как содержимое файла изменилось. * * @param fileId - ID заменяемого файла * @param userId - ID пользователя (для проверки владельца) * @param newFile - Новый файл из multer * @returns Обновлённая запись DiaryFile с новыми метаданными */ async replaceFile( fileId: string, userId: string, newFile: Express.Multer.File, ): Promise<DiaryFile> { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: ['entry'], }); if (!file) { throw new NotFoundException(`Файл ${fileId} не найден`); } if (file.entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этому файлу'); } // Удаляем старый файл из Object Storage. // Ключ берём из БД — он был сформирован при первой загрузке. await this.storageService.delete(file.storageKey); // multer декодирует имя файла как latin1 — перекодируем в UTF-8 const newOriginalName = Buffer.from( newFile.originalname, 'latin1', ).toString('utf8'); // Формируем новый уникальный ключ для нового файла. // Используем тот же путь diary/{userId}/{entryId}/..., но новый uuid и имя. const newStorageKey = `diary/${userId}/${file.entryId}/${uuidv4()}-${newOriginalName}`; // Загружаем новый файл в Object Storage await this.storageService.upload( newStorageKey, newFile.buffer, newFile.mimetype, ); // Обновляем метаданные в БД: // - новое имя, тип и ключ хранилища // - сбрасываем OCR, так как файл изменился file.originalName = newOriginalName; file.mimeType = newFile.mimetype; file.storageKey = newStorageKey; file.ocrStatus = OcrStatus.NONE; file.ocrText = null; file.ocrError = null; return this.fileRepo.save(file); } /** * Удаляет файл из Object Storage и из БД. * * @param fileId - ID файла * @param userId - ID пользователя (для проверки, что файл принадлежит ему) */ async deleteFile(fileId: string, userId: string): Promise<void> { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: ['entry'], // подгружаем запись, чтобы проверить владельца }); if (!file) { throw new NotFoundException(`Файл ${fileId} не найден`); } if (file.entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этому файлу'); } // Удаляем из Object Storage await this.storageService.delete(file.storageKey); // Удаляем из БД await this.fileRepo.remove(file); } /** * Возвращает временную подписанную ссылку для скачивания файла. * Ссылка действительна 1 час. * * @param fileId - ID файла * @param userId - ID пользователя (для проверки владельца) * @returns URL для скачивания файла */ async getFileUrl(fileId: string, userId: string): Promise<string> { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: ['entry'], }); if (!file) { throw new NotFoundException(`Файл ${fileId} не найден`); } if (file.entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этому файлу'); } return this.storageService.getSignedUrl(file.storageKey); } // ───────────────────────────────────────────── // OCR распознавание // ───────────────────────────────────────────── /** * Запускает OCR-распознавание для файла асинхронно. * * Метод сразу возвращает управление (статус 'pending'), * а распознавание продолжается в фоне. * По завершении — отправляет результат клиенту через WebSocket (DiaryGateway). * * @param fileId - ID файла для распознавания * @param userId - ID пользователя (для проверки владельца и отправки WS-события) * @throws NotFoundException если файл не найден * @throws ForbiddenException если файл не принадлежит пользователю * @throws Error если MIME-тип файла не поддерживается OCR */ async startOcr(fileId: string, userId: string): Promise<void> { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: ['entry'], }); if (!file) { throw new NotFoundException(`Файл ${fileId} не найден`); } if (file.entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этому файлу'); } if (!this.ocrService.isSupportedMimeType(file.mimeType)) { throw new Error( `Файл типа ${file.mimeType} не поддерживается для OCR. ` + 'Поддерживаются: изображения (jpeg, png, gif, bmp, tiff, webp) и PDF.', ); } // Ставим статус 'pending' — клиент увидит его немедленно file.ocrStatus = OcrStatus.PENDING; await this.fileRepo.save(file); // Запускаем распознавание в фоне, не блокируя ответ клиенту. // setImmediate откладывает выполнение до следующей итерации event loop — // HTTP-ответ успеет уйти до начала тяжёлой операции. setImmediate(() => this.processOcr(file.id, userId)); } /** * Обновляет OCR-текст файла вручную (после редактирования пользователем). * * @param fileId - ID файла * @param userId - ID пользователя (для проверки владельца) * @param ocrText - новый текст */ async updateOcrText( fileId: string, userId: string, ocrText: string, ): Promise<DiaryFile> { const file = await this.fileRepo.findOne({ where: { id: fileId }, relations: ['entry'], }); if (!file) { throw new NotFoundException(`Файл ${fileId} не найден`); } if (file.entry.userId !== userId) { throw new ForbiddenException('Нет доступа к этому файлу'); } file.ocrText = ocrText; return this.fileRepo.save(file); } /** * Выполняет OCR-распознавание в фоне. * Вызывается из startOcr через setImmediate. * * @param fileId - ID файла * @param userId - ID пользователя (нужен для WebSocket-уведомления) */ private async processOcr(fileId: string, userId: string): Promise<void> { // Перечитываем файл из БД на случай если что-то изменилось const file = await this.fileRepo.findOne({ where: { id: fileId } }); if (!file) return; try { // Скачиваем файл из Object Storage для передачи в OCR const buffer = await this.storageService.download(file.storageKey); // Отправляем в Yandex Vision OCR и получаем распознанный текст const ocrText = await this.ocrService.recognizeText( buffer, file.mimeType, ); // Сохраняем результат в БД file.ocrStatus = OcrStatus.DONE; file.ocrText = ocrText; file.ocrError = null; await this.fileRepo.save(file); // Уведомляем клиента через WebSocket this.diaryGateway.emitOcrComplete(userId, { fileId: file.id, ocrStatus: 'done', ocrText, }); } catch (error) { console.error(`OCR ошибка для файла ${fileId}:`, error); // Сохраняем статус ошибки и сообщение в БД file.ocrStatus = OcrStatus.ERROR; file.ocrError = (error instanceof Error ? error.message : String(error)) ?? 'Неизвестная ошибка OCR'; await this.fileRepo.save(file); // Уведомляем клиента об ошибке через WebSocket this.diaryGateway.emitOcrComplete(userId, { fileId: file.id, ocrStatus: 'error', ocrError: file.ocrError, }); } } }