/** * 频响文件 S3 存储服务 * 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY * 正式环境:通过 IAM 角色访问 S3(无需配置密钥) */ const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); const logger = require('../config/logger'); const AWS_REGION = process.env.AWS_REGION || 'eu-central-1'; const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; const S3_BUCKET = process.env.AWS_S3_MEASUREMENT_BUCKET || process.env.AWS_S3_OTA_BUCKET || 'luxsin-app-bucket'; /** * 获取 S3 客户端 * 有显式凭证则用凭证,否则走 IAM 角色 */ function getS3Client() { const options = { region: AWS_REGION }; if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) { options.credentials = { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY, }; } return new S3Client(options); } /** * 获取品牌首字符(字母则转大写) */ function getBrandFirstChar(brandName) { if (!brandName || !brandName.length) return '_'; const first = brandName[0]; return /[a-zA-Z]/.test(first) ? first.toUpperCase() : first; } /** * 构建 S3 Key * 路径:autoeq/measurements/{source}/data/{form}/{brandFirstChar}/{brand model}.csv * @param {object} params * @param {string} params.source - 来源,默认 Eafonyoung * @param {string} params.form - 佩戴方式 (in-ear / over-ear / earbud) * @param {string} params.brandName - 品牌名称 * @param {string} params.modelName - 型号名称 * @returns {string} S3 Key */ function buildMeasurementKey({ source, form, brandName, modelName }) { const src = source || 'Eafonyoung'; const brandChar = getBrandFirstChar(brandName); const fileName = `${brandName} ${modelName}.csv`; return `autoeq/measurements/${src}/data/${form}/${brandChar}/${fileName}`; } /** * 上传频响文件到 S3 * @param {Buffer} buffer - 文件内容(已转换为 CSV) * @param {object} params - 路径参数 * @returns {Promise} S3 Key */ async function uploadMeasurementToS3(buffer, params) { const key = buildMeasurementKey(params); const client = getS3Client(); try { await client.send( new PutObjectCommand({ Bucket: S3_BUCKET, Key: key, Body: buffer, ContentType: 'text/csv', }) ); logger.info(`Measurement uploaded to s3://${S3_BUCKET}/${key}`); return key; } catch (e) { logger.error(`S3 measurement upload failed: ${e.message}`); throw new Error(`S3 上传失败:${e.message}`); } } /** * 从 S3 读取频响 CSV 文件 * @param {object} params - 路径参数(同 buildMeasurementKey) * @returns {Promise<{ key: string, content: string }>} */ async function getMeasurementFromS3(params) { const key = buildMeasurementKey(params); const client = getS3Client(); try { const response = await client.send( new GetObjectCommand({ Bucket: S3_BUCKET, Key: key, }) ); const content = await response.Body.transformToString('utf-8'); logger.info(`Measurement read from s3://${S3_BUCKET}/${key}`); return { key, content }; } catch (e) { if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) { throw new Error('S3 上未找到该型号的频响文件'); } logger.error(`S3 measurement read failed: ${e.message}`); throw new Error(`S3 读取失败:${e.message}`); } } module.exports = { uploadMeasurementToS3, getMeasurementFromS3, buildMeasurementKey, };