首次提交

This commit is contained in:
yangy
2026-05-21 15:20:12 +08:00
commit 3224041990
32 changed files with 1504 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
package cache
import (
"fmt"
"github.com/luxsin/app-api/internal/config"
"github.com/redis/go-redis/v9"
)
func NewClient(cfg config.RedisConfig) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.Database,
})
}
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Env string
Host string
Port int
Database DatabaseConfig
Meilisearch MeilisearchConfig
Redis RedisConfig
}
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("APP_PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid APP_PORT: %w", err)
}
env := getEnv("APP_ENV", "development")
db, err := loadDatabase(env)
if err != nil {
return nil, err
}
if err := db.validate(env); err != nil {
return nil, err
}
ms := loadMeilisearch(env)
if err := ms.validate(); err != nil {
return nil, err
}
rd := loadRedis(env)
if err := rd.validate(); err != nil {
return nil, err
}
return &Config{
Env: env,
Host: getEnv("APP_HOST", "0.0.0.0"),
Port: port,
Database: db,
Meilisearch: ms,
Redis: rd,
}, nil
}
func (c *Config) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"fmt"
"os"
"strconv"
)
type DatabaseConfig struct {
Host string
Port int
Name string
User string
Password string
}
func loadDatabase(env string) (DatabaseConfig, error) {
if os.Getenv("DATABASE_HOST") != "" {
return databaseFromEnv()
}
switch env {
case "production":
return DatabaseConfig{
Host: "database-1.chmuueamo72p.eu-central-1.rds.amazonaws.com",
Port: 3306,
Name: "audio",
User: "root",
Password: os.Getenv("DATABASE_PASSWORD"),
}, nil
default:
return DatabaseConfig{
Host: "localhost",
Port: 3306,
Name: "audio",
User: "root",
Password: "root123",
}, nil
}
}
func databaseFromEnv() (DatabaseConfig, error) {
port, err := strconv.Atoi(getEnv("DATABASE_PORT", "3306"))
if err != nil {
return DatabaseConfig{}, fmt.Errorf("invalid DATABASE_PORT: %w", err)
}
return DatabaseConfig{
Host: os.Getenv("DATABASE_HOST"),
Port: port,
Name: getEnv("DATABASE_NAME", "audio"),
User: getEnv("DATABASE_USER", "root"),
Password: os.Getenv("DATABASE_PASSWORD"),
}, nil
}
func (d DatabaseConfig) validate(env string) error {
if d.Host == "" {
return fmt.Errorf("database host is required")
}
if d.Name == "" {
return fmt.Errorf("database name is required")
}
if d.User == "" {
return fmt.Errorf("database user is required")
}
if env == "production" && d.Password == "" {
return fmt.Errorf("DATABASE_PASSWORD is required in production")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"fmt"
"os"
)
type MeilisearchConfig struct {
Host string
APIKey string
Index string
}
func loadMeilisearch(env string) MeilisearchConfig {
if os.Getenv("MEILISEARCH_HOST") != "" {
return MeilisearchConfig{
Host: os.Getenv("MEILISEARCH_HOST"),
APIKey: os.Getenv("MEILISEARCH_API_KEY"),
Index: getEnv("MEILISEARCH_INDEX", "models"),
}
}
switch env {
case "production":
return MeilisearchConfig{
Host: "http://ip-172-31-22-170.eu-central-1.compute.internal:7700",
APIKey: getEnv("MEILISEARCH_API_KEY", "young9#!UJsD219921031"),
Index: "models",
}
default:
return MeilisearchConfig{
Host: "http://ec2-18-184-205-87.eu-central-1.compute.amazonaws.com:7700",
APIKey: getEnv("MEILISEARCH_API_KEY", "young9#!UJsD219921031"),
Index: "models",
}
}
}
func (m MeilisearchConfig) validate() error {
if m.Host == "" {
return fmt.Errorf("meilisearch host is required")
}
if m.APIKey == "" {
return fmt.Errorf("meilisearch api key is required")
}
if m.Index == "" {
return fmt.Errorf("meilisearch index is required")
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"fmt"
"os"
"strconv"
)
type RedisConfig struct {
Host string
Port int
Password string
Database int
}
func loadRedis(env string) RedisConfig {
if os.Getenv("REDIS_HOST") != "" {
return redisFromEnv()
}
switch env {
case "production":
return RedisConfig{
Host: "172.31.38.162",
Port: 16279,
Password: getEnv("REDIS_PASSWORD", "eafon123!"),
Database: 1,
}
default:
return RedisConfig{
Host: "ec2-3-69-138-29.eu-central-1.compute.amazonaws.com",
Port: 16279,
Password: getEnv("REDIS_PASSWORD", "eafon123!"),
Database: 1,
}
}
}
func redisFromEnv() RedisConfig {
port, _ := strconv.Atoi(getEnv("REDIS_PORT", "16279"))
db, _ := strconv.Atoi(getEnv("REDIS_DATABASE", "1"))
return RedisConfig{
Host: os.Getenv("REDIS_HOST"),
Port: port,
Password: os.Getenv("REDIS_PASSWORD"),
Database: db,
}
}
func (r RedisConfig) validate() error {
if r.Host == "" {
return fmt.Errorf("redis host is required")
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package database
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/go-sql-driver/mysql"
"github.com/luxsin/app-api/internal/config"
)
func Open(cfg config.DatabaseConfig) (*sql.DB, error) {
mc := mysql.Config{
User: cfg.User,
Passwd: cfg.Password,
Net: "tcp",
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
DBName: cfg.Name,
Params: map[string]string{
"charset": "utf8mb4",
"parseTime": "True",
"loc": "Local",
},
}
db, err := sql.Open("mysql", mc.FormatDSN())
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}
+49
View File
@@ -0,0 +1,49 @@
package handler
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"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"
)
type BrandHandler struct {
repo *repository.BrandRepository
log *zap.Logger
}
func NewBrandHandler(db *sql.DB, log *zap.Logger) *BrandHandler {
return &BrandHandler{
repo: repository.NewBrandRepository(db),
log: log,
}
}
func (h *BrandHandler) GetBrand(c *gin.Context) {
brandName := c.Query("brandName")
base64Resp := encode.ParseBase64Param(c)
list, err := h.repo.List(c.Request.Context(), brandName)
if err != nil {
h.log.Error("get brand list failed", zap.Error(err))
response.InternalError(c, "failed to get brand list")
return
}
if base64Resp {
encoded, err := encode.EncodeJSON(list)
if err != nil {
h.log.Error("encode response failed", zap.Error(err))
response.InternalError(c, "failed to encode response")
return
}
c.String(http.StatusOK, encoded)
return
}
c.JSON(http.StatusOK, list)
}
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
type DeviceHandler struct {
redis *redis.Client
log *zap.Logger
}
func NewDeviceHandler(redis *redis.Client, log *zap.Logger) *DeviceHandler {
return &DeviceHandler{
redis: redis,
log: log,
}
}
func (h *DeviceHandler) ReportDevInfo(c *gin.Context) {
mac := strings.TrimSpace(c.Query("mac"))
model := strings.TrimSpace(c.Query("model"))
ver := strings.TrimSpace(c.Query("ver"))
xForwardedFor := c.GetHeader("X-Forwarded-For")
if mac == "" || model == "" {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "参数校验失败",
})
return
}
if ver == "" {
ver = ""
}
if xForwardedFor == "" {
xForwardedFor = ""
}
h.log.Info("report dev info",
zap.String("remote_ip", xForwardedFor),
zap.String("time", time.Now().Format("yyyy-MM-dd HH:mm:ss")),
)
deviceInfo := map[string]string{
"mac_addr": mac,
"model": model,
"active_date": time.Now().Format("yyyy-MM-dd"),
"ip_addr": xForwardedFor,
"ver": ver,
}
jsonData, err := json.Marshal(deviceInfo)
if err != nil {
h.log.Error("marshal device info failed", zap.Error(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "系统错误",
})
return
}
ctx := c.Request.Context()
if err := h.redis.HSet(ctx, "devices", mac, jsonData).Err(); err != nil {
h.log.Error("redis hset failed", zap.Error(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "系统错误",
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "操作成功",
})
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/luxsin/app-api/internal/response"
)
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Check(c *gin.Context) {
response.OK(c, gin.H{
"status": "up",
})
}
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"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"
)
type ModelHandler struct {
repo *repository.ModelRepository
log *zap.Logger
}
func NewModelHandler(db *sql.DB, log *zap.Logger) *ModelHandler {
return &ModelHandler{
repo: repository.NewModelRepository(db),
log: log,
}
}
func (h *ModelHandler) GetModel(c *gin.Context) {
brandName := c.Query("brandName")
modelName := c.Query("modelName")
base64Resp := encode.ParseBase64Param(c)
list, err := h.repo.List(c.Request.Context(), brandName, modelName)
if err != nil {
h.log.Error("get model list failed", zap.Error(err))
response.InternalError(c, "failed to get model list")
return
}
if base64Resp {
encoded, err := encode.EncodeJSON(list)
if err != nil {
h.log.Error("encode response failed", zap.Error(err))
response.InternalError(c, "failed to encode response")
return
}
c.String(http.StatusOK, encoded)
return
}
c.JSON(http.StatusOK, list)
}
+56
View File
@@ -0,0 +1,56 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/luxsin/app-api/internal/response"
"github.com/luxsin/app-api/internal/search"
"github.com/luxsin/app-api/pkg/encode"
"go.uber.org/zap"
)
type ModelListHandler struct {
search *search.Client
log *zap.Logger
}
func NewModelListHandler(searchClient *search.Client, log *zap.Logger) *ModelListHandler {
return &ModelListHandler{
search: searchClient,
log: log,
}
}
func (h *ModelListHandler) ModelList(c *gin.Context) {
key := c.Query("key")
base64Resp := encode.ParseBase64Param(c)
count := 100
if v := c.Query("count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
count = n
}
}
list, err := h.search.ModelList(c.Request.Context(), key, count)
if err != nil {
h.log.Error("model list search failed", zap.Error(err))
response.InternalError(c, "failed to search models")
return
}
if base64Resp {
encoded, err := encode.EncodeJSON(list)
if err != nil {
h.log.Error("encode response failed", zap.Error(err))
response.InternalError(c, "failed to encode response")
return
}
c.String(http.StatusOK, encoded)
return
}
c.JSON(http.StatusOK, list)
}
+20
View File
@@ -0,0 +1,20 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+45
View File
@@ -0,0 +1,45 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Logger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
requestID, _ := c.Get(RequestIDKey)
fields := []zap.Field{
zap.Int("status", status),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Duration("latency", latency),
zap.String("ip", c.ClientIP()),
zap.Any("request_id", requestID),
}
if query != "" {
fields = append(fields, zap.String("query", query))
}
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
if status >= 500 {
log.Error("request", fields...)
} else if status >= 400 {
log.Warn("request", fields...)
} else {
log.Info("request", fields...)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
const RequestIDKey = "X-Request-ID"
func newRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "unknown"
}
return hex.EncodeToString(b)
}
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader(RequestIDKey)
if id == "" {
id = newRequestID()
}
c.Set(RequestIDKey, id)
c.Header(RequestIDKey, id)
c.Next()
}
}
+6
View File
@@ -0,0 +1,6 @@
package model
type Brand struct {
ID int `json:"id"`
Name string `json:"name"`
}
+14
View File
@@ -0,0 +1,14 @@
package model
import "time"
type Model struct {
ID int `json:"id"`
BrandName string `json:"brandName"`
Name string `json:"name"`
Form *string `json:"form,omitempty"`
Rig *string `json:"rig,omitempty"`
Source *string `json:"source,omitempty"`
EqKey *string `json:"eqKey,omitempty"`
CreateAt time.Time `json:"createAt"`
}
+50
View File
@@ -0,0 +1,50 @@
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/luxsin/app-api/internal/model"
)
type BrandRepository struct {
db *sql.DB
}
func NewBrandRepository(db *sql.DB) *BrandRepository {
return &BrandRepository{db: db}
}
func (r *BrandRepository) List(ctx context.Context, brandName string) ([]model.Brand, error) {
query := "SELECT id, name FROM brand"
args := []any{}
if strings.TrimSpace(brandName) != "" {
query += " WHERE name LIKE ?"
args = append(args, "%"+brandName+"%")
}
query += " ORDER BY name ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query brand: %w", err)
}
defer rows.Close()
list := make([]model.Brand, 0)
for rows.Next() {
var b model.Brand
if err := rows.Scan(&b.ID, &b.Name); err != nil {
return nil, fmt.Errorf("scan brand: %w", err)
}
list = append(list, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate brand: %w", err)
}
return list, nil
}
+94
View File
@@ -0,0 +1,94 @@
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/luxsin/app-api/internal/model"
)
type ModelRepository struct {
db *sql.DB
}
func NewModelRepository(db *sql.DB) *ModelRepository {
return &ModelRepository{db: db}
}
func (r *ModelRepository) List(ctx context.Context, brandName, modelName string) ([]model.Model, error) {
brandName = strings.TrimSpace(brandName)
modelName = strings.TrimSpace(modelName)
const baseQuery = `SELECT id, brand_name, name, form, rig, source, eq_key, create_at FROM model`
var (
query string
args []any
)
switch {
case brandName != "":
query = baseQuery + " WHERE brand_name = ? ORDER BY name ASC"
args = []any{brandName}
case modelName != "":
query = baseQuery + " WHERE name LIKE ? ORDER BY name ASC"
args = []any{"%" + modelName + "%"}
default:
return []model.Model{}, nil
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query model: %w", err)
}
defer rows.Close()
list := make([]model.Model, 0)
for rows.Next() {
m, err := scanModel(rows)
if err != nil {
return nil, err
}
list = append(list, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate model: %w", err)
}
return list, nil
}
func scanModel(rows *sql.Rows) (model.Model, error) {
var m model.Model
var form, rig, source, eqKey sql.NullString
if err := rows.Scan(
&m.ID,
&m.BrandName,
&m.Name,
&form,
&rig,
&source,
&eqKey,
&m.CreateAt,
); err != nil {
return model.Model{}, fmt.Errorf("scan model: %w", err)
}
m.Form = nullStringPtr(form)
m.Rig = nullStringPtr(rig)
m.Source = nullStringPtr(source)
m.EqKey = nullStringPtr(eqKey)
return m, nil
}
func nullStringPtr(ns sql.NullString) *string {
if !ns.Valid {
return nil
}
s := ns.String
return &s
}
+36
View File
@@ -0,0 +1,36 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Body{
Code: 0,
Message: "ok",
Data: data,
})
}
func Fail(c *gin.Context, httpStatus int, code int, message string) {
c.JSON(httpStatus, Body{
Code: code,
Message: message,
})
}
func BadRequest(c *gin.Context, message string) {
Fail(c, http.StatusBadRequest, 40000, message)
}
func InternalError(c *gin.Context, message string) {
Fail(c, http.StatusInternalServerError, 50000, message)
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"database/sql"
"github.com/gin-gonic/gin"
"github.com/luxsin/app-api/internal/handler"
"github.com/luxsin/app-api/internal/middleware"
"github.com/luxsin/app-api/internal/search"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, redis *redis.Client) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestID())
r.Use(middleware.Logger(log))
r.Use(middleware.CORS())
health := handler.NewHealthHandler()
brand := handler.NewBrandHandler(db, log)
model := handler.NewModelHandler(db, log)
modelList := handler.NewModelListHandler(searchClient, log)
device := handler.NewDeviceHandler(redis, log)
v1 := r.Group("/api/v1")
{
v1.GET("/health", health.Check)
}
audio := r.Group("/audio")
{
audio.GET("/getBrand", brand.GetBrand)
audio.GET("/getModel", model.GetModel)
audio.GET("/modelList", modelList.ModelList)
audio.GET("/reportDevInfo", device.ReportDevInfo)
}
return r
}
+45
View File
@@ -0,0 +1,45 @@
package search
import (
"context"
"fmt"
"github.com/luxsin/app-api/internal/config"
"github.com/meilisearch/meilisearch-go"
)
var modelListAttributes = []string{"rig", "form", "name", "brand_name", "source", "eq_key"}
type Client struct {
index meilisearch.IndexManager
}
func NewClient(cfg config.MeilisearchConfig) *Client {
ms := meilisearch.New(cfg.Host, meilisearch.WithAPIKey(cfg.APIKey))
return &Client{index: ms.Index(cfg.Index)}
}
func (c *Client) ModelList(ctx context.Context, key string, count int) ([]map[string]any, error) {
resp, err := c.index.SearchWithContext(ctx, key, &meilisearch.SearchRequest{
Limit: int64(count),
AttributesToRetrieve: modelListAttributes,
})
if err != nil {
return nil, fmt.Errorf("meilisearch search: %w", err)
}
if len(resp.Hits) == 0 {
return []map[string]any{}, nil
}
list := make([]map[string]any, 0, len(resp.Hits))
for _, hit := range resp.Hits {
item := make(map[string]any)
if err := hit.DecodeInto(&item); err != nil {
return nil, fmt.Errorf("decode meilisearch hit: %w", err)
}
list = append(list, item)
}
return list, nil
}