fix(files): 修复 304 缓存未销毁流导致的 S3 连接池泄漏与图片挂起卡死
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
@@ -9,14 +10,18 @@ import { buildNotFoundPayload, sendRouteError } from '../utils/http.js'
|
||||
const router = Router()
|
||||
|
||||
router.get('/object', async (req, res) => {
|
||||
let readerToDestroy: Readable | null = null
|
||||
try {
|
||||
const rawKey = String(req.query.key || '').trim()
|
||||
const isPublic = isPublicObjectKey(rawKey)
|
||||
const object = await getStoredFileObject(req.query.key, req.query.variant)
|
||||
readerToDestroy = object.reader
|
||||
|
||||
if (object.etag) {
|
||||
res.setHeader('ETag', object.etag)
|
||||
if (req.headers['if-none-match'] === object.etag) {
|
||||
object.reader.destroy()
|
||||
readerToDestroy = null
|
||||
res.status(304).end()
|
||||
return
|
||||
}
|
||||
@@ -26,12 +31,17 @@ router.get('/object', async (req, res) => {
|
||||
if (req.headers['if-modified-since']) {
|
||||
const ifModifiedSince = new Date(req.headers['if-modified-since'])
|
||||
if (!isNaN(ifModifiedSince.getTime()) && object.lastModified <= ifModifiedSince) {
|
||||
object.reader.destroy()
|
||||
readerToDestroy = null
|
||||
res.status(304).end()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (object.size > 0) {
|
||||
res.setHeader('Content-Length', object.size)
|
||||
}
|
||||
res.setHeader('Content-Type', object.contentType || 'application/octet-stream')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
|
||||
@@ -41,7 +51,14 @@ router.get('/object', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'private, max-age=300')
|
||||
}
|
||||
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
object.reader.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
object.reader.on('error', (error) => {
|
||||
object.reader.destroy()
|
||||
if (!res.headersSent) {
|
||||
sendRouteError(res, error, '读取文件失败', '[files/object]')
|
||||
} else {
|
||||
@@ -49,7 +66,11 @@ router.get('/object', async (req, res) => {
|
||||
}
|
||||
})
|
||||
object.reader.pipe(res)
|
||||
readerToDestroy = null
|
||||
} catch (error) {
|
||||
if (readerToDestroy) {
|
||||
readerToDestroy.destroy()
|
||||
}
|
||||
sendRouteError(res, error, '读取文件失败', '[files/object]')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import {
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -65,6 +68,12 @@ export class ObjectStorage {
|
||||
accessKeyId: String(config.accessKeyId || '').trim(),
|
||||
secretAccessKey: String(config.secretAccessKey || '').trim(),
|
||||
},
|
||||
requestHandler: new NodeHttpHandler({
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 15000,
|
||||
httpAgent: new http.Agent({ maxSockets: 200, keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ maxSockets: 200, keepAlive: true }),
|
||||
}),
|
||||
// 默认的 CRC32 请求校验头不被 OSS 的 S3 兼容层支持,关闭以保持兼容。
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
@@ -175,6 +184,12 @@ export class ObjectStorage {
|
||||
return
|
||||
}
|
||||
|
||||
// 阿里云 OSS 桶由控制台预先创建,跳过 HeadBucketCommand 避免 RAM 权限受限或挂起阻塞
|
||||
if (!this.pathStyle) {
|
||||
this.bucketReady = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.bucketReadyPromise) {
|
||||
this.bucketReadyPromise = (async () => {
|
||||
try {
|
||||
@@ -183,11 +198,6 @@ export class ObjectStorage {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error
|
||||
}
|
||||
if (!this.pathStyle) {
|
||||
throw new Error(`OSS bucket ${this.bucket} 不存在,请先在阿里云控制台创建`, {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }))
|
||||
}
|
||||
this.bucketReady = true
|
||||
@@ -303,7 +313,9 @@ function toObjectStorageConfig(config: {
|
||||
|
||||
export function normalizeStorageEndpoint(rawEndpoint: unknown) {
|
||||
const endpoint = String(rawEndpoint || '').trim()
|
||||
const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`)
|
||||
const isOss = endpoint.toLowerCase().includes('.aliyuncs.com')
|
||||
const defaultScheme = isOss ? 'https://' : 'http://'
|
||||
const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `${defaultScheme}${endpoint}`)
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80
|
||||
|
||||
return {
|
||||
|
||||
@@ -110,4 +110,3 @@ export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
|
||||
: {}
|
||||
return normalizeSubmittedFields(collect.fields)
|
||||
}
|
||||
|
||||
|
||||
@@ -4511,4 +4511,3 @@ body {
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ export type ConsignmentBuyerInfo = {
|
||||
screenshots: string[]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 工单 `material.consignment` 里的寄售履约信息(履约/指派时后端写入)。
|
||||
* 历史老单没有 sourceSubmissionNo,展示时必须回退到 inventoryNo。
|
||||
|
||||
Reference in New Issue
Block a user