Add Luxsin curve API client and validation functionality

- Introduced a new module `curve_client.py` for interacting with the Luxsin curve API, including functions for custom Base64 decoding and parametric EQ validation.
- Implemented error handling for API responses and validation checks for curve data.
- Removed outdated test files to streamline the codebase.
- Updated brand creation and update logic in `brands.py` to ensure brand names are validated and checked for duplicates.
- Enhanced model handling in `models.py` with new validation and push-to-search functionality, integrating curve validation before pushing to Meilisearch.
- Updated frontend components to support new validation and push processes, including user feedback for validation results and progress tracking.
- Changed favicon to SVG format for better scalability and appearance.
This commit is contained in:
yangy
2026-05-20 14:13:42 +08:00
parent 1564c8766d
commit fb1aad45af
12 changed files with 653 additions and 256 deletions
+148
View File
@@ -0,0 +1,148 @@
"""Luxsin 曲线 API:拉取、自定义 Base64 解码与 parametric_eq 校验。"""
import base64
import json
import logging
from typing import Any, Optional, Tuple
from urllib.parse import urlencode
import requests
logger = logging.getLogger(__name__)
LUXSIN_CURVE_API_BASE = "https://api.luxsin.com.cn/audio/getCurve"
CUSTOM_CHARS = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/"
STANDARD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
TARGET_OVER_EAR = "Harman over-ear 2018"
TARGET_IN_EAR = "Harman in-ear 2019"
FORM_TARGET_MAP = {
"over-ear": TARGET_OVER_EAR,
"in-ear": TARGET_IN_EAR,
}
def curve_target_for_form(form: Optional[str]) -> Optional[str]:
if not form:
return None
return FORM_TARGET_MAP.get(form.strip().lower())
def custom_base64_to_string(encoded: str) -> str:
"""将 Luxsin 自定义 Base64 字母表映射为标准 Base64 后解码为 UTF-8 字符串。"""
if not encoded or not isinstance(encoded, str):
raise ValueError("曲线数据为空")
standard_b64 = []
for c in encoded:
idx = CUSTOM_CHARS.find(c)
if idx != -1:
standard_b64.append(STANDARD_CHARS[idx])
else:
standard_b64.append(c)
try:
raw = base64.b64decode("".join(standard_b64), validate=False)
except Exception as e:
raise ValueError(f"Base64 解码失败:{e}") from e
try:
return raw.decode("utf-8")
except UnicodeDecodeError as e:
raise ValueError(f"UTF-8 解码失败:{e}") from e
def is_valid_parametric_eq_payload(data: Any) -> bool:
if not isinstance(data, dict):
return False
peq = data.get("parametric_eq")
if not isinstance(peq, dict):
return False
filters = peq.get("filters")
return isinstance(filters, list) and len(filters) == 10
def extract_encoded_payload(response: requests.Response) -> str:
"""从 getCurve 响应中提取待解码字符串。"""
text = (response.text or "").strip()
if not text:
raise ValueError("曲线接口响应为空")
try:
body = response.json()
except ValueError:
return text
if isinstance(body, str):
return body.strip()
if not isinstance(body, dict):
raise ValueError("曲线接口响应格式异常")
for key in ("data", "curve", "result", "content", "body"):
val = body.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
nested = body.get("data")
if isinstance(nested, dict):
for key in ("curve", "data", "content", "encoded"):
val = nested.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(nested, str) and nested.strip():
return nested.strip()
raise ValueError("曲线接口响应中未找到可解码数据")
def fetch_and_validate_curve(
brand: str,
name: str,
form: str,
*,
timeout: float = 20.0,
) -> Tuple[bool, str]:
"""
拉取并校验曲线。返回 (是否通过, 失败原因);通过时原因为空字符串。
"""
brand = (brand or "").strip()
name = (name or "").strip()
if not brand or not name:
return False, "品牌名称或型号名称为空"
target = curve_target_for_form(form)
if not target:
return False, "佩戴方式须为入耳式(in-ear)或头戴式(over-ear)才能校验曲线"
query = urlencode({"brand": brand, "name": name, "target": target})
url = f"{LUXSIN_CURVE_API_BASE}?{query}"
try:
resp = requests.get(url, timeout=timeout)
except requests.RequestException as e:
logger.warning("getCurve request failed: %s %s", url, e)
return False, f"无法连接曲线接口:{e}"
if resp.status_code != 200:
return False, f"曲线接口返回 HTTP {resp.status_code}"
try:
encoded = extract_encoded_payload(resp)
except ValueError as e:
return False, str(e)
try:
decoded_text = custom_base64_to_string(encoded)
payload = json.loads(decoded_text)
except json.JSONDecodeError:
return False, "解码后的数据不是合法 JSON"
except ValueError as e:
return False, str(e)
if not is_valid_parametric_eq_payload(payload):
return False, "曲线数据异常"
return True, ""
+29 -13
View File
@@ -65,14 +65,16 @@ def get_brand(brand_id: int, db: Session = Depends(get_db)):
def create_brand(brand: BrandCreate, db: Session = Depends(get_db)):
"""创建新品牌"""
try:
logger.info(f"Creating brand: name={brand.name}")
# 检查是否已存在
existing = db.query(Brand).filter(Brand.name == brand.name).first()
name = (brand.name or "").strip()
if not name:
return ApiResponse.error(msg="品牌名称不能为空", code=0)
logger.info(f"Creating brand: name={name}")
existing = db.query(Brand).filter(Brand.name == name).first()
if existing:
logger.warning(f"Brand already exists: name={brand.name}")
logger.warning(f"Brand already exists: name={name}")
return ApiResponse.error(msg="品牌名称已存在", code=0)
db_brand = Brand(name=brand.name)
db_brand = Brand(name=name)
db.add(db_brand)
db.commit()
db.refresh(db_brand)
@@ -88,19 +90,33 @@ def create_brand(brand: BrandCreate, db: Session = Depends(get_db)):
def update_brand(brand_id: int, brand: BrandUpdate, db: Session = Depends(get_db)):
"""更新品牌"""
try:
logger.info(f"Updating brand: id={brand_id}, data={brand.dict()}")
logger.info(f"Updating brand: id={brand_id}, data={brand.model_dump()}")
db_brand = db.query(Brand).filter(Brand.id == brand_id).first()
if not db_brand:
logger.warning(f"Brand not found: id={brand_id}")
return ApiResponse.no_data(msg="品牌不存在")
# 如果更新名称,检查是否冲突
if brand.name and brand.name != db_brand.name:
existing = db.query(Brand).filter(Brand.name == brand.name).first()
if brand.name is None:
db.commit()
db.refresh(db_brand)
return ApiResponse.success(data=db_brand.to_dict(), msg="品牌更新成功")
new_name = brand.name.strip()
if not new_name:
return ApiResponse.error(msg="品牌名称不能为空", code=0)
current_name = (db_brand.name or "").strip()
if new_name != current_name:
# 排除当前记录;MySQL 大小写不敏感时,仅改大小写也会命中自身
existing = (
db.query(Brand)
.filter(Brand.name == new_name, Brand.id != brand_id)
.first()
)
if existing:
logger.warning(f"Brand name already exists: name={brand.name}")
logger.warning(f"Brand name already exists: name={new_name}")
return ApiResponse.error(msg="品牌名称已存在", code=0)
db_brand.name = brand.name
db_brand.name = new_name
db.commit()
db.refresh(db_brand)
+125 -63
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from sqlalchemy import asc, desc
from typing import List, Optional
@@ -6,6 +7,7 @@ import logging
from database import get_db
from models.model import Model
from response import ApiResponse, PageData
from curve_client import fetch_and_validate_curve
import requests
import os
import shutil
@@ -16,6 +18,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/models", tags=["models"])
class PushToSearchBody(BaseModel):
"""推送 Meilisearch 请求体;字段名 model_ids 需关闭 protected_namespaces 避免 Pydantic 警告。"""
model_config = ConfigDict(protected_namespaces=())
model_ids: List[int] = Field(..., min_length=1, description="型号 ID 列表")
# Meilisearch 配置(从环境变量读取)
MEILISEARCH_URL = os.getenv("MEILISEARCH_URL", "http://localhost:7700")
MEILISEARCH_API_KEY = os.getenv("MEILISEARCH_API_KEY", "")
@@ -260,69 +271,120 @@ def delete_model(model_id: int, db: Session = Depends(get_db)):
return ApiResponse(code=0, msg="error", data=None)
@router.post("/push-to-search", response_model=ApiResponse)
def push_to_search(
model_ids: List[int] = Body(..., embed=True, description="型号 ID 列表"),
db: Session = Depends(get_db)
):
"""推送型号数据到 Meilisearch"""
try:
if not model_ids:
return ApiResponse(code=0, msg="请选择要推送的型号", data=None)
# 查询选中的型号
models = db.query(Model).filter(Model.id.in_(model_ids)).all()
if not models:
return ApiResponse(code=0, msg="未找到选中的型号数据", data=None)
# 准备推送数据
push_data = []
for model in models:
model_dict = {
'id': model.id,
'brand_name': model.brand_name,
'name': model.name,
'rig': model.rig,
'form': model.form,
'source': model.source
}
# 只包含有值的字段
push_data.append({k: v for k, v in model_dict.items() if v is not None})
# 推送到 Meilisearch
headers = {
"Authorization": f"Bearer {MEILISEARCH_API_KEY}",
"Content-Type": "application/json"
}
# 如果索引不存在,先创建索引
try:
# 更新或添加文档
response = requests.post(
f"{MEILISEARCH_URL}/indexes/{MEILISEARCH_INDEX}/documents",
json=push_data,
headers=headers,
timeout=30
)
if response.status_code not in [200, 202]:
return ApiResponse(code=0, msg=f"推送到 Meilisearch 失败:{response.text}", data=None)
task_info = response.json()
return ApiResponse(
code=1,
msg="success",
data={
"pushed_count": len(push_data),
"task_uid": task_info.get("taskUid"),
"models": push_data
def _get_models_by_ids(model_ids: List[int], db: Session):
if not model_ids:
return None, ApiResponse(code=0, msg="请选择要推送的型号", data=None)
models = db.query(Model).filter(Model.id.in_(model_ids)).all()
if not models:
return None, ApiResponse(code=0, msg="未找到选中的型号数据", data=None)
return models, None
def _validate_models_curve(models) -> List[dict]:
validation_errors = []
for model in models:
ok, reason = fetch_and_validate_curve(
model.brand_name,
model.name,
model.form or "",
)
if not ok:
validation_errors.append(
{
"id": model.id,
"brand_name": model.brand_name,
"name": model.name,
"form": model.form,
"reason": reason,
}
)
except requests.exceptions.RequestException as e:
return ApiResponse(code=0, msg=f"连接 Meilisearch 失败:{str(e)}", data=None)
return validation_errors
def _build_meilisearch_documents(models) -> List[dict]:
push_data = []
for model in models:
model_dict = {
"id": model.id,
"brand_name": model.brand_name,
"name": model.name,
"rig": model.rig,
"form": model.form,
"source": model.source,
}
push_data.append({k: v for k, v in model_dict.items() if v is not None})
return push_data
def _push_documents_to_meilisearch(push_data: List[dict]) -> ApiResponse:
headers = {
"Authorization": f"Bearer {MEILISEARCH_API_KEY}",
"Content-Type": "application/json",
}
try:
response = requests.post(
f"{MEILISEARCH_URL}/indexes/{MEILISEARCH_INDEX}/documents",
json=push_data,
headers=headers,
timeout=30,
)
if response.status_code not in [200, 202]:
return ApiResponse(code=0, msg=f"推送到 Meilisearch 失败:{response.text}", data=None)
task_info = response.json()
return ApiResponse(
code=1,
msg="success",
data={
"pushed_count": len(push_data),
"task_uid": task_info.get("taskUid"),
"models": push_data,
},
)
except requests.exceptions.RequestException as e:
return ApiResponse(code=0, msg=f"连接 Meilisearch 失败:{str(e)}", data=None)
@router.post("/push-to-search/validate", response_model=ApiResponse)
def validate_push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)):
"""推送前校验:拉取并验证曲线 parametric_eq 数据"""
try:
models, err = _get_models_by_ids(body.model_ids, db)
if err:
return err
validation_errors = _validate_models_curve(models)
if validation_errors:
names = "".join(
f"{e['brand_name']} {e['name']}" for e in validation_errors[:5]
)
suffix = "" if len(validation_errors) > 5 else ""
return ApiResponse(
code=0,
msg=f"曲线数据校验未通过:{names}{suffix}",
data={"errors": validation_errors, "validated_count": 0},
)
return ApiResponse(
code=1,
msg="success",
data={"validated_count": len(models)},
)
except Exception as e:
return ApiResponse(code=0, msg="error", data=None)
logger.error("validate_push_to_search failed: %s", e, exc_info=True)
return ApiResponse(code=0, msg=f"校验失败:{str(e)}", data=None)
@router.post("/push-to-search", response_model=ApiResponse)
def push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)):
"""推送型号数据到 Meilisearch(需先通过 validate 接口)"""
try:
models, err = _get_models_by_ids(body.model_ids, db)
if err:
return err
push_data = _build_meilisearch_documents(models)
return _push_documents_to_meilisearch(push_data)
except Exception as e:
logger.error("push_to_search failed: %s", e, exc_info=True)
return ApiResponse(code=0, msg=f"推送失败:{str(e)}", data=None)
-12
View File
@@ -1,12 +0,0 @@
from database import get_db
from models import Brand
if __name__ == "__main__":
db_gen = get_db()
db = next(db_gen)
try:
brands = db.query(Brand).all()
for brand in brands:
print(brand.name)
finally:
db_gen.close() # 这会触发 finally 块中的 db.close()
-17
View File
@@ -1,17 +0,0 @@
import requests
import json
try:
response = requests.get("http://localhost:8001/api/brands/", params={"skip": 0, "limit": 10})
print(f"Status Code: {response.status_code}")
print(f"Content-Type: {response.headers.get('content-type')}")
print(f"Response: {response.text[:500]}")
if response.status_code == 200:
data = response.json()
print(f"\nSuccess! Got {len(data)} brands")
for brand in data[:5]:
print(f" - {brand}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
-52
View File
@@ -1,52 +0,0 @@
import requests
import json
BASE_URL = "http://localhost:8002"
def test_api(endpoint, method="GET", data=None, params=None):
"""测试 API 接口"""
url = f"{BASE_URL}{endpoint}"
try:
if method == "GET":
response = requests.get(url, params=params)
elif method == "POST":
response = requests.post(url, json=data, params=params)
elif method == "PUT":
response = requests.put(url, json=data)
elif method == "DELETE":
response = requests.delete(url)
result = response.json()
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
print(f"Response: {json.dumps(result, ensure_ascii=False, indent=2)}")
return result
except Exception as e:
print(f"\n{method} {endpoint} - Error: {e}")
import traceback
traceback.print_exc()
return None
# 测试品牌接口
print("=" * 60)
print("测试品牌接口")
print("=" * 60)
# 1. 获取品牌列表(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 5})
# 2. 模糊查询品牌(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "Audio"})
# 3. 模糊查询品牌(无数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "不存在的品牌 XYZ"})
# 4. 获取单个品牌(成功)
test_api("/api/brands/1")
# 5. 获取单个品牌(不存在)
test_api("/api/brands/999999")
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
-65
View File
@@ -1,65 +0,0 @@
import requests
import json
BASE_URL = "http://localhost:8002"
def test_api(endpoint, method="GET", data=None, params=None):
"""测试 API 接口"""
url = f"{BASE_URL}{endpoint}"
try:
if method == "GET":
response = requests.get(url, params=params)
elif method == "POST":
response = requests.post(url, json=data, params=params)
elif method == "PUT":
response = requests.put(url, json=data)
elif method == "DELETE":
response = requests.delete(url)
result = response.json()
print(f"\n{method} {endpoint}")
print(f"Status: {response.status_code}")
print(f"Response: {json.dumps(result, ensure_ascii=False, indent=2)}")
return result
except Exception as e:
print(f"\n{method} {endpoint} - Error: {e}")
return None
# 测试品牌接口
print("=" * 60)
print("测试品牌接口")
print("=" * 60)
# 1. 获取品牌列表(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 5})
# 2. 模糊查询品牌(成功,有数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "Audio"})
# 3. 模糊查询品牌(无数据)
test_api("/api/brands/", params={"skip": 0, "limit": 10, "name": "不存在的品牌XYZ"})
# 4. 获取单个品牌(成功)
test_api("/api/brands/1")
# 5. 获取单个品牌(不存在)
test_api("/api/brands/999999")
# 6. 创建品牌(成功)
test_api("/api/brands/", method="POST", data={"name": "Test Brand 测试"})
# 7. 创建品牌(重复)
test_api("/api/brands/", method="POST", data={"name": "1MORE"})
# 8. 更新品牌(成功)
test_api("/api/brands/1", method="PUT", data={"name": "1MORE Updated"})
# 9. 删除品牌(成功)
test_api("/api/brands/623", method="DELETE")
# 10. 删除品牌(不存在)
test_api("/api/brands/999999", method="DELETE")
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)