完善接单平台上传与物品规则

This commit is contained in:
yml2213
2026-07-25 15:56:18 +08:00
parent f6d3f00f21
commit a2587eda01
40 changed files with 4978 additions and 1008 deletions
@@ -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)),
)
}