修复 curve 接口的一些问题
This commit is contained in:
@@ -5,4 +5,8 @@ GIN_MODE=release
|
||||
|
||||
# Production uses built-in RDS host when DATABASE_HOST is unset.
|
||||
# Password must be set via environment variable (never commit real password).
|
||||
# jdbc:mysql://database-1.chmuueamo72p.eu-central-1.rds.amazonaws.com:3306/audio?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&serverTimezone=Asia/Shanghai
|
||||
# young9#!UJsD219921031
|
||||
# root
|
||||
|
||||
DATABASE_PASSWORD=your-production-password
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /server .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./server"]
|
||||
+8
-4
@@ -69,10 +69,14 @@ func main() {
|
||||
// 启动时预热缓存
|
||||
warmUpCache(log, db, rdb)
|
||||
|
||||
// 启动定时刷入任务:每 5 分钟从 Redis 刷入数据库
|
||||
deviceRepo := repository.NewDeviceRepository(db)
|
||||
devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log)
|
||||
devicePersistTask.Start(5 * time.Minute)
|
||||
// 启动定时刷入任务:每 5 分钟从 Redis 刷入数据库(仅 ENABLE_PERSIST_TASK=true 的容器运行)
|
||||
if cfg.EnablePersistTask {
|
||||
deviceRepo := repository.NewDeviceRepository(db)
|
||||
devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log)
|
||||
devicePersistTask.Start(5 * time.Minute)
|
||||
} else {
|
||||
log.Info("device persist task disabled")
|
||||
}
|
||||
|
||||
engine := router.New(log, db, searchClient, rdb, cfg.Equalize)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: app-api
|
||||
restart: always
|
||||
ports:
|
||||
- "8084:8080"
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- APP_HOST=0.0.0.0
|
||||
- APP_PORT=8080
|
||||
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-eafon123!}
|
||||
- MEILISEARCH_API_KEY=${MEILISEARCH_API_KEY:-young9#!UJsD219921031}
|
||||
- ENABLE_PERSIST_TASK=false
|
||||
- MEASUREMENT_BASE_PATH=/app/measurements
|
||||
- TARGET_BASE_PATH=/app/targets
|
||||
volumes:
|
||||
- /data/project/autoeq/measurements:/app/measurements:ro
|
||||
- /data/project/autoeq/targets:/app/targets:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
+16
-14
@@ -7,13 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Env string
|
||||
Host string
|
||||
Port int
|
||||
Database DatabaseConfig
|
||||
Meilisearch MeilisearchConfig
|
||||
Redis RedisConfig
|
||||
Equalize EqualizeConfig
|
||||
Env string
|
||||
Host string
|
||||
Port int
|
||||
Database DatabaseConfig
|
||||
Meilisearch MeilisearchConfig
|
||||
Redis RedisConfig
|
||||
Equalize EqualizeConfig
|
||||
EnablePersistTask bool
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -45,13 +46,14 @@ func Load() (*Config, error) {
|
||||
eq := loadEqualize(env)
|
||||
|
||||
return &Config{
|
||||
Env: env,
|
||||
Host: getEnv("APP_HOST", "0.0.0.0"),
|
||||
Port: port,
|
||||
Database: db,
|
||||
Meilisearch: ms,
|
||||
Redis: rd,
|
||||
Equalize: eq,
|
||||
Env: env,
|
||||
Host: getEnv("APP_HOST", "0.0.0.0"),
|
||||
Port: port,
|
||||
Database: db,
|
||||
Meilisearch: ms,
|
||||
Redis: rd,
|
||||
Equalize: eq,
|
||||
EnablePersistTask: getEnv("ENABLE_PERSIST_TASK", "false") == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -3,32 +3,32 @@ package config
|
||||
import "os"
|
||||
|
||||
type EqualizeConfig struct {
|
||||
APIURL string
|
||||
APIURL string
|
||||
MeasurementBasePath string
|
||||
TargetBasePath 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", ""),
|
||||
MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", "/app/measurements"),
|
||||
TargetBasePath: getEnv("TARGET_BASE_PATH", "/app/targets"),
|
||||
}
|
||||
}
|
||||
|
||||
switch env {
|
||||
case "production":
|
||||
return EqualizeConfig{
|
||||
APIURL: "https://autoeq.app/equalize",
|
||||
MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", ""),
|
||||
TargetBasePath: getEnv("TARGET_BASE_PATH", ""),
|
||||
APIURL: "http://172.31.18.70:8000/equalize",
|
||||
MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", "/app/measurements"),
|
||||
TargetBasePath: getEnv("TARGET_BASE_PATH", "/app/targets"),
|
||||
}
|
||||
default:
|
||||
return EqualizeConfig{
|
||||
APIURL: "https://autoeq.app/equalize",
|
||||
MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", ""),
|
||||
TargetBasePath: getEnv("TARGET_BASE_PATH", ""),
|
||||
MeasurementBasePath: getEnv("MEASUREMENT_BASE_PATH", "/app/measurements"),
|
||||
TargetBasePath: getEnv("TARGET_BASE_PATH", "/app/targets"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/luxsin/app-api/internal/config"
|
||||
)
|
||||
|
||||
@@ -19,9 +19,10 @@ func Open(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
DBName: cfg.Name,
|
||||
Params: map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
"parseTime": "True",
|
||||
"loc": "Local",
|
||||
"charset": "utf8mb4",
|
||||
"parseTime": "True",
|
||||
"loc": "Local",
|
||||
"allowNativePasswords": "true",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+88
-44
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/luxsin/app-api/internal/config"
|
||||
"github.com/luxsin/app-api/internal/repository"
|
||||
"github.com/luxsin/app-api/internal/response"
|
||||
"github.com/luxsin/app-api/pkg/encode"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -33,12 +35,12 @@ func NewCurveHandler(repo *repository.CurveRepository, curveCache *cache.CurveCa
|
||||
}
|
||||
|
||||
// GetCurve 获取目标曲线
|
||||
// GET /audio/getCurve?brand=xxx&name=xxx&target=xxx&includeRaw=false
|
||||
// GET /audio/getCurve?brand=xxx&name=xxx&target=xxx&base64Resp=true
|
||||
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"
|
||||
brand := strings.TrimSpace(queryParam(c, "brand"))
|
||||
name := strings.TrimSpace(queryParam(c, "name"))
|
||||
target := strings.TrimSpace(queryParam(c, "target"))
|
||||
base64Resp := encode.ParseBase64Param(c)
|
||||
|
||||
if brand == "" || name == "" || target == "" {
|
||||
response.BadRequest(c, "brand、name和target参数不能为空")
|
||||
@@ -59,22 +61,33 @@ func (h *CurveHandler) GetCurve(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JSON 结果
|
||||
// 解析 JSON 结果,只提取 parametric_eq
|
||||
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")
|
||||
}
|
||||
parametricEq, _ := resp["parametric_eq"]
|
||||
|
||||
resultData := gin.H{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"parametric_eq": parametricEq,
|
||||
}
|
||||
|
||||
response.OK(c, resp)
|
||||
if base64Resp {
|
||||
encoded, err := encode.EncodeJSON(resultData)
|
||||
if err != nil {
|
||||
h.log.Error("encode response failed", zap.Error(err))
|
||||
response.InternalError(c, "编码响应失败")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, encoded)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resultData)
|
||||
}
|
||||
|
||||
// getCurvePoint 获取曲线数据:先查缓存,缓存不存在则请求 EQ 接口并缓存结果
|
||||
@@ -92,6 +105,7 @@ func (h *CurveHandler) getCurvePoint(ctx context.Context, brand, name, target st
|
||||
|
||||
// 获取了锁,缓存仍然为空,需要请求 EQ 接口
|
||||
if acquired {
|
||||
h.log.Info("curve cache miss, requesting eq api", zap.String("brand", brand), zap.String("name", name), zap.String("target", target))
|
||||
result, eqErr := h.getCurvePointFromPEQ(ctx, brand, name, target)
|
||||
if eqErr != nil {
|
||||
// 释放锁
|
||||
@@ -193,35 +207,35 @@ func (h *CurveHandler) reqEqualize(headPhone string, measurement map[string]any,
|
||||
q := floatVal(bassBoost, "q", 0.7)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"target": target,
|
||||
"sound_signature": nil,
|
||||
"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",
|
||||
"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,
|
||||
@@ -247,9 +261,17 @@ func (h *CurveHandler) reqEqualize(headPhone string, measurement map[string]any,
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
start := time.Now()
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
httpResp, err := client.Do(httpReq)
|
||||
elapsed := time.Since(start)
|
||||
h.log.Info("eq api response",
|
||||
zap.String("headPhone", headPhone),
|
||||
zap.Duration("latency", elapsed),
|
||||
zap.String("url", h.cfg.APIURL),
|
||||
)
|
||||
if err != nil {
|
||||
h.log.Error("eq api request failed", zap.Duration("latency", elapsed), zap.Error(err))
|
||||
return "", fmt.Errorf("call eq api: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
@@ -272,8 +294,8 @@ func (h *CurveHandler) reqEqualize(headPhone string, measurement map[string]any,
|
||||
parametricEq, _ := eqResp["parametric_eq"].(map[string]any)
|
||||
if parametricEq == nil {
|
||||
result := map[string]any{
|
||||
"code": 200,
|
||||
"msg": "req param error",
|
||||
"code": 200,
|
||||
"msg": "req param error",
|
||||
"param": reqBody,
|
||||
}
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
@@ -389,4 +411,26 @@ func intVal(m map[string]any, key string, defaultVal int) int {
|
||||
default:
|
||||
return defaultVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryParam 从 URL 原始 query 中获取参数,保留 + 为字面量而非空格
|
||||
func queryParam(c *gin.Context, key string) string {
|
||||
vals, ok := c.Request.URL.Query()[key]
|
||||
if !ok || len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
// c.Query() 会把 + 解码为空格,这里从原始 query 手动解码,+ 保留为 +
|
||||
if strings.Contains(vals[0], " ") {
|
||||
rawQuery := c.Request.URL.RawQuery
|
||||
for _, pair := range strings.Split(rawQuery, "&") {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 && kv[0] == key {
|
||||
decoded, err := url.PathUnescape(strings.ReplaceAll(kv[1], "+", "%2B"))
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals[0]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import (
|
||||
|
||||
func Logger(log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 跳过 health 检查的日志
|
||||
if c.Request.URL.Path == "/api/v1/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
@@ -9,5 +9,5 @@ type Target struct {
|
||||
ReadCSV BoolInt `json:"readCsv"`
|
||||
File *string `json:"file,omitempty"`
|
||||
BassBoost *string `json:"bassBoost,omitempty"`
|
||||
CreateAt time.Time `json:"createAt"`
|
||||
}
|
||||
AddTime time.Time `json:"addTime"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewCurveRepository(db *sql.DB) *CurveRepository {
|
||||
|
||||
// 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 = ?`
|
||||
const query = `SELECT id, brand_name, name, form, rig, source, eq_key, create_at FROM model WHERE BINARY brand_name = ? AND BINARY name = ?`
|
||||
|
||||
var m model.Model
|
||||
var form, rig, source, eqKey sql.NullString
|
||||
@@ -43,13 +43,13 @@ func (r *CurveRepository) GetModelByBrandAndName(ctx context.Context, brandName,
|
||||
|
||||
// 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 = ?`
|
||||
const query = `SELECT id, label, read_csv, file, bassBoost, addtime FROM target WHERE BINARY 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,
|
||||
&t.ID, &t.Label, &t.ReadCSV, &file, &bassBoost, &t.AddTime,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -62,4 +62,4 @@ func (r *CurveRepository) GetTargetByLabel(ctx context.Context, label string) (*
|
||||
t.BassBoost = nullStringPtr(bassBoost)
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user