diff --git a/cmd/server/main.go b/cmd/server/main.go index 4b52889..598d170 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -74,7 +74,7 @@ func main() { devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log) devicePersistTask.Start(5 * time.Minute) - engine := router.New(log, db, searchClient, rdb) + engine := router.New(log, db, searchClient, rdb, cfg.Equalize) srv := &http.Server{ Addr: cfg.Addr(), diff --git a/internal/cache/curve_cache.go b/internal/cache/curve_cache.go new file mode 100644 index 0000000..231618b --- /dev/null +++ b/internal/cache/curve_cache.go @@ -0,0 +1,88 @@ +package cache + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + curveLockTTL = 10 * time.Second + curveLockRetryDelay = 100 * time.Millisecond + curveLockMaxRetries = 50 +) + +// CurveCache 曲线数据缓存(Redis Hash) +type CurveCache struct { + rdb *redis.Client +} + +func NewCurveCache(rdb *redis.Client) *CurveCache { + return &CurveCache{rdb: rdb} +} + +// Get 从 Redis Hash 中获取曲线缓存 +func (c *CurveCache) Get(ctx context.Context, brand, name, target string) (string, error) { + key := brand + " " + name + val, err := c.rdb.HGet(ctx, key, target).Result() + if err == redis.Nil { + return "", nil + } + if err != nil { + return "", fmt.Errorf("hget curve cache: %w", err) + } + return val, nil +} + +// Set 将曲线数据存入 Redis Hash +func (c *CurveCache) Set(ctx context.Context, brand, name, target, data string) error { + key := brand + " " + name + return c.rdb.HSet(ctx, key, target, data).Err() +} + +// AcquireLock 获取分布式锁(SETNX),防止并发请求同一个曲线数据 +func (c *CurveCache) AcquireLock(ctx context.Context, brand, name, target string) (bool, error) { + lockKey := brand + " " + name + ":" + target + ":lock" + return c.rdb.SetNX(ctx, lockKey, "locked", curveLockTTL).Result() +} + +// ReleaseLock 释放分布式锁 +func (c *CurveCache) ReleaseLock(ctx context.Context, brand, name, target string) error { + lockKey := brand + " " + name + ":" + target + ":lock" + return c.rdb.Del(ctx, lockKey).Err() +} + +// GetWithLock 获取缓存数据,缓存不存在时尝试加锁后重新获取 +// 返回值: (data, acquired, error) +// - data: 缓存数据(空字符串表示无数据) +// - acquired: 是否成功获取锁(缓存不存在时需要加锁) +func (c *CurveCache) GetWithLock(ctx context.Context, brand, name, target string) (data string, acquired bool, err error) { + // 先查缓存 + data, err = c.Get(ctx, brand, name, target) + if err != nil { + return "", false, err + } + if data != "" { + return data, false, nil // 缓存命中 + } + + // 缓存不存在,尝试加锁 + acquired, err = c.AcquireLock(ctx, brand, name, target) + if err != nil { + return "", false, fmt.Errorf("acquire curve lock: %w", err) + } + + if acquired { + // 加锁成功,再次检查缓存(双重检查) + data, err = c.Get(ctx, brand, name, target) + if err != nil { + return "", true, err + } + return data, true, nil + } + + // 未获取锁,等待后重试 + return "", false, nil +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index d349f3f..6a5cf40 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { Database DatabaseConfig Meilisearch MeilisearchConfig Redis RedisConfig + Equalize EqualizeConfig } func Load() (*Config, error) { @@ -41,6 +42,8 @@ func Load() (*Config, error) { return nil, err } + eq := loadEqualize(env) + return &Config{ Env: env, Host: getEnv("APP_HOST", "0.0.0.0"), @@ -48,6 +51,7 @@ func Load() (*Config, error) { Database: db, Meilisearch: ms, Redis: rd, + Equalize: eq, }, nil } diff --git a/internal/config/database.go b/internal/config/database.go index 6cfe74e..8f200d1 100644 --- a/internal/config/database.go +++ b/internal/config/database.go @@ -30,7 +30,7 @@ func loadDatabase(env string) (DatabaseConfig, error) { }, nil default: return DatabaseConfig{ - Host: "localhost", + Host: "192.168.9.137", Port: 3306, Name: "audio", User: "root", diff --git a/internal/config/equalize.go b/internal/config/equalize.go new file mode 100644 index 0000000..a4cfe13 --- /dev/null +++ b/internal/config/equalize.go @@ -0,0 +1,34 @@ +package config + +import "os" + +type EqualizeConfig struct { + APIURL string + MeasurementBasePath string + TargetBasePath string +} + +func loadEqualize(env string) EqualizeConfig { + if os.Getenv("EQ_API_URL") != "" { + return EqualizeConfig{ + APIURL: os.Getenv("EQ_API_URL"), + MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", ""), + TargetBasePath: getEnv("TARGET_BASE_PATH", ""), + } + } + + switch env { + case "production": + return EqualizeConfig{ + APIURL: "https://autoeq.app/equalize", + MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", ""), + TargetBasePath: getEnv("TARGET_BASE_PATH", ""), + } + default: + return EqualizeConfig{ + APIURL: "https://autoeq.app/equalize", + MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", ""), + TargetBasePath: getEnv("TARGET_BASE_PATH", ""), + } + } +} \ No newline at end of file diff --git a/internal/config/redis.go b/internal/config/redis.go index 1a04799..9a00ca6 100644 --- a/internal/config/redis.go +++ b/internal/config/redis.go @@ -28,7 +28,7 @@ func loadRedis(env string) RedisConfig { } default: return RedisConfig{ - Host: "localhost", + Host: "192.168.9.137", Port: 6379, Password: "", Database: 1, diff --git a/internal/handler/curve.go b/internal/handler/curve.go new file mode 100644 index 0000000..13b3ae8 --- /dev/null +++ b/internal/handler/curve.go @@ -0,0 +1,392 @@ +package handler + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/luxsin/app-api/internal/cache" + "github.com/luxsin/app-api/internal/config" + "github.com/luxsin/app-api/internal/repository" + "github.com/luxsin/app-api/internal/response" + "go.uber.org/zap" +) + +type CurveHandler struct { + repo *repository.CurveRepository + cache *cache.CurveCache + cfg config.EqualizeConfig + log *zap.Logger +} + +func NewCurveHandler(repo *repository.CurveRepository, curveCache *cache.CurveCache, cfg config.EqualizeConfig, log *zap.Logger) *CurveHandler { + return &CurveHandler{repo: repo, cache: curveCache, cfg: cfg, log: log} +} + +// GetCurve 获取目标曲线 +// GET /audio/getCurve?brand=xxx&name=xxx&target=xxx&includeRaw=false +func (h *CurveHandler) GetCurve(c *gin.Context) { + brand := strings.TrimSpace(c.Query("brand")) + name := strings.TrimSpace(c.Query("name")) + target := strings.TrimSpace(c.Query("target")) + includeRaw := c.Query("includeRaw") == "true" || c.Query("includeRaw") == "1" + + if brand == "" || name == "" || target == "" { + response.BadRequest(c, "brand、name和target参数不能为空") + return + } + + ctx := c.Request.Context() + + result, err := h.getCurvePoint(ctx, brand, name, target) + if err != nil { + h.log.Error("get curve point failed", zap.Error(err)) + response.InternalError(c, "获取曲线数据失败") + return + } + + if result == "" { + response.Fail(c, http.StatusOK, 40004, "无曲线数据") + return + } + + // 解析 JSON 结果 + var resp map[string]any + if err := json.Unmarshal([]byte(result), &resp); err != nil { + response.InternalError(c, "解析曲线数据失败") + return + } + + // 如果 includeRaw 为 false 且 code != 200,移除 fr 字段 + if !includeRaw { + codeVal, _ := resp["code"] + if code, ok := codeVal.(float64); ok && code != 200 { + delete(resp, "fr") + } + } + + response.OK(c, resp) +} + +// getCurvePoint 获取曲线数据:先查缓存,缓存不存在则请求 EQ 接口并缓存结果 +func (h *CurveHandler) getCurvePoint(ctx context.Context, brand, name, target string) (string, error) { + data, acquired, err := h.cache.GetWithLock(ctx, brand, name, target) + if err != nil { + return "", err + } + + // 缓存命中 + if data != "" && !acquired { + h.log.Info("curve cache hit", zap.String("brand", brand), zap.String("name", name), zap.String("target", target)) + return data, nil + } + + // 获取了锁,缓存仍然为空,需要请求 EQ 接口 + if acquired { + result, eqErr := h.getCurvePointFromPEQ(ctx, brand, name, target) + if eqErr != nil { + // 释放锁 + if lockErr := h.cache.ReleaseLock(ctx, brand, name, target); lockErr != nil { + h.log.Warn("release curve lock failed", zap.Error(lockErr)) + } + return "", eqErr + } + // 释放锁 + if lockErr := h.cache.ReleaseLock(ctx, brand, name, target); lockErr != nil { + h.log.Warn("release curve lock failed", zap.Error(lockErr)) + } + + if result != "" { + // 缓存结果 + if cacheErr := h.cache.Set(ctx, brand, name, target, result); cacheErr != nil { + h.log.Warn("curve cache set failed", zap.Error(cacheErr)) + } + return result, nil + } + return "", nil + } + + // 未获取锁(其他请求正在处理),等待后重试 + time.Sleep(100 * time.Millisecond) + return h.getCurvePoint(ctx, brand, name, target) +} + +// getCurvePointFromPEQ 从 EQ 接口获取曲线数据 +func (h *CurveHandler) getCurvePointFromPEQ(ctx context.Context, brand, name, targetName string) (string, error) { + m, err := h.repo.GetModelByBrandAndName(ctx, brand, name) + if err != nil { + return "", fmt.Errorf("query model: %w", err) + } + if m == nil { + return "", nil + } + + t, err := h.repo.GetTargetByLabel(ctx, targetName) + if err != nil { + return "", fmt.Errorf("query target: %w", err) + } + if t == nil { + return "", nil + } + + // 确定 headPhone 名称 + headPhone := brand + " " + name + if m.EqKey != nil && *m.EqKey == "name" { + headPhone = name + } + + // 获取 measurement 数据(仅 Eafonyoung 源需要读 CSV) + var measurement map[string]any + if m.Source != nil && *m.Source == "Eafonyoung" { + measurement, err = h.readCSV(h.cfg.MeasurementBasePath + "/Eafonyoung/data/" + deref(m.Form) + "/" + headPhone + ".csv") + if err != nil || measurement == nil { + return "", nil + } + } + + // 构建请求 + var targetParam any + var targetRaw map[string]any + + if bool(t.ReadCSV) { + targetRaw, err = h.readCSV(h.cfg.TargetBasePath + "/" + deref(t.File)) + if err != nil || targetRaw == nil { + return "", nil + } + targetParam = targetRaw + } else { + targetParam = targetName + } + + // 解析 bassBoost + var bassBoost map[string]any + if t.BassBoost != nil { + if err := json.Unmarshal([]byte(*t.BassBoost), &bassBoost); err != nil { + h.log.Warn("parse bassBoost failed", zap.Error(err)) + bassBoost = map[string]any{"gain": 0, "fc": 100, "q": 0.7} + } + } else { + bassBoost = map[string]any{"gain": 0, "fc": 100, "q": 0.7} + } + + resp, err := h.reqEqualize(headPhone, measurement, targetParam, bassBoost, deref(m.Source), deref(m.Rig)) + if err != nil { + return "", err + } + + return resp, nil +} + +// reqEqualize 调用 autoeq API +func (h *CurveHandler) reqEqualize(headPhone string, measurement map[string]any, target any, bassBoost map[string]any, source, rig string) (string, error) { + gain := floatVal(bassBoost, "gain", 0) + fc := intVal(bassBoost, "fc", 100) + q := floatVal(bassBoost, "q", 0.7) + + reqBody := map[string]any{ + "target": target, + "sound_signature": nil, + "sound_signature_smoothing_window_size": 1, + "bass_boost_gain": gain, + "bass_boost_fc": fc, + "bass_boost_q": q, + "treble_boost_gain": 0, + "treble_boost_fc": 10000, + "treble_boost_q": 0.7, + "tilt": 0, + "fs": 48000, + "bit_depth": 16, + "phase": "minimum", + "f_res": 16, + "preamp": 0, + "max_gain": 12, + "max_slope": 18, + "window_size": 0.08, + "treble_window_size": 2, + "treble_f_lower": 6000, + "treble_f_upper": 8000, + "treble_gain_k": 1, + "graphic_eq": false, + "parametric_eq": true, + "fixed_band_eq": false, + "convolution_eq": false, + "source": source, + "rig": rig, + "parametric_eq_config": "MINIDSP_IL_DSP", + "response": map[string]any{ + "fr_f_step": 1.02, + "base64fp16": false, + "fr_fields": []string{"raw"}, + }, + } + + // measurement 或 headPhone 二选一 + if measurement != nil { + reqBody["measurement"] = measurement + } else if headPhone != "" { + reqBody["name"] = headPhone + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal eq request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, h.cfg.APIURL, bytes.NewReader(jsonData)) + if err != nil { + return "", fmt.Errorf("create eq request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + httpResp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("call eq api: %w", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return "", fmt.Errorf("read eq response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + h.log.Warn("eq api returned non-200", zap.Int("status", httpResp.StatusCode), zap.String("body", string(body))) + return "", fmt.Errorf("eq api status: %d", httpResp.StatusCode) + } + + var eqResp map[string]any + if err := json.Unmarshal(body, &eqResp); err != nil { + return "", fmt.Errorf("unmarshal eq response: %w", err) + } + + parametricEq, _ := eqResp["parametric_eq"].(map[string]any) + if parametricEq == nil { + result := map[string]any{ + "code": 200, + "msg": "req param error", + "param": reqBody, + } + resultJSON, _ := json.Marshal(result) + return string(resultJSON), nil + } + + result := map[string]any{ + "code": 200, + "msg": "ok", + "parametric_eq": eqResp["parametric_eq"], + "fr": eqResp["fr"], + } + resultJSON, _ := json.Marshal(result) + return string(resultJSON), nil +} + +// readCSV 读取 CSV 文件并返回 {frequency: [...], raw: [...]} +func (h *CurveHandler) readCSV(path string) (map[string]any, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + h.log.Info("csv file not found", zap.String("path", path)) + return nil, nil + } + return nil, fmt.Errorf("open csv: %w", err) + } + defer f.Close() + + reader := csv.NewReader(f) + // 跳过表头 + if _, err := reader.Read(); err != nil { + return nil, fmt.Errorf("read csv header: %w", err) + } + + frequency := make([]float64, 0) + raw := make([]float64, 0) + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read csv row: %w", err) + } + if len(record) < 2 { + continue + } + freq, err1 := strconv.ParseFloat(record[0], 64) + val, err2 := strconv.ParseFloat(record[1], 64) + if err1 != nil || err2 != nil { + continue + } + frequency = append(frequency, freq) + raw = append(raw, val) + } + + if len(frequency) == 0 { + return nil, nil + } + + return map[string]any{ + "frequency": frequency, + "raw": raw, + }, nil +} + +// 辅助函数 +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func floatVal(m map[string]any, key string, defaultVal float64) float64 { + v, ok := m[key] + if !ok { + return defaultVal + } + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case string: + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return defaultVal + } + return f + default: + return defaultVal + } +} + +func intVal(m map[string]any, key string, defaultVal int) int { + v, ok := m[key] + if !ok { + return defaultVal + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case string: + i, err := strconv.Atoi(n) + if err != nil { + return defaultVal + } + return i + default: + return defaultVal + } +} \ No newline at end of file diff --git a/internal/handler/ota.go b/internal/handler/ota.go index 11d30f1..f41ca8e 100644 --- a/internal/handler/ota.go +++ b/internal/handler/ota.go @@ -95,7 +95,7 @@ func (h *OTAHandler) GetOTA(c *gin.Context) { } // 3. 检查是否为定向升级 - if ota.Target == 0 { + if !bool(ota.Target) { // 非定向,直接返回 response.OK(c, ota) return diff --git a/internal/model/ota.go b/internal/model/ota.go index ccf9396..63e57b0 100644 --- a/internal/model/ota.go +++ b/internal/model/ota.go @@ -1,27 +1,51 @@ package model -import "time" +import ( + "database/sql" + "time" +) + +// BoolInt 数据库存储为 int(1/0),JSON 序列化为 bool(true/false) +type BoolInt bool + +func (b *BoolInt) Scan(value interface{}) error { + if value == nil { + *b = false + return nil + } + switch v := value.(type) { + case int64: + *b = v == 1 + case []byte: + *b = len(v) > 0 && v[0] == '1' + default: + *b = false + } + return nil +} + +var _ sql.Scanner = (*BoolInt)(nil) // OTA 固件升级记录 type OTA struct { - ID int `json:"id"` + ID int `json:"-"` VerCode int `json:"verCode"` VerName string `json:"verName"` URL string `json:"url"` MD5 string `json:"md5"` - Force int `json:"force"` + Force BoolInt `json:"force"` Desc *string `json:"desc,omitempty"` Model *string `json:"model,omitempty"` HW int `json:"hw"` - Target int `json:"target"` - Beta int `json:"beta"` - PawVerCode int `json:"pawVerCode"` - PawVerName string `json:"pawVerName"` - PawURL string `json:"pawUrl"` - PawMD5 string `json:"pawMd5"` + Target BoolInt `json:"target"` + Beta BoolInt `json:"beta"` + PawVerCode int `json:"-"` + PawVerName string `json:"-"` + PawURL string `json:"-"` + PawMD5 string `json:"-"` StartTime *time.Time `json:"startTime,omitempty"` EndTime *time.Time `json:"endTime,omitempty"` - Status int `json:"status"` + Status int `json:"-"` } // BlackList OTA 黑名单 diff --git a/internal/model/target.go b/internal/model/target.go new file mode 100644 index 0000000..d662828 --- /dev/null +++ b/internal/model/target.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// Target 目标曲线 +type Target struct { + ID int `json:"id"` + Label string `json:"label"` + ReadCSV BoolInt `json:"readCsv"` + File *string `json:"file,omitempty"` + BassBoost *string `json:"bassBoost,omitempty"` + CreateAt time.Time `json:"createAt"` +} \ No newline at end of file diff --git a/internal/repository/curve.go b/internal/repository/curve.go new file mode 100644 index 0000000..2217d66 --- /dev/null +++ b/internal/repository/curve.go @@ -0,0 +1,65 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "github.com/luxsin/app-api/internal/model" +) + +type CurveRepository struct { + db *sql.DB +} + +func NewCurveRepository(db *sql.DB) *CurveRepository { + return &CurveRepository{db: db} +} + +// GetModelByBrandAndName 按 brand_name + name 查询 Model(唯一索引) +func (r *CurveRepository) GetModelByBrandAndName(ctx context.Context, brandName, name string) (*model.Model, error) { + const query = `SELECT id, brand_name, name, form, rig, source, eq_key, create_at FROM model WHERE brand_name = ? AND name = ?` + + var m model.Model + var form, rig, source, eqKey sql.NullString + + err := r.db.QueryRowContext(ctx, query, brandName, name).Scan( + &m.ID, &m.BrandName, &m.Name, &form, &rig, &source, &eqKey, &m.CreateAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query model: %w", err) + } + + m.Form = nullStringPtr(form) + m.Rig = nullStringPtr(rig) + m.Source = nullStringPtr(source) + m.EqKey = nullStringPtr(eqKey) + + return &m, nil +} + +// GetTargetByLabel 按 label 查询 Target +func (r *CurveRepository) GetTargetByLabel(ctx context.Context, label string) (*model.Target, error) { + const query = `SELECT id, label, read_csv, file, bass_boost, create_at FROM target WHERE label = ?` + + var t model.Target + var file, bassBoost sql.NullString + + err := r.db.QueryRowContext(ctx, query, label).Scan( + &t.ID, &t.Label, &t.ReadCSV, &file, &bassBoost, &t.CreateAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query target: %w", err) + } + + t.File = nullStringPtr(file) + t.BassBoost = nullStringPtr(bassBoost) + + return &t, nil +} \ No newline at end of file diff --git a/internal/repository/ota.go b/internal/repository/ota.go index 19ca699..b863fd9 100644 --- a/internal/repository/ota.go +++ b/internal/repository/ota.go @@ -18,7 +18,7 @@ func NewOTARepository(db *sql.DB) *OTARepository { // GetLatestOTA 按 model+hw+beta+status=1 查询最新一条 OTA 记录(verCode 降序) func (r *OTARepository) GetLatestOTA(ctx context.Context, modelVal string, hw, beta int) (*model.OTA, error) { - const query = `SELECT id, verCode, verName, url, md5, force, desc, model, hw, target, beta, + const query = `SELECT id, verCode, verName, url, md5, ` + "`force`" + `, ` + "`desc`" + `, model, hw, target, beta, pawVerCode, pawVerName, pawUrl, pawMd5, startTime, endTime, status FROM ota WHERE model = ? AND hw = ? AND beta = ? AND status = 1 ORDER BY verCode DESC LIMIT 1` @@ -56,7 +56,7 @@ func (r *OTARepository) GetLatestOTA(ctx context.Context, modelVal string, hw, b // GetLatestOTANotInBlackList 查询最新一条不在黑名单中的 OTA 记录 func (r *OTARepository) GetLatestOTANotInBlackList(ctx context.Context, modelVal string, hw, beta int, mac string) (*model.OTA, error) { - const query = `SELECT id, verCode, verName, url, md5, force, desc, model, hw, target, beta, + const query = `SELECT id, verCode, verName, url, md5, ` + "`force`" + `, ` + "`desc`" + `, model, hw, target, beta, pawVerCode, pawVerName, pawUrl, pawMd5, startTime, endTime, status FROM ota WHERE status = 1 AND model = ? AND hw = ? AND beta = ? AND id NOT IN (SELECT ota_id FROM black_list WHERE mac = ?) @@ -95,7 +95,7 @@ func (r *OTARepository) GetLatestOTANotInBlackList(ctx context.Context, modelVal // GetLatestOTANotTarget 查询最新一条非定向的 OTA 记录 func (r *OTARepository) GetLatestOTANotTarget(ctx context.Context, modelVal string, hw, beta int) (*model.OTA, error) { - const query = `SELECT id, verCode, verName, url, md5, force, desc, model, hw, target, beta, + const query = `SELECT id, verCode, verName, url, md5, ` + "`force`" + `, ` + "`desc`" + `, model, hw, target, beta, pawVerCode, pawVerName, pawUrl, pawMd5, startTime, endTime, status FROM ota WHERE status = 1 AND model = ? AND hw = ? AND beta = ? AND target = 0 ORDER BY verCode DESC LIMIT 1` diff --git a/internal/response/response.go b/internal/response/response.go index 020cb43..1251c54 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -7,23 +7,23 @@ import ( ) type Body struct { - Code int `json:"code"` - Message string `json:"message"` - Data any `json:"data,omitempty"` + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data,omitempty"` } func OK(c *gin.Context, data any) { c.JSON(http.StatusOK, Body{ - Code: 0, - Message: "ok", - Data: data, + Code: 200, + Msg: "操作成功", + Data: data, }) } func Fail(c *gin.Context, httpStatus int, code int, message string) { c.JSON(httpStatus, Body{ - Code: code, - Message: message, + Code: code, + Msg: message, }) } diff --git a/internal/router/router.go b/internal/router/router.go index 9196a87..7628bad 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/luxsin/app-api/internal/cache" + "github.com/luxsin/app-api/internal/config" "github.com/luxsin/app-api/internal/handler" "github.com/luxsin/app-api/internal/middleware" "github.com/luxsin/app-api/internal/repository" @@ -13,7 +14,7 @@ import ( "go.uber.org/zap" ) -func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Client) *gin.Engine { +func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Client, eqCfg config.EqualizeConfig) *gin.Engine { r := gin.New() r.Use(gin.Recovery()) r.Use(middleware.RequestID()) @@ -23,11 +24,13 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl // Cache brandCache := cache.NewBrandCache(rdb) modelCache := cache.NewModelCache(rdb) + curveCache := cache.NewCurveCache(rdb) // Repository brandRepo := repository.NewBrandRepository(db, brandCache) modelRepo := repository.NewModelRepository(db, modelCache) otaRepo := repository.NewOTARepository(db) + curveRepo := repository.NewCurveRepository(db) // Handler health := handler.NewHealthHandler() @@ -36,6 +39,7 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl modelList := handler.NewModelListHandler(searchClient, log) device := handler.NewDeviceHandler(rdb, log) ota := handler.NewOTAHandler(otaRepo, log) + curve := handler.NewCurveHandler(curveRepo, curveCache, eqCfg, log) v1 := r.Group("/api/v1") { @@ -49,6 +53,7 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl audio.GET("/modelList", modelList.ModelList) audio.GET("/reportDevInfo", device.ReportDevInfo) audio.GET("/ota", ota.GetOTA) + audio.GET("/getCurve", curve.GetCurve) } return r diff --git a/olds/EqualizeController.java b/olds/EqualizeController.java new file mode 100644 index 0000000..ecc5561 --- /dev/null +++ b/olds/EqualizeController.java @@ -0,0 +1,154 @@ +package com.luxsin.app.controller; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.luxsin.app.annotation.Base64Response; +import com.luxsin.app.bean.OTA; +import com.luxsin.app.service.BrandService; +import com.luxsin.app.service.ModelService; +import com.luxsin.app.service.OTAService; +import lombok.extern.slf4j.Slf4j; +import org.eafon.data.EResponseTool; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.Map; + +@Slf4j +@RestController +@CrossOrigin(origins = "*") +public class EqualizeController { + private final ModelService modelService; + + private final OTAService otaService; + + private final RedisTemplate redisTemplate; + + private final BrandService brandService; + public EqualizeController(ModelService modelService, OTAService otaService, RedisTemplate redisTemplate, BrandService brandService) { + this.modelService = modelService; + this.otaService = otaService; + this.redisTemplate = redisTemplate; + this.brandService = brandService; + } + + /** + * 耳机列表 + * @param key + * @param count + * @return + */ + @GetMapping("modelList") + @Base64Response + public String getModelList(String key, @RequestParam(value = "count", defaultValue = "100") int count){ + return modelService.modelList(key, count); + } + + /** + * 目标曲线 + * @param brand + * @param name + * @param target + * @return + */ + @GetMapping("getCurve") + @Base64Response + public String getCurve(String brand, String name, String target, @RequestParam(value = "includeRaw", defaultValue = "false") boolean includeRaw){ + String result = modelService.getCurvePoint(brand, name, target); + if(!includeRaw){ + JSONObject resp = JSONUtil.parseObj(result); + if(resp.containsKey("code") && resp.getInt("code") != 0){ + resp.remove("fr"); + } + return resp.toString(); + } + return result; + } + + @GetMapping("modelCurve") + @Base64Response + public String modelCurve(String brand, String name){ + return modelService.getModelCurve(brand, name); + } + + @GetMapping("test/modelCurve") + public String testModelCurve(String brand, String name){ + return modelService.getModelCurve(brand, name); + } + + @GetMapping("test/getCurve") + public String testGetCurve(String brand, String name, String target, + @RequestParam(value = "includeRaw", defaultValue = "false") boolean includeRaw){ + String result = modelService.getCurvePoint(brand, name, target); + if(!includeRaw){ + JSONObject resp = JSONUtil.parseObj(result); + if(resp.containsKey("code") && resp.getInt("code") != 0){ + resp.remove("fr"); + } + return resp.toString(); + } + return result; + } + + /** + * 升级 + * @param model + * @param hw + * @param mac + * @param beta + * @return + */ + @GetMapping("ota") + public EResponseTool OTA(String model, String hw, @RequestParam(value = "mac", defaultValue = "null") String mac, + @RequestParam(value = "beta", defaultValue = "0") int beta){ + return otaService.getOTA(model, hw, mac, beta); + } + + /** + * 上报用户设备信息 + * @param mac + * @param model + * @return + */ + @GetMapping("reportDevInfo") + public EResponseTool reportDevInfo(String mac, String model,String ver, + @RequestHeader(value = "X-Forwarded-For", required = false) String xForwardedFor){ + if(StrUtil.isEmpty(mac) || StrUtil.isEmpty(model)){ + return EResponseTool.error(EResponseTool.ResultCode.VALIDATE_FAILED); + } + log.info("report dev info remote ip = {}, now time = {}", xForwardedFor, DateUtil.now()); + ver = null == ver ? "" : ver; + xForwardedFor = null == xForwardedFor ? "" : xForwardedFor; + + redisTemplate.opsForHash().put("devices", mac, JSONUtil.toJsonStr(Map.of("mac_addr", mac, "model", model, + "active_date", LocalDate.now().toString(), + "ip_addr", xForwardedFor, + "ver", ver))); + return EResponseTool.success(); + } + + /** + * 获取品牌列表 + * @param brandName + * @return + */ + @GetMapping("getBrand") + @Base64Response + public String getBrand(String brandName){ + return brandService.brandList(brandName); + } + + /** + * 获取型号列表 + * @param brandName + * @param modelName + * @return + */ + @GetMapping("getModel") + @Base64Response + public String getModel(String brandName, String modelName){ + return modelService.getModels(brandName, modelName); + } +} diff --git a/olds/ModelService.java b/olds/ModelService.java new file mode 100644 index 0000000..8c136ad --- /dev/null +++ b/olds/ModelService.java @@ -0,0 +1,280 @@ +package com.luxsin.app.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.luxsin.app.bean.Model; +import com.luxsin.app.bean.Target; +import com.luxsin.app.bean.table.ModelTableDef; +import com.luxsin.app.bean.table.TargetTableDef; +import com.luxsin.app.config.MyConfig; +import com.luxsin.app.dao.ModelMapper; +import com.luxsin.app.dao.TargetMapper; +import com.meilisearch.sdk.Index; +import com.meilisearch.sdk.SearchRequest; +import com.meilisearch.sdk.model.Searchable; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.eafon.data.EResponseTool; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class ModelService extends ServiceImpl { + private final RedisTemplate redisTemplate; + + private final TargetMapper targetMapper; + + private final MyConfig myConfig; + + private final Index modelIdx; + + public ModelService(RedisTemplate redisTemplate, TargetMapper targetMapper, + MyConfig myConfig, Index modelIdx) { + this.redisTemplate = redisTemplate; + this.targetMapper = targetMapper; + this.myConfig = myConfig; + this.modelIdx = modelIdx; + } + + /** + * 获取手机列表 + * @param key + * @param count + * @return + */ + public String modelList(String key, int count){ + List list = Collections.emptyList(); + Searchable searchable = modelIdx.search(SearchRequest.builder() + .q(key) + .attributesToRetrieve(new String[]{"rig", "form", "name", "brand_name", "source", "eq_key"}) + .limit(count) + .build()); + if(!searchable.getHits().isEmpty()){ + return JSONUtil.toJsonStr(searchable.getHits()); + }else { + return JSONUtil.toJsonStr(list); + } + } + + /** + * 获取目标曲线 + * @param brand + * @param name + * @param targetName + * @param raw 是否只是需要机型的raw数据 + * @return + */ + public String getCurvePoint(String brand, String name, String targetName){ + if(StrUtil.isEmpty(brand) || StrUtil.isEmpty(name) || StrUtil.isEmpty(targetName)){ + return EResponseTool.error(EResponseTool.ResultCode.VALIDATE_FAILED).toString(); + } + String cacheKey = brand + " " + name; + String cache = String.valueOf(redisTemplate.opsForHash().get(cacheKey, targetName)); + if(StrUtil.isEmpty(cache) || "null".equals(cache)){ + String lockKey = cacheKey+":"+targetName+":lock"; + boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS)); + if(locked){ + try { + cache = String.valueOf(redisTemplate.opsForHash().get(cacheKey, targetName)); + if(StrUtil.isEmpty(cache) || "null".equals(cache)){ + //redis中没有数据的,需要请求eq接口 + JSONObject resp = getCurvePointFromPEQ(brand, name, targetName); + if(null!=resp && resp.getInt("code") == EResponseTool.ResultCode.SUCCESS.getCode()){ + //将请求接口得到的数据,放入缓存 + redisTemplate.opsForHash().put(brand+" "+name, targetName, resp.toString()); + return resp.toString(); + } + } + }finally { + redisTemplate.delete(lockKey); + } + }else{ + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return getCurvePoint(brand, name, targetName); + } + }else{ + log.info("brand: {} name: {} target {} read cache.", brand, name, targetName); + return cache; + } + + return EResponseTool.error(EResponseTool.ResultCode.EMPTY).toString(); + } + + private JSONObject getCurvePointFromPEQ(String brand, String name, String targetName){ + Model model = getOne(QueryWrapper.create().where(ModelTableDef.MODEL.BRAND_NAME.eq(brand)). + and(ModelTableDef.MODEL.NAME.eq(name))); + if(!Objects.isNull(model)){ + Target target = targetMapper.selectOneByQuery(QueryWrapper.create().where(TargetTableDef.TARGET.LABEL.eq(targetName))); + if(!Objects.isNull(target)){ + String eq_key = model.getEq_key(); + String headPhone = brand+" "+name; + if(StrUtil.isNotEmpty(eq_key) && eq_key.equals("name")){ + headPhone = name; + } + + JSONObject measurement = null; + if("Eafonyoung".equals(model.getSource())){ + measurement = getCSV(myConfig.getMeasurementBasePath()+"/Eafonyoung/data/"+model.getForm()+"/"+headPhone+".csv"); + if(Objects.isNull(measurement)){ + return null; + } + } + + JSONObject resp; + if(target.isRead_csv()){ + //读取自定义的target + JSONObject targetRaw = getCSV(myConfig.getTargetBasePath()+"/"+target.getFile()); + resp = reqEqualize(headPhone, measurement, targetRaw, JSONUtil.parseObj(target.getBassBoost()), + model.getSource(), model.getRig()); + }else{ + resp = reqEqualize(headPhone, measurement, targetName, JSONUtil.parseObj(target.getBassBoost()), + model.getSource(), model.getRig()); + } + + if(resp.getInt("code") == EResponseTool.ResultCode.SUCCESS.getCode()){ + return resp; + }else{ + log.info("请求reqEqualize接口失败,没有数据"); + } + } + } + return null; + } + + + private JSONObject getCSV(String path){ + if(FileUtil.exist(path)){ + List list = FileUtil.readLines(path, "UTF-8"); + JSONArray frequency = new JSONArray(); + JSONArray raw = new JSONArray(); + for(int i=1;i reqMap = new HashMap<>(); + reqMap.put("target", target); + reqMap.put("sound_signature", null); + reqMap.put("sound_signature_smoothing_window_size", 1); + reqMap.put("bass_boost_gain", bassBoost.getFloat("gain")); + reqMap.put("bass_boost_fc", bassBoost.getInt("fc")); + reqMap.put("bass_boost_q", bassBoost.getFloat("q")); + reqMap.put("treble_boost_gain",0); + reqMap.put("treble_boost_fc", 10000); + reqMap.put("treble_boost_q", 0.7); + reqMap.put("tilt", 0); + reqMap.put("fs",48000); + reqMap.put("bit_depth", 16); + reqMap.put("phase", "minimum"); + reqMap.put("f_res", 16); + reqMap.put("preamp",0); + reqMap.put("max_gain" ,12); + reqMap.put("max_slope", 18); + reqMap.put("window_size", 0.08); + reqMap.put("treble_window_size", 2); + reqMap.put("treble_f_lower", 6000); + reqMap.put("treble_f_upper", 8000); + reqMap.put("treble_gain_k", 1); + reqMap.put("graphic_eq", false); + reqMap.put("parametric_eq", true); + reqMap.put("fixed_band_eq", false); + reqMap.put("convolution_eq", false); + + + if(null!=measurement){ + reqMap.put("measurement", measurement); + } + else if(StrUtil.isNotEmpty(headPhone)){ + reqMap.put("name", headPhone); + } + + reqMap.put("source", source); + reqMap.put("rig", rig); + reqMap.put("parametric_eq_config", "MINIDSP_IL_DSP"); + + Map response = new HashMap<>(); + JSONArray fr_fields = new JSONArray(); + fr_fields.addAll(List.of("raw")); + response.put("fr_f_step", 1.02); + response.put("base64fp16" ,false); + response.put("fr_fields", fr_fields); + + reqMap.put("response", response); + + String resp = HttpUtil.post("https://autoeq.app/equalize", new JSONObject(reqMap).toString(), 10000); + JSONObject result = new JSONObject(); + if(StrUtil.isNotEmpty(resp)){ + JSONObject respJson = JSONUtil.parseObj(resp); + JSONObject parametric_eq = respJson.getJSONObject("parametric_eq"); + if(Objects.isNull(parametric_eq)){ + result.putOnce("code", 0); + result.putOnce("msg", "req param error"); + result.putOnce("param", reqMap); + return result; + }else { + result.putOnce("parametric_eq", respJson.getJSONObject("parametric_eq")); + result.putOnce("fr", respJson.getJSONObject("fr")); + } + } + result.putOnce("code", EResponseTool.ResultCode.SUCCESS.getCode()); + result.putOnce("msg", "ok"); + return result; + } + + public JSONObject reqEqualize(String headPhone, JSONObject measurement, String target, JSONObject bassBoost, String source, String rig) { + return reqEqualizeInternal(headPhone, measurement, target, bassBoost, source, rig); + } + + public JSONObject reqEqualize(String headPhone, JSONObject measurement, JSONObject target, JSONObject bassBoost, String source, String rig) { + return reqEqualizeInternal(headPhone, measurement, target, bassBoost, source, rig); + } + + public String getModels(String brandName, String modelName){ + List list; + if(StrUtil.isNotEmpty(brandName)){ + list = list(QueryWrapper.create().where(ModelTableDef.MODEL.BRAND_NAME.eq(brandName)).orderBy(ModelTableDef.MODEL.NAME, true)); + } + else if(StrUtil.isNotEmpty(modelName)){ + list = list(QueryWrapper.create().where(ModelTableDef.MODEL.NAME.like(modelName)).orderBy(ModelTableDef.MODEL.NAME, true)); + } + else{ + list = Collections.emptyList(); + } + return JSONUtil.toJsonStr(list); + } + + public String getModelCurve(String brand, String name){ + return getCurvePoint(brand, name, "Harman over-ear 2018"); + } +}