fix(files): 修复 304 缓存未销毁流导致的 S3 连接池泄漏与图片挂起卡死

This commit is contained in:
yml2213
2026-09-17 21:03:20 +08:00
parent c5d1e2c375
commit 8228776b8c
5 changed files with 39 additions and 9 deletions
+21
View File
@@ -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)
}
-1
View File
@@ -4511,4 +4511,3 @@ body {
padding-top: 6px;
border-top: 1px dashed #cbd5e1;
}
-1
View File
@@ -85,7 +85,6 @@ export type ConsignmentBuyerInfo = {
screenshots: string[]
}
/**
* 工单 `material.consignment` 里的寄售履约信息(履约/指派时后端写入)。
* 历史老单没有 sourceSubmissionNo,展示时必须回退到 inventoryNo。