diff --git a/.env.production.example b/.env.production.example index bd16196..b8f307d 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..843aa18 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/server/main.go b/cmd/server/main.go index 598d170..b09b157 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc6a7c0 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go index 6a5cf40..161e047 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/equalize.go b/internal/config/equalize.go index a4cfe13..4873d2f 100644 --- a/internal/config/equalize.go +++ b/internal/config/equalize.go @@ -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"), } } -} \ No newline at end of file +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go index c6705cd..a1dfb28 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -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", }, } diff --git a/internal/handler/curve.go b/internal/handler/curve.go index 13b3ae8..c478e75 100644 --- a/internal/handler/curve.go +++ b/internal/handler/curve.go @@ -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 } -} \ No newline at end of file +} + +// 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] +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index 42e31c3..4edc77d 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -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 diff --git a/internal/model/target.go b/internal/model/target.go index d662828..f3beb3c 100644 --- a/internal/model/target.go +++ b/internal/model/target.go @@ -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"` -} \ No newline at end of file + AddTime time.Time `json:"addTime"` +} diff --git a/internal/repository/curve.go b/internal/repository/curve.go index 2217d66..543cd69 100644 --- a/internal/repository/curve.go +++ b/internal/repository/curve.go @@ -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 -} \ No newline at end of file +} diff --git a/upload.sh b/upload.sh new file mode 100755 index 0000000..e03c6e1 --- /dev/null +++ b/upload.sh @@ -0,0 +1 @@ +scp -i ~/.ssh/aws_eafon.pem -r ./cmd/ ./internal/ ./pkg ./go.mod ./go.sum ./Dockerfile ./docker-compose.yml ubuntu@ec2-18-184-205-87.eu-central-1.compute.amazonaws.com:/data/project/app-api/