352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
import { Router, type Request, type Response, type Router as RouterType } from 'express';
|
|
import { eq, gt, like, and, desc, count } from 'drizzle-orm';
|
|
import multer from 'multer';
|
|
import { db } from '../config/database.js';
|
|
import { otas } from '../schemas/index.js';
|
|
import logger from '../config/logger.js';
|
|
import { ApiResponse } from '../utils/response.js';
|
|
import { authMiddleware } from '../middleware/auth.js';
|
|
import { OtaCreateSchema, OtaUpdateSchema } from '../validators/ota.js';
|
|
import {
|
|
OTA_MODEL_X8,
|
|
OTA_MODEL_X9,
|
|
OTA_UPLOAD_MODELS,
|
|
readUploadContentAndMd5,
|
|
saveX9PackageLocal,
|
|
uploadX8PackageToS3,
|
|
} from '../services/otaStorage.js';
|
|
|
|
const router: RouterType = Router();
|
|
|
|
const upload = multer({ storage: multer.memoryStorage() });
|
|
|
|
// POST /api/ota/upload-package (需登录)
|
|
router.post('/api/ota/upload-package', authMiddleware, upload.single('package_file'), async (req: Request, res: Response) => {
|
|
try {
|
|
const model = (req.body.model || '').trim();
|
|
if (!OTA_UPLOAD_MODELS.has(model)) {
|
|
res.json(ApiResponse.error(`当前仅支持为 ${OTA_MODEL_X8}、${OTA_MODEL_X9} 上传升级包`));
|
|
return;
|
|
}
|
|
if (!req.file) {
|
|
res.json(ApiResponse.error('请选择升级包文件'));
|
|
return;
|
|
}
|
|
|
|
logger.info(`Uploading OTA package: model=${model}, filename=${req.file.originalname}`);
|
|
|
|
const { content, md5Hex } = await readUploadContentAndMd5(req.file.buffer);
|
|
|
|
if (model === OTA_MODEL_X9) {
|
|
const { savedName, downloadUrl } = saveX9PackageLocal(content, md5Hex);
|
|
res.json(ApiResponse.success({
|
|
md5: md5Hex,
|
|
filename: savedName,
|
|
url: downloadUrl,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (model === OTA_MODEL_X8) {
|
|
const { savedName, downloadUrl, s3Key } = await uploadX8PackageToS3(content, md5Hex);
|
|
res.json(ApiResponse.success({
|
|
md5: md5Hex,
|
|
filename: savedName,
|
|
url: downloadUrl,
|
|
s3_key: s3Key,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
res.json(ApiResponse.error('不支持的设备型号'));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (msg.includes('未配置') || msg.includes('S3 上传失败')) {
|
|
res.json(ApiResponse.error(msg));
|
|
return;
|
|
}
|
|
logger.error(`Error uploading OTA package: ${msg}`);
|
|
res.json(ApiResponse.error(`上传失败:${msg}`));
|
|
}
|
|
});
|
|
|
|
// GET /api/ota/latest/check (无需登录 — 设备端调用)
|
|
router.get('/api/ota/latest/check', async (req: Request, res: Response) => {
|
|
try {
|
|
const currentVerCode = parseInt(req.query.currentVerCode as string, 10);
|
|
const model = req.query.model as string;
|
|
const hw = req.query.hw !== undefined ? parseInt(req.query.hw as string, 10) : null;
|
|
|
|
logger.info(`Checking latest OTA: currentVerCode=${currentVerCode}, model=${model}, hw=${hw}`);
|
|
|
|
const conditions = [eq(otas.status, 1), gt(otas.verCode, currentVerCode), eq(otas.model, model)];
|
|
if (hw !== null && !isNaN(hw)) {
|
|
conditions.push(eq(otas.hw, hw));
|
|
}
|
|
|
|
const [latestOta] = await db
|
|
.select()
|
|
.from(otas)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(otas.verCode))
|
|
.limit(1);
|
|
|
|
if (!latestOta) {
|
|
logger.info(`No available OTA found for model=${model}, currentVerCode=${currentVerCode}`);
|
|
res.json(ApiResponse.noData('empty'));
|
|
return;
|
|
}
|
|
|
|
logger.info(`Latest OTA found: verCode=${latestOta.verCode}, verName=${latestOta.verName}`);
|
|
res.json(ApiResponse.success(otaToDict(latestOta)));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error checking latest OTA: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
// 以下接口需登录
|
|
router.use('/api/ota', authMiddleware);
|
|
|
|
// GET /api/ota/
|
|
router.get('/api/ota/', async (req: Request, res: Response) => {
|
|
try {
|
|
const skip = parseInt(req.query.skip as string || '0', 10);
|
|
const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000);
|
|
const verCode = req.query.verCode !== undefined ? parseInt(req.query.verCode as string, 10) : null;
|
|
const verName = req.query.verName as string | undefined;
|
|
const model = req.query.model as string | undefined;
|
|
const status = req.query.status !== undefined ? parseInt(req.query.status as string, 10) : null;
|
|
const beta = req.query.beta !== undefined ? parseInt(req.query.beta as string, 10) : null;
|
|
|
|
const conditions = [];
|
|
if (verCode !== null && !isNaN(verCode)) conditions.push(eq(otas.verCode, verCode));
|
|
if (verName) conditions.push(like(otas.verName, `%${verName}%`));
|
|
if (model) conditions.push(like(otas.model, `%${model}%`));
|
|
if (status !== null && !isNaN(status)) conditions.push(eq(otas.status, status));
|
|
if (beta !== null && !isNaN(beta)) conditions.push(eq(otas.beta, beta));
|
|
|
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const [totalResult] = await db
|
|
.select({ value: count() })
|
|
.from(otas)
|
|
.where(whereClause);
|
|
const total = totalResult?.value ?? 0;
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(otas)
|
|
.where(whereClause)
|
|
.orderBy(desc(otas.id))
|
|
.offset(skip)
|
|
.limit(limit);
|
|
|
|
logger.info(`Found ${rows.length} OTA records, total=${total}`);
|
|
|
|
if (!rows.length) {
|
|
res.json(ApiResponse.noData('empty'));
|
|
return;
|
|
}
|
|
|
|
const items = rows.map(otaToDict);
|
|
res.json(ApiResponse.success({ items, total, skip, limit }));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error getting OTA list: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
// GET /api/ota/:ota_id
|
|
router.get('/api/ota/:ota_id', async (req: Request, res: Response) => {
|
|
try {
|
|
const otaId = parseInt(req.params.ota_id as string, 10);
|
|
logger.info(`Getting OTA: id=${otaId}`);
|
|
|
|
const [ota] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1);
|
|
if (!ota) {
|
|
logger.warn(`OTA not found: id=${otaId}`);
|
|
res.json(ApiResponse.noData('empty'));
|
|
return;
|
|
}
|
|
res.json(ApiResponse.success(otaToDict(ota)));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error getting OTA: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
// POST /api/ota/
|
|
router.post('/api/ota/', async (req: Request, res: Response) => {
|
|
try {
|
|
const parsed = OtaCreateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
const errors = parsed.error.issues.map((e: { message: string }) => e.message).join('; ');
|
|
res.json(ApiResponse.error(errors));
|
|
return;
|
|
}
|
|
|
|
const data = parsed.data;
|
|
logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`);
|
|
|
|
// 检查版本号是否已存在
|
|
const [existing] = await db
|
|
.select()
|
|
.from(otas)
|
|
.where(and(
|
|
eq(otas.verCode, data.verCode),
|
|
eq(otas.model, data.model!),
|
|
eq(otas.beta, data.beta ?? 0),
|
|
))
|
|
.limit(1);
|
|
if (existing) {
|
|
logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`);
|
|
res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
|
|
return;
|
|
}
|
|
|
|
const result = await db.insert(otas).values({
|
|
verCode: data.verCode,
|
|
verName: data.verName,
|
|
url: data.url,
|
|
md5: data.md5,
|
|
force: data.force ?? 0,
|
|
desc: data.desc ?? null,
|
|
model: data.model ?? null,
|
|
hw: data.hw ?? 0,
|
|
target: data.target ?? 0,
|
|
beta: data.beta ?? 0,
|
|
startTime: data.startTime ? new Date(data.startTime) : null,
|
|
endTime: data.endTime ? new Date(data.endTime) : null,
|
|
status: data.status ?? 1,
|
|
});
|
|
|
|
const [dbOta] = await db.select().from(otas).where(eq(otas.id, result[0].insertId)).limit(1);
|
|
logger.info(`OTA created successfully: id=${dbOta.id}, verCode=${dbOta.verCode}`);
|
|
res.json(ApiResponse.success(otaToDict(dbOta)));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error creating OTA: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
// PUT /api/ota/:ota_id
|
|
router.put('/api/ota/:ota_id', async (req: Request, res: Response) => {
|
|
try {
|
|
const otaId = parseInt(req.params.ota_id as string, 10);
|
|
|
|
const parsed = OtaUpdateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
const errors = parsed.error.issues.map((e: { message: string }) => e.message).join('; ');
|
|
res.json(ApiResponse.error(errors));
|
|
return;
|
|
}
|
|
|
|
const data = parsed.data;
|
|
logger.info(`Updating OTA: id=${otaId}`);
|
|
|
|
const [dbOta] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1);
|
|
if (!dbOta) {
|
|
logger.warn(`OTA not found: id=${otaId}`);
|
|
res.json(ApiResponse.noData('empty'));
|
|
return;
|
|
}
|
|
|
|
// 如果更新版本号或灰度状态,检查是否冲突
|
|
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 || newBeta !== dbOta.beta) {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(otas)
|
|
.where(and(eq(otas.verCode, newVerCode), eq(otas.model, newModel!), eq(otas.beta, newBeta)))
|
|
.limit(1);
|
|
if (existing) {
|
|
logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`);
|
|
res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)'));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 更新字段
|
|
const updateData: Partial<typeof otas.$inferInsert> = {};
|
|
if (data.verCode !== undefined && data.verCode !== null) updateData.verCode = data.verCode;
|
|
if (data.verName !== undefined && data.verName !== null) updateData.verName = data.verName;
|
|
if (data.url !== undefined && data.url !== null) updateData.url = data.url;
|
|
if (data.md5 !== undefined && data.md5 !== null) updateData.md5 = data.md5;
|
|
if (data.force !== undefined && data.force !== null) updateData.force = data.force;
|
|
if (data.desc !== undefined) updateData.desc = data.desc;
|
|
if (data.model !== undefined) updateData.model = data.model;
|
|
if (data.hw !== undefined && data.hw !== null) updateData.hw = data.hw;
|
|
if (data.target !== undefined && data.target !== null) updateData.target = data.target;
|
|
if (data.beta !== undefined && data.beta !== null) updateData.beta = data.beta;
|
|
if (data.startTime !== undefined && data.startTime !== null) updateData.startTime = new Date(data.startTime);
|
|
if (data.endTime !== undefined && data.endTime !== null) updateData.endTime = new Date(data.endTime);
|
|
if (data.status !== undefined && data.status !== null) updateData.status = data.status;
|
|
|
|
if (Object.keys(updateData).length > 0) {
|
|
await db.update(otas).set(updateData).where(eq(otas.id, otaId));
|
|
}
|
|
|
|
logger.info(`OTA updated successfully: id=${otaId}`);
|
|
|
|
const [updated] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1);
|
|
res.json(ApiResponse.success(otaToDict(updated)));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error updating OTA: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
// DELETE /api/ota/:ota_id
|
|
router.delete('/api/ota/:ota_id', async (req: Request, res: Response) => {
|
|
try {
|
|
const otaId = parseInt(req.params.ota_id as string, 10);
|
|
logger.info(`Deleting OTA: id=${otaId}`);
|
|
|
|
const [dbOta] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1);
|
|
if (!dbOta) {
|
|
logger.warn(`OTA not found: id=${otaId}`);
|
|
res.json(ApiResponse.noData('empty'));
|
|
return;
|
|
}
|
|
|
|
await db.delete(otas).where(eq(otas.id, otaId));
|
|
logger.info(`OTA deleted successfully: id=${otaId}`);
|
|
res.json(ApiResponse.success(null, 'success'));
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
logger.error(`Error deleting OTA: ${msg}`);
|
|
res.json(ApiResponse.error('error'));
|
|
}
|
|
});
|
|
|
|
function otaToDict(ota: typeof otas.$inferSelect) {
|
|
return {
|
|
id: ota.id,
|
|
verCode: ota.verCode,
|
|
verName: ota.verName,
|
|
url: ota.url,
|
|
md5: ota.md5,
|
|
force: ota.force,
|
|
desc: ota.desc,
|
|
model: ota.model,
|
|
hw: ota.hw,
|
|
target: ota.target,
|
|
beta: ota.beta,
|
|
startTime: ota.startTime ? ota.startTime.toISOString() : null,
|
|
endTime: ota.endTime ? ota.endTime.toISOString() : null,
|
|
status: ota.status,
|
|
create_at: ota.createAt ? ota.createAt.toISOString() : null,
|
|
};
|
|
}
|
|
|
|
export default router;
|