Add OTA package upload functionality and UI enhancements
- Introduced a new endpoint for uploading OTA packages in backend/routes/ota.py. - Implemented file handling and MD5 checksum calculation for OTA packages. - Updated frontend API to support OTA package uploads with a new uploadOtaPackage function. - Enhanced the OTA management UI to include file upload options and improved layout. - Updated requirements.txt to include boto3 for S3 interactions. - Made various UI adjustments for better user experience and consistency.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录"""
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import UploadFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OTA_MODEL_X8 = "Luxsin-X8"
|
||||
OTA_MODEL_X9 = "Luxsin-X9"
|
||||
OTA_UPLOAD_MODELS = {OTA_MODEL_X8, OTA_MODEL_X9}
|
||||
|
||||
# 本地根目录;实际文件路径为 {根}/{md5前5位}/LUXSIN.PKG
|
||||
OTA_UPLOAD_DIR_DEV = os.getenv(
|
||||
"OTA_UPLOAD_DIR_DEV", "H:/soft/projects/luxsin/dashboard/ota"
|
||||
)
|
||||
OTA_UPLOAD_DIR_PROD = os.getenv(
|
||||
"OTA_UPLOAD_DIR_PROD", "/data/project/dashboard/upload"
|
||||
)
|
||||
|
||||
AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
|
||||
AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "")
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "")
|
||||
AWS_S3_OTA_BUCKET = os.getenv("AWS_S3_OTA_BUCKET", "luxsin-app-bucket")
|
||||
|
||||
OTA_X8_PUBLIC_BASE = (os.getenv("OTA_X8_PUBLIC_BASE") or "http://am.luxsinaudio.com").rstrip(
|
||||
"/"
|
||||
)
|
||||
OTA_X9_URL_BASE = (os.getenv("OTA_X9_URL_BASE") or "http://source.luxsin.net").rstrip("/")
|
||||
|
||||
OTA_FILENAME_X8 = "LUXSIN_X8.PKG"
|
||||
OTA_FILENAME_X9 = "LUXSIN.PKG"
|
||||
|
||||
|
||||
def get_ota_upload_dir() -> Path:
|
||||
is_dev = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||
raw = OTA_UPLOAD_DIR_DEV if is_dev else OTA_UPLOAD_DIR_PROD
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def md5_prefix5(md5_hex: str) -> str:
|
||||
return (md5_hex or "")[:5]
|
||||
|
||||
|
||||
def build_x8_public_url(s3_key: str) -> str:
|
||||
"""X8:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG"""
|
||||
return f"{OTA_X8_PUBLIC_BASE}/{s3_key}"
|
||||
|
||||
|
||||
def build_x9_public_url(ym: str, prefix: str) -> str:
|
||||
"""X9:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG"""
|
||||
return f"{OTA_X9_URL_BASE}/ota/{ym}/x9/{prefix}/{OTA_FILENAME_X9}"
|
||||
|
||||
|
||||
async def read_upload_content_and_md5(package_file: UploadFile) -> tuple[bytes, str]:
|
||||
md5_hash = hashlib.md5()
|
||||
buf = io.BytesIO()
|
||||
chunk_size = 1024 * 1024
|
||||
while True:
|
||||
chunk = await package_file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
md5_hash.update(chunk)
|
||||
buf.write(chunk)
|
||||
return buf.getvalue(), md5_hash.hexdigest()
|
||||
|
||||
|
||||
def save_x9_package_local(content: bytes, md5_hex: str) -> tuple[str, str]:
|
||||
"""
|
||||
保存到 {OTA_UPLOAD_DIR}/{md5前5位}/LUXSIN.PKG
|
||||
对外 URL:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG
|
||||
"""
|
||||
root = get_ota_upload_dir()
|
||||
prefix = md5_prefix5(md5_hex)
|
||||
ym = datetime.now().strftime("%Y%m")
|
||||
dest_dir = root / prefix
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
final_path = dest_dir / OTA_FILENAME_X9
|
||||
if final_path.exists():
|
||||
final_path.unlink()
|
||||
final_path.write_bytes(content)
|
||||
download_url = build_x9_public_url(ym, prefix)
|
||||
logger.info("OTA X9 package saved locally: %s", final_path)
|
||||
return OTA_FILENAME_X9, download_url
|
||||
|
||||
|
||||
def upload_x8_package_to_s3(content: bytes, md5_hex: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
S3 Key:ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
|
||||
对外 URL:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
|
||||
"""
|
||||
if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY:
|
||||
raise ValueError("未配置 AWS 访问密钥(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
|
||||
|
||||
ym = datetime.now().strftime("%Y%m")
|
||||
prefix = md5_prefix5(md5_hex)
|
||||
s3_key = f"ota/{ym}/x8/{prefix}/{OTA_FILENAME_X8}"
|
||||
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
region_name=AWS_REGION,
|
||||
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
try:
|
||||
client.put_object(
|
||||
Bucket=AWS_S3_OTA_BUCKET,
|
||||
Key=s3_key,
|
||||
Body=content,
|
||||
ContentType="application/octet-stream",
|
||||
)
|
||||
except ClientError as e:
|
||||
logger.error("S3 upload failed: %s", e, exc_info=True)
|
||||
raise ValueError(f"S3 上传失败:{e}") from e
|
||||
|
||||
download_url = build_x8_public_url(s3_key)
|
||||
logger.info("OTA X8 package uploaded to s3://%s/%s", AWS_S3_OTA_BUCKET, s3_key)
|
||||
return OTA_FILENAME_X8, download_url, s3_key
|
||||
@@ -8,3 +8,4 @@ python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
PyJWT==2.8.0
|
||||
boto3==1.34.162
|
||||
|
||||
+72
-1
@@ -1,11 +1,22 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
from database import get_db
|
||||
from models.ota import Ota
|
||||
from schemas import OtaCreate, OtaUpdate, OtaResponse
|
||||
from response import ApiResponse, PageData
|
||||
from ota_storage import (
|
||||
OTA_MODEL_X8,
|
||||
OTA_MODEL_X9,
|
||||
OTA_UPLOAD_MODELS,
|
||||
read_upload_content_and_md5,
|
||||
save_x9_package_local,
|
||||
upload_x8_package_to_s3,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,6 +24,66 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ota", tags=["ota"])
|
||||
|
||||
|
||||
@router.post("/upload-package", response_model=ApiResponse)
|
||||
async def upload_ota_package(
|
||||
model: str = Form(..., description="设备型号"),
|
||||
package_file: UploadFile = File(..., description="OTA 升级包"),
|
||||
):
|
||||
"""上传 OTA 升级包:计算 MD5;X8 上传 S3 为 LUXSIN_X8.PKG,X9 本地保存为 LUXSIN.PKG"""
|
||||
try:
|
||||
model = (model or "").strip()
|
||||
if model not in OTA_UPLOAD_MODELS:
|
||||
return ApiResponse(
|
||||
code=0,
|
||||
msg=f"当前仅支持为 {OTA_MODEL_X8}、{OTA_MODEL_X9} 上传升级包",
|
||||
data=None,
|
||||
)
|
||||
if not package_file or not package_file.filename:
|
||||
return ApiResponse(code=0, msg="请选择升级包文件", data=None)
|
||||
|
||||
logger.info(
|
||||
"Uploading OTA package: model=%s, filename=%s",
|
||||
model,
|
||||
package_file.filename,
|
||||
)
|
||||
|
||||
content, md5_hex = await read_upload_content_and_md5(package_file)
|
||||
|
||||
if model == OTA_MODEL_X9:
|
||||
saved_name, download_url = save_x9_package_local(content, md5_hex)
|
||||
return ApiResponse(
|
||||
code=1,
|
||||
msg="success",
|
||||
data={
|
||||
"md5": md5_hex,
|
||||
"filename": saved_name,
|
||||
"url": download_url,
|
||||
},
|
||||
)
|
||||
|
||||
if model == OTA_MODEL_X8:
|
||||
saved_name, download_url, s3_key = upload_x8_package_to_s3(
|
||||
content, md5_hex
|
||||
)
|
||||
return ApiResponse(
|
||||
code=1,
|
||||
msg="success",
|
||||
data={
|
||||
"md5": md5_hex,
|
||||
"filename": saved_name,
|
||||
"url": download_url,
|
||||
"s3_key": s3_key,
|
||||
},
|
||||
)
|
||||
|
||||
return ApiResponse(code=0, msg="不支持的设备型号", data=None)
|
||||
except ValueError as e:
|
||||
return ApiResponse(code=0, msg=str(e), data=None)
|
||||
except Exception as e:
|
||||
logger.error("Error uploading OTA package: %s", e, exc_info=True)
|
||||
return ApiResponse(code=0, msg=f"上传失败:{e}", data=None)
|
||||
|
||||
|
||||
@router.get("/", response_model=ApiResponse)
|
||||
def get_ota_list(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
|
||||
Reference in New Issue
Block a user