完善接单平台上传与物品规则
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createFileAsset, type FileAssetRow } from '../../repositories/file-asset-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||
|
||||
const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
export type UploadedFileDto = {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type UploadFileAssetInput = {
|
||||
file: Express.Multer.File | null | undefined
|
||||
scene: unknown
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
}
|
||||
|
||||
export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
const file = input.file
|
||||
if (!file?.buffer?.length) {
|
||||
throw createHttpError('请选择要上传的文件', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_required',
|
||||
})
|
||||
}
|
||||
|
||||
const maxUploadSize = getMaxUploadSizeBytes()
|
||||
if (file.buffer.length > maxUploadSize) {
|
||||
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const scene = normalizeScene(input.scene)
|
||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||
const storage = getObjectStorage()
|
||||
await storage.putObject({
|
||||
key: objectKey,
|
||||
content: file.buffer,
|
||||
contentType,
|
||||
metadata: {
|
||||
'original-filename': file.originalname,
|
||||
},
|
||||
})
|
||||
|
||||
const variants = await generateImageVariants(objectKey, file.buffer, contentType)
|
||||
let thumbnailObjectKey = ''
|
||||
let mediumObjectKey = ''
|
||||
|
||||
for (const variant of variants) {
|
||||
try {
|
||||
await storage.putObject({
|
||||
key: variant.key,
|
||||
content: variant.content,
|
||||
contentType: variant.contentType,
|
||||
metadata: {
|
||||
'source-object': objectKey,
|
||||
},
|
||||
})
|
||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||
thumbnailObjectKey = variant.key
|
||||
}
|
||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||
mediumObjectKey = variant.key
|
||||
}
|
||||
} catch {
|
||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||
}
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
originalFilename: file.originalname,
|
||||
contentType,
|
||||
sizeBytes: file.buffer.length,
|
||||
thumbnailObjectKey,
|
||||
mediumObjectKey,
|
||||
url: fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
uploaderType: String(input.uploaderType || '').trim(),
|
||||
uploaderId: String(input.uploaderId || '').trim(),
|
||||
metadataJson: JSON.stringify({
|
||||
originalMimeType: file.mimetype,
|
||||
originalSize: file.size,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
file: mapUploadedFile(asset, {
|
||||
objectKey,
|
||||
fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await getObjectStorage().getObject(candidate)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('文件不存在或暂不可访问', {
|
||||
statusCode: 404,
|
||||
errorCode: 'file_not_found',
|
||||
cause: lastError,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
const ext = path.extname(objectKey)
|
||||
const base = ext ? objectKey.slice(0, -ext.length) : objectKey
|
||||
return `${base}.${variant}.jpg`
|
||||
}
|
||||
|
||||
function mapUploadedFile(
|
||||
asset: FileAssetRow | null,
|
||||
fallback: {
|
||||
objectKey: string
|
||||
fileUrl: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
},
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
return detected
|
||||
}
|
||||
|
||||
return (
|
||||
String(mimeType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(';')[0] || 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
function detectContentType(data: Buffer) {
|
||||
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
data.length >= 8 &&
|
||||
data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (
|
||||
data.length >= 12 &&
|
||||
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
|
||||
return 'application/pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeScene(value: unknown) {
|
||||
const scene = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return scene || 'misc'
|
||||
}
|
||||
|
||||
function createObjectKey(scene: string, filename: string, contentType: string) {
|
||||
const now = new Date()
|
||||
const token = crypto.randomBytes(12).toString('hex')
|
||||
const ext = extensionForContentType(contentType) || path.extname(filename).toLowerCase() || '.bin'
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${scene}/${now.getFullYear()}/${month}/${day}/${token}${ext}`
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
if (contentType === 'image/jpeg') return '.jpg'
|
||||
if (contentType === 'image/png') return '.png'
|
||||
if (contentType === 'image/webp') return '.webp'
|
||||
if (contentType === 'application/pdf') return '.pdf'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function generateImageVariants(objectKey: string, data: Buffer, contentType: string) {
|
||||
if (!IMAGE_CONTENT_TYPES.has(contentType)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const configs = [
|
||||
{ name: IMAGE_VARIANT_THUMB, maxSide: THUMB_MAX_SIDE, quality: 76 },
|
||||
{ name: IMAGE_VARIANT_MEDIUM, maxSide: MEDIUM_MAX_SIDE, quality: 82 },
|
||||
]
|
||||
const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = []
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const content = await sharp(data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: config.maxSide,
|
||||
height: config.maxSide,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.flatten({ background: '#ffffff' })
|
||||
.jpeg({ quality: config.quality, mozjpeg: true })
|
||||
.toBuffer()
|
||||
variants.push({
|
||||
name: config.name,
|
||||
key: imageVariantKey(objectKey, config.name),
|
||||
content,
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
} catch {
|
||||
return variants
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function imageVariantFallbackKeys(objectKey: string, variant: string) {
|
||||
const keys = [imageVariantKey(objectKey, variant), objectKey]
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
function normalizeVariant(value: unknown) {
|
||||
const variant = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (variant === IMAGE_VARIANT_THUMB || variant === IMAGE_VARIANT_MEDIUM) {
|
||||
return variant
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import * as Minio from 'minio'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
type ObjectStorageConfig = {
|
||||
endpoint: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
region: string
|
||||
}
|
||||
|
||||
type StoredObject = {
|
||||
reader: Readable
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export class ObjectStorage {
|
||||
private readonly client: Minio.Client
|
||||
private readonly bucket: string
|
||||
private readonly region: string
|
||||
private bucketReady = false
|
||||
|
||||
constructor(config: ObjectStorageConfig) {
|
||||
const endpoint = normalizeStorageEndpoint(config.endpoint)
|
||||
this.bucket = String(config.bucket || '').trim()
|
||||
this.region = String(config.region || 'us-east-1').trim() || 'us-east-1'
|
||||
this.client = new Minio.Client({
|
||||
endPoint: endpoint.endPoint,
|
||||
port: endpoint.port,
|
||||
useSSL: endpoint.useSSL,
|
||||
accessKey: String(config.accessKeyId || '').trim(),
|
||||
secretKey: String(config.secretAccessKey || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
async putObject(input: {
|
||||
key: string
|
||||
content: Buffer
|
||||
contentType: string
|
||||
metadata?: Record<string, string>
|
||||
}): Promise<void> {
|
||||
await this.ensureBucketReady()
|
||||
await this.client.putObject(this.bucket, input.key, input.content, input.content.length, {
|
||||
'Content-Type': input.contentType,
|
||||
...sanitizeObjectMetadata(input.metadata),
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<StoredObject> {
|
||||
await this.ensureBucketReady()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const stat = await this.client.statObject(this.bucket, objectKey)
|
||||
const reader = await this.client.getObject(this.bucket, objectKey)
|
||||
const metadata = stat.metaData || {}
|
||||
const contentType = String(
|
||||
metadata['content-type'] ||
|
||||
metadata['Content-Type'] ||
|
||||
metadata.contentType ||
|
||||
'application/octet-stream',
|
||||
)
|
||||
|
||||
return {
|
||||
reader,
|
||||
contentType,
|
||||
size: Number(stat.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketReady(): Promise<void> {
|
||||
if (this.bucketReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const exists = await this.client.bucketExists(this.bucket)
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, this.region)
|
||||
}
|
||||
this.bucketReady = true
|
||||
}
|
||||
}
|
||||
|
||||
let storageInstance: ObjectStorage | null = null
|
||||
|
||||
export function getObjectStorage(): ObjectStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = new ObjectStorage({
|
||||
endpoint: runtimeConfig.storage.endpoint,
|
||||
bucket: runtimeConfig.storage.bucket,
|
||||
accessKeyId: runtimeConfig.storage.accessKeyId,
|
||||
secretAccessKey: runtimeConfig.storage.secretAccessKey,
|
||||
region: runtimeConfig.storage.region,
|
||||
})
|
||||
}
|
||||
|
||||
return storageInstance
|
||||
}
|
||||
|
||||
export function normalizeStorageEndpoint(rawEndpoint: unknown) {
|
||||
const endpoint = String(rawEndpoint || '').trim()
|
||||
const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`)
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80
|
||||
|
||||
return {
|
||||
endPoint: parsed.hostname,
|
||||
port,
|
||||
useSSL: parsed.protocol === 'https:',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeObjectKey(rawKey: unknown): string {
|
||||
const key = String(rawKey || '')
|
||||
.trim()
|
||||
.replace(/^\/+/, '')
|
||||
if (!key || key.includes('..') || key.includes('\\')) {
|
||||
throw createHttpError('文件 key 不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'file_key_invalid',
|
||||
})
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function sanitizeObjectMetadata(metadata: Record<string, string> | undefined) {
|
||||
if (!metadata) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata)
|
||||
.map(([key, value]) => [String(key || '').trim(), encodeURIComponent(String(value || ''))])
|
||||
.filter(([key]) => Boolean(key)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user