diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js index 7380204..bd83c09 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.js @@ -11,7 +11,7 @@ const logger = require('../config/logger'); const { ApiResponse } = require('../utils/response'); const { authMiddleware } = require('../middleware/auth'); const { fetchAndValidateCurve } = require('../services/curveClient'); -const { uploadMeasurementToS3, getMeasurementFromS3 } = require('../services/measurementStorage'); +const { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } = require('../services/measurementStorage'); const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage'); const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink'); @@ -499,6 +499,15 @@ router.put('/api/models/:model_id', upload.single('measurement_file'), async (re logger.info(`Measurement file uploaded to S3: ${s3Key}`); } + // 若未上传新文件,且路径相关字段发生变更,则迁移 S3 上的旧 CSV + const hasNewFile = squiglink_csv || (req.file && req.file.originalname); + if (!hasNewFile) { + await moveMeasurementOnS3( + { source: dbModel.source, form: dbModel.form, brandName: dbModel.brand_name, modelName: dbModel.name }, + { source: effSource, form: effForm, brandName: newBrandName, modelName: newName } + ); + } + if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name; if (name !== undefined && name !== 'null') dbModel.name = name; if (form !== undefined && form !== 'null') dbModel.form = form; diff --git a/backend/src/routes/ota.js b/backend/src/routes/ota.js index 5b1ec99..917d544 100644 --- a/backend/src/routes/ota.js +++ b/backend/src/routes/ota.js @@ -113,19 +113,21 @@ router.get('/api/ota/', async (req, res) => { const verName = req.query.verName; const model = req.query.model; const status = req.query.status !== undefined ? parseInt(req.query.status, 10) : null; + const beta = req.query.beta !== undefined ? parseInt(req.query.beta, 10) : null; const where = {}; if (verCode !== null && !isNaN(verCode)) where.verCode = verCode; if (verName) where.verName = { [Op.like]: `%${verName}%` }; if (model) where.model = { [Op.like]: `%${model}%` }; if (status !== null && !isNaN(status)) where.status = status; + if (beta !== null && !isNaN(beta)) where.beta = beta; const total = await Ota.count({ where }); const rows = await Ota.findAll({ where, offset: skip, limit, - order: [['verCode', 'DESC']], + order: [['id', 'DESC']], }); logger.info(`Found ${rows.length} OTA records, total=${total}`); @@ -171,13 +173,13 @@ router.post('/api/ota/', async (req, res) => { const data = parsed.data; logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`); - // 检查版本号是否已存在 + // 检查版本号是否已存在(同一 verCode + model + beta 视为唯一) const existing = await Ota.findOne({ - where: { verCode: data.verCode, model: data.model }, + where: { verCode: data.verCode, model: data.model, beta: data.beta ?? 0 }, }); if (existing) { - logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}`); - return res.json(ApiResponse.error('该版本已存在')); + logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`); + return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); } // 处理日期字段 @@ -213,17 +215,18 @@ router.put('/api/ota/:ota_id', async (req, res) => { return res.json(ApiResponse.noData('empty')); } - // 如果更新版本号,检查是否冲突 + // 如果更新版本号或灰度状态,检查是否冲突 const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode; const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model; + const newBeta = data.beta !== undefined && data.beta !== null ? data.beta : dbOta.beta; - if (newVerCode !== dbOta.verCode || newModel !== dbOta.model) { + if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta) { const existing = await Ota.findOne({ - where: { verCode: newVerCode, model: newModel }, + where: { verCode: newVerCode, model: newModel, beta: newBeta }, }); if (existing) { - logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}`); - return res.json(ApiResponse.error('该版本已存在')); + logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`); + return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); } } diff --git a/backend/src/services/measurementStorage.js b/backend/src/services/measurementStorage.js index 5b3e8a2..5a6f7be 100644 --- a/backend/src/services/measurementStorage.js +++ b/backend/src/services/measurementStorage.js @@ -3,7 +3,7 @@ * 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY * 正式环境:通过 IAM 角色访问 S3(无需配置密钥) */ -const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); +const { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const logger = require('../config/logger'); const AWS_REGION = process.env.AWS_REGION || 'eu-central-1'; @@ -107,8 +107,58 @@ async function getMeasurementFromS3(params) { } } +/** + * 在 S3 上迁移频响文件(复制到新路径后删除旧文件) + * @param {object} oldParams - 旧路径参数 { source, form, brandName, modelName } + * @param {object} newParams - 新路径参数 + * @returns {Promise} 新 S3 Key,若旧文件不存在则返回 null + */ +async function moveMeasurementOnS3(oldParams, newParams) { + const oldKey = buildMeasurementKey(oldParams); + const newKey = buildMeasurementKey(newParams); + + if (oldKey === newKey) { + logger.info(`S3 move skipped: path unchanged (${oldKey})`); + return newKey; + } + + const client = getS3Client(); + + try { + // 服务端复制(无需下载再上传) + await client.send( + new CopyObjectCommand({ + Bucket: S3_BUCKET, + CopySource: `${S3_BUCKET}/${encodeURIComponent(oldKey).replace(/%2F/g, '/')}`, + Key: newKey, + ContentType: 'text/csv', + }) + ); + logger.info(`S3 copied: ${oldKey} -> ${newKey}`); + + // 复制成功后删除旧文件 + await client.send( + new DeleteObjectCommand({ + Bucket: S3_BUCKET, + Key: oldKey, + }) + ); + logger.info(`S3 deleted old: ${oldKey}`); + + return newKey; + } catch (e) { + if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) { + logger.info(`S3 move skipped: old key not found (${oldKey})`); + return null; + } + logger.error(`S3 measurement move failed: ${e.message}`); + throw new Error(`S3 迁移失败:${e.message}`); + } +} + module.exports = { uploadMeasurementToS3, getMeasurementFromS3, + moveMeasurementOnS3, buildMeasurementKey, }; diff --git a/frontend/src/layout/index.vue b/frontend/src/layout/index.vue index 583e9de..f5b3d16 100644 --- a/frontend/src/layout/index.vue +++ b/frontend/src/layout/index.vue @@ -24,16 +24,16 @@ :class="{ 'is-active': isSidebarCollapsed }" aria-label="主导航" > - - + { if (route.path === '/home') return '' const t = route.meta?.title diff --git a/frontend/src/views/ota/index.vue b/frontend/src/views/ota/index.vue index 1b36162..4953d39 100644 --- a/frontend/src/views/ota/index.vue +++ b/frontend/src/views/ota/index.vue @@ -32,6 +32,17 @@ + + + + + + - + + + @@ -102,6 +122,11 @@ 停用 + + +