2026-05-15 17:34:54 +08:00
|
|
|
|
"""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}
|
|
|
|
|
|
|
2026-05-18 09:56:58 +08:00
|
|
|
|
# 默认本地根目录;实际路径为 {根}/{md5前5位}/LUXSIN.PKG(具体目录在 get_ota_upload_dir 内按当前环境读取)
|
|
|
|
|
|
_OTA_UPLOAD_DIR_DEV_DEFAULT = "H:/soft/projects/luxsin/dashboard/ota"
|
|
|
|
|
|
_OTA_UPLOAD_DIR_PROD_DEFAULT = "/data/project/dashboard/upload"
|
2026-05-15 17:34:54 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-18 09:56:58 +08:00
|
|
|
|
"""按 DEBUG 选开发/生产目录;每次调用重新读环境变量,避免 import 早于 load_dotenv。"""
|
|
|
|
|
|
debug_val = (os.getenv("DEBUG") or "false").strip().lower()
|
|
|
|
|
|
is_dev = debug_val in ("true", "1", "yes", "on")
|
|
|
|
|
|
dev = os.getenv("OTA_UPLOAD_DIR_DEV", _OTA_UPLOAD_DIR_DEV_DEFAULT)
|
|
|
|
|
|
prod = os.getenv("OTA_UPLOAD_DIR_PROD", _OTA_UPLOAD_DIR_PROD_DEFAULT)
|
|
|
|
|
|
raw = dev if is_dev else prod
|
2026-05-15 17:34:54 +08:00
|
|
|
|
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
|