Files
iistwin/server/documents/asset.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

185 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fssync from 'fs';
import fs from 'fs/promises';
import path from 'path';
import {
organizationAssets,
fileUploads,
type OrganizationAsset,
type InsertOrganizationAsset,
} from "@shared/schema";
import * as storage from "../storage/documents.storage";
import { uploadToS3, streamFromS3, isS3Enabled, S3_BUCKET } from "../utils/s3";
import { uploadsDir } from "../utils/upload";
import { db } from "../db";
import { eq, and } from "drizzle-orm";
export interface AssetListFilters {
organizationId: number;
type?: string;
isActive?: boolean;
}
export interface MulterFile {
path: string;
originalname: string;
mimetype: string;
size: number;
}
const MIME_BY_EXT: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
};
function getMimeType(fileKeyOrName: string, fallback?: string): string {
const ext = path.extname(fileKeyOrName).slice(1).toLowerCase();
return fallback || MIME_BY_EXT[ext] || 'image/png';
}
export class AssetService {
// ── CRUD ───────────────────────────────────────────────────────────────────
async list(filters: AssetListFilters): Promise<OrganizationAsset[]> {
return storage.listOrganizationAssets(filters);
}
async getById(id: number, organizationId: number): Promise<OrganizationAsset | undefined> {
return storage.getOrganizationAsset(id, organizationId);
}
async create(data: InsertOrganizationAsset & { file?: MulterFile }): Promise<OrganizationAsset> {
let fileKey = data.fileKey;
if (data.file) {
const upload = await this.uploadAssetFile(data.file);
fileKey = upload.key;
}
if (!fileKey) {
throw new Error('Не указан файл для актива');
}
return storage.createOrganizationAsset({
...data,
fileKey,
});
}
async update(
id: number,
organizationId: number,
updates: Partial<InsertOrganizationAsset> & { file?: MulterFile },
): Promise<OrganizationAsset | undefined> {
let fileKey = updates.fileKey;
if (updates.file) {
const existing = await storage.getOrganizationAsset(id, organizationId);
if (existing?.fileKey) {
await this.deleteAssetFile(existing.fileKey);
}
const upload = await this.uploadAssetFile(updates.file);
fileKey = upload.key;
}
return storage.updateOrganizationAsset(id, organizationId, {
...updates,
...(fileKey ? { fileKey } : {}),
});
}
async delete(id: number, organizationId: number): Promise<void> {
const asset = await storage.getOrganizationAsset(id, organizationId);
if (asset?.fileKey) {
await this.deleteAssetFile(asset.fileKey);
}
await storage.deleteOrganizationAsset(id, organizationId);
}
// ── File handling ──────────────────────────────────────────────────────────
private async uploadAssetFile(file: MulterFile): Promise<{ url: string; key: string }> {
const buffer = await fs.readFile(file.path);
const name = file.originalname;
const mimeType = file.mimetype || getMimeType(name);
if (isS3Enabled) {
const result = await uploadToS3(buffer, name, mimeType);
await fs.unlink(file.path).catch(() => {});
return result;
}
// Локальный режим: копируем файл из tmp в uploads/
const ext = path.extname(name);
const key = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
const dest = path.join(uploadsDir, key);
await fs.copyFile(file.path, dest);
await fs.unlink(file.path).catch(() => {});
return { url: `/api/files/${key}`, key };
}
private async deleteAssetFile(fileKey: string): Promise<void> {
if (isS3Enabled) {
const { deleteFromS3 } = await import("../utils/s3.js");
await deleteFromS3(fileKey);
} else {
const filePath = path.join(uploadsDir, path.basename(fileKey));
await fs.unlink(filePath).catch(() => {});
}
}
// ── Base64 loading ─────────────────────────────────────────────────────────
async loadAsBase64(fileKey: string): Promise<string | null> {
const mimeType = getMimeType(fileKey);
if (isS3Enabled) {
const streamResult = await streamFromS3(fileKey);
if (!streamResult?.body) return null;
const chunks: Buffer[] = [];
for await (const chunk of streamResult.body as any) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
const filePath = path.join(uploadsDir, path.basename(fileKey));
try {
const buffer = await fs.readFile(filePath);
return `data:${mimeType};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
async loadByIdAsBase64(assetId: number, organizationId: number): Promise<string | null> {
const asset = await storage.getOrganizationAsset(assetId, organizationId);
if (!asset?.fileKey) return null;
return this.loadAsBase64(asset.fileKey);
}
async getAssetsForGeneration(
placements: Array<{ assetId: number; position: string; width?: number; height?: number; opacity?: number }>,
organizationId: number,
): Promise<Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }>> {
const result: Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }> = [];
for (const placement of placements) {
const base64 = await this.loadByIdAsBase64(placement.assetId, organizationId);
if (!base64) continue;
result.push({
assetId: placement.assetId,
base64,
position: placement.position,
width: placement.width,
height: placement.height,
opacity: placement.opacity,
});
}
return result;
}
}