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:
yangy
2026-05-15 17:34:54 +08:00
parent 661b85ce62
commit 1a6c96da8c
9 changed files with 511 additions and 132 deletions
+124
View File
@@ -0,0 +1,124 @@
"""OTA 升级包存储:Luxsin-X8 -> S3Luxsin-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:
"""X8http://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:
"""X9http://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
对外 URLhttp://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 Keyota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
对外 URLhttp://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
+1
View File
@@ -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
View File
@@ -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 升级包:计算 MD5X8 上传 S3 为 LUXSIN_X8.PKGX9 本地保存为 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="跳过记录数"),