diff --git a/api-summary.json b/api-summary.json
index 9c8323c..7d0b7e2 100644
--- a/api-summary.json
+++ b/api-summary.json
@@ -788,6 +788,20 @@
"description": null
}
]
+ },
+ {
+ "name": "downloadEquipmentAttachment",
+ "label": "GET /equipment/{id}/attachment/download",
+ "method": "GET",
+ "path": "/equipment/{id}/attachment/download",
+ "description": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)",
+ "attributes": [
+ {
+ "name": "id",
+ "type": "uuid",
+ "description": null
+ }
+ ]
}
]
},
diff --git a/client/src/resources/equipment/EquipmentAttachmentInput.tsx b/client/src/resources/equipment/EquipmentAttachmentInput.tsx
index a2702ed..0c840f7 100644
--- a/client/src/resources/equipment/EquipmentAttachmentInput.tsx
+++ b/client/src/resources/equipment/EquipmentAttachmentInput.tsx
@@ -3,6 +3,7 @@ import { useNotify, useRecordContext, useRefresh } from 'react-admin';
import { useCallback, useState, type ChangeEvent } from 'react';
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
+import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
export type FileAttachmentValue = {
objectKey?: string;
@@ -83,11 +84,12 @@ export function EquipmentAttachmentInput() {
Вложение (файл в MinIO)
- {att?.downloadUrl ? (
-
-
- {att.originalFileName || 'Скачать файл'}
-
+ {att?.objectKey ? (
+
+
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
) : (
diff --git a/client/src/resources/equipment/EquipmentAttachmentLink.tsx b/client/src/resources/equipment/EquipmentAttachmentLink.tsx
new file mode 100644
index 0000000..d7d5aae
--- /dev/null
+++ b/client/src/resources/equipment/EquipmentAttachmentLink.tsx
@@ -0,0 +1,31 @@
+import { Link } from '@mui/material';
+import { useNotify } from 'react-admin';
+import { downloadEquipmentAttachmentFile } from './attachmentDownload';
+
+type Props = {
+ equipmentId: string;
+ /** Текст ссылки и имя при сохранении в браузере */
+ fileName: string;
+};
+
+export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
+ const notify = useNotify();
+ const label = fileName.trim() || 'Скачать';
+
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ void downloadEquipmentAttachmentFile(equipmentId, label).catch(() =>
+ notify('Не удалось скачать файл', { type: 'warning' }),
+ );
+ }}
+ sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
+ >
+ {label}
+
+ );
+}
diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx
index 3814ade..e795377 100644
--- a/client/src/resources/equipment/EquipmentList.tsx
+++ b/client/src/resources/equipment/EquipmentList.tsx
@@ -11,6 +11,7 @@ import {
TextInput,
TopToolbar,
} from 'react-admin';
+import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared';
const equipmentFilters = [
@@ -37,7 +38,19 @@ export function EquipmentList() {
(record?.attachment?.objectKey ? '✓' : '—')}
+ render={(record: {
+ id: string;
+ attachment?: { objectKey?: string; originalFileName?: string | null } | null;
+ }) =>
+ record?.attachment?.objectKey ? (
+
+ ) : (
+ '—'
+ )
+ }
/>
diff --git a/client/src/resources/equipment/EquipmentShow.tsx b/client/src/resources/equipment/EquipmentShow.tsx
index 7003c51..076fa07 100644
--- a/client/src/resources/equipment/EquipmentShow.tsx
+++ b/client/src/resources/equipment/EquipmentShow.tsx
@@ -1,4 +1,5 @@
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
+import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared';
export function EquipmentShow() {
@@ -13,11 +14,15 @@ export function EquipmentShow() {
- record?.attachment?.downloadUrl ? (
-
- {record.attachment.originalFileName || 'Скачать'}
-
+ render={(record: {
+ id: string;
+ attachment?: { objectKey?: string | null; originalFileName?: string | null } | null;
+ }) =>
+ record?.attachment?.objectKey ? (
+
) : (
'—'
)
diff --git a/client/src/resources/equipment/attachmentDownload.ts b/client/src/resources/equipment/attachmentDownload.ts
new file mode 100644
index 0000000..d4aa2ba
--- /dev/null
+++ b/client/src/resources/equipment/attachmentDownload.ts
@@ -0,0 +1,26 @@
+import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
+import { resolveApiBaseUrl } from '../../lib/apiBase';
+
+/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */
+export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise {
+ await ensureFreshToken();
+ const token = getAccessToken();
+ const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`;
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
+ });
+ if (!response.ok) {
+ throw new Error(`Download failed: ${response.status}`);
+ }
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = objectUrl;
+ a.download = suggestedFileName || 'download';
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(objectUrl);
+}
diff --git a/domain/toir.api.dsl b/domain/toir.api.dsl
index 8e7b156..2867e05 100644
--- a/domain/toir.api.dsl
+++ b/domain/toir.api.dsl
@@ -361,6 +361,14 @@ api API.Equipment {
type uuid;
}
}
+
+ endpoint downloadEquipmentAttachment {
+ label "GET /equipment/{id}/attachment/download";
+ description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
+ attribute id {
+ type uuid;
+ }
+ }
}
api API.EquipmentStatusChange {
diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts
index 445d702..e8c18fa 100644
--- a/server/src/modules/equipment/equipment.controller.ts
+++ b/server/src/modules/equipment/equipment.controller.ts
@@ -8,6 +8,7 @@ import {
Post,
Query,
Res,
+ StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -51,6 +52,19 @@ export class EquipmentController {
return this.equipmentService.removeAttachment(id);
}
+ @Roles('viewer', 'editor', 'admin')
+ @Get(':id/attachment/download')
+ async downloadAttachment(@Param('id') id: string): Promise {
+ const { stream, contentType, fileName, contentLength } =
+ await this.equipmentService.getAttachmentDownloadStream(id);
+ const encoded = encodeURIComponent(fileName);
+ return new StreamableFile(stream, {
+ type: contentType,
+ disposition: `attachment; filename*=UTF-8''${encoded}`,
+ length: contentLength,
+ });
+ }
+
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {
diff --git a/server/src/modules/equipment/equipment.service.ts b/server/src/modules/equipment/equipment.service.ts
index 0997621..a43d04c 100644
--- a/server/src/modules/equipment/equipment.service.ts
+++ b/server/src/modules/equipment/equipment.service.ts
@@ -13,6 +13,7 @@ import { setListHeaders } from '../../common/http';
import { PrismaService } from '../../prisma/prisma.service';
import type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service';
+import type { Readable } from 'node:stream';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@@ -153,6 +154,40 @@ export class EquipmentService {
return this.toRecord(updated);
}
+ async getAttachmentDownloadStream(id: string): Promise<{
+ stream: Readable;
+ contentType: string;
+ fileName: string;
+ contentLength?: number;
+ }> {
+ if (!this.storage.isConfigured()) {
+ throw new ServiceUnavailableException(
+ 'Object storage is not configured (set S3_* environment variables).',
+ );
+ }
+
+ const item = await this.prisma.equipment.findUnique({ where: { id } });
+ if (!item) {
+ throw new NotFoundException(`Equipment ${id} not found`);
+ }
+ if (!isEquipmentAttachmentStored(item.attachment)) {
+ throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
+ }
+
+ const raw = item.attachment;
+ const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
+ const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
+ const len =
+ typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
+
+ return {
+ stream,
+ contentType: raw.contentType || contentType,
+ fileName,
+ contentLength: len,
+ };
+ }
+
async removeAttachment(id: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts
index 99bce9e..bb44241 100644
--- a/server/src/storage/storage.service.ts
+++ b/server/src/storage/storage.service.ts
@@ -2,6 +2,7 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { HttpException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
+import type { Readable } from 'node:stream';
export type StoredAttachmentMeta = {
objectKey: string;
@@ -77,6 +78,30 @@ export class StorageService {
}
}
+ async getObjectStream(objectKey: string): Promise<{
+ stream: Readable;
+ contentType: string;
+ contentLength?: number;
+ }> {
+ this.assertConfigured();
+ try {
+ const out = await this.client!.send(
+ new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }),
+ );
+ if (!out.Body) {
+ throw new ServiceUnavailableException('S3 GetObject: пустое тело ответа');
+ }
+ const stream = out.Body as Readable;
+ return {
+ stream,
+ contentType: out.ContentType || 'application/octet-stream',
+ contentLength: out.ContentLength,
+ };
+ } catch (err) {
+ throw interpretS3Failure('GetObject', err);
+ }
+ }
+
async getDownloadUrl(objectKey: string): Promise {
this.assertConfigured();
if (this.publicBaseUrl) {