commit 3224041990166f4eb52edf5f8b768f1578532077 Author: yangy Date: Thu May 21 15:20:12 2026 +0800 首次提交 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5953c70 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Server +APP_ENV=development +APP_HOST=0.0.0.0 +APP_PORT=8080 + +# Gin mode: debug | release | test +GIN_MODE=debug + +# Database (development defaults apply when APP_ENV=development and vars are unset) +# DATABASE_HOST=localhost +# DATABASE_PORT=3306 +# DATABASE_NAME=audio +# DATABASE_USER=root +# DATABASE_PASSWORD=root123 + +# Meilisearch (development defaults apply when APP_ENV=development and vars are unset) +# MEILISEARCH_HOST=http://ec2-18-184-205-87.eu-central-1.compute.amazonaws.com:7700 +# MEILISEARCH_API_KEY=your-api-key +# MEILISEARCH_INDEX=models + +# Redis (development defaults apply when APP_ENV=development and vars are unset) +# REDIS_HOST=ec2-3-69-138-29.eu-central-1.compute.amazonaws.com +# REDIS_PORT=16279 +# REDIS_PASSWORD=eafon123! +# REDIS_DATABASE=1 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..bd16196 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,8 @@ +APP_ENV=production +APP_HOST=0.0.0.0 +APP_PORT=8080 +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). +DATABASE_PASSWORD=your-production-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdaff23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test & coverage +*.test +*.out +coverage.html + +# Go workspace +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Env & secrets +.env +.env.local + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c81960 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: run build test tidy + +run: + go run ./cmd/server + +build: + go build -o bin/server ./cmd/server + +test: + go test ./... + +tidy: + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d09671 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# app-api + +基于 [Gin](https://github.com/gin-gonic/gin) 的 Go HTTP API 脚手架。 + +## 项目结构 + +``` +app-api/ +├── cmd/server/ # 程序入口 +├── internal/ +│ ├── config/ # 配置加载 +│ ├── handler/ # HTTP 处理器 +│ ├── middleware/ # 中间件(日志、CORS、Request ID) +│ ├── response/ # 统一 JSON 响应 +│ └── router/ # 路由注册 +└── pkg/logger/ # 可复用日志封装 +``` + +## 快速开始 + +### 环境要求 + +- Go 1.22+ + +### 安装依赖 + +```bash +go mod tidy +``` + +### 配置(可选) + +复制环境变量示例: + +```bash +cp .env.example .env +``` + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `APP_ENV` | `development` | 运行环境 | +| `APP_HOST` | `0.0.0.0` | 监听地址 | +| `APP_PORT` | `8080` | 监听端口 | +| `GIN_MODE` | `debug` | Gin 运行模式 | + +### 数据库 + +根据 `APP_ENV` 自动选择 MySQL 配置;也可通过 `DATABASE_*` 环境变量完全覆盖。 + +| 环境 | `APP_ENV` | 默认连接 | +|------|-----------|----------| +| 开发 | `development` | `localhost:3306/audio`,用户 `root`,密码 `root123` | +| 正式 | `production` | AWS RDS `database-1.chmuueamo72p.eu-central-1.rds.amazonaws.com:3306/audio` | + +正式环境密码**必须**通过环境变量 `DATABASE_PASSWORD` 提供(不要写入代码仓库)。可参考 `.env.production.example`。 + +开发环境可在 `.env` 中覆盖: + +```bash +DATABASE_HOST=localhost +DATABASE_PORT=3306 +DATABASE_NAME=audio +DATABASE_USER=root +DATABASE_PASSWORD=root123 +``` + +正式环境启动示例(PowerShell): + +```powershell +$env:APP_ENV = "production" +$env:DATABASE_PASSWORD = "your-production-password" +go run ./cmd/server +``` + +### 运行 + +```bash +make run +# 或 +go run ./cmd/server +``` + +### 健康检查 + +```bash +curl http://localhost:8080/api/v1/health +``` + +响应示例: + +```json +{ + "code": 0, + "message": "ok", + "data": { + "status": "up" + } +} +``` + +### 品牌列表 + +```bash +# 查询全部 +curl "http://localhost:8080/audio/getBrand" + +# 按名称模糊查询 +curl "http://localhost:8080/audio/getBrand?brandName=sony" +``` + +## 添加新接口 + +1. 在 `internal/handler/` 新建 handler +2. 在 `internal/router/router.go` 的 `/api/v1` 分组下注册路由 +3. 使用 `internal/response` 返回统一格式 + +## 构建 + +```bash +make build +./bin/server +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..82ea2b3 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "errors" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/luxsin/app-api/internal/config" + "github.com/luxsin/app-api/internal/database" + "github.com/luxsin/app-api/internal/cache" + "github.com/luxsin/app-api/internal/router" + "github.com/luxsin/app-api/internal/search" + "github.com/luxsin/app-api/pkg/logger" + "go.uber.org/zap" +) + +func main() { + cfg, err := config.Load() + if err != nil { + panic(err) + } + + if cfg.Env == "production" { + gin.SetMode(gin.ReleaseMode) + } + + log, err := logger.New(cfg.Env) + if err != nil { + panic(err) + } + defer log.Sync() //nolint:errcheck + + db, err := database.Open(cfg.Database) + if err != nil { + log.Fatal("database connect failed", zap.Error(err)) + } + defer db.Close() + + log.Info("database connected", + zap.String("host", cfg.Database.Host), + zap.Int("port", cfg.Database.Port), + zap.String("database", cfg.Database.Name), + ) + + searchClient := search.NewClient(cfg.Meilisearch) + log.Info("meilisearch configured", + zap.String("host", cfg.Meilisearch.Host), + zap.String("index", cfg.Meilisearch.Index), + ) + + redisClient := cache.NewClient(cfg.Redis) + defer redisClient.Close() + log.Info("redis connected", + zap.String("host", cfg.Redis.Host), + zap.Int("port", cfg.Redis.Port), + zap.Int("db", cfg.Redis.Database), + ) + + engine := router.New(log, db, searchClient, redisClient) + + srv := &http.Server{ + Addr: cfg.Addr(), + Handler: engine, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Info("server starting", zap.String("addr", cfg.Addr()), zap.String("env", cfg.Env)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal("server failed", zap.Error(err)) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("server shutting down") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("server shutdown failed", zap.Error(err)) + } + + log.Info("server stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2decc11 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/luxsin/app-api + +go 1.24.0 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/meilisearch/meilisearch-go v0.36.2 + github.com/redis/go-redis/v9 v9.19.0 + go.uber.org/zap v1.27.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aa25acd --- /dev/null +++ b/go.sum @@ -0,0 +1,119 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU= +github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..059b4f1 --- /dev/null +++ b/internal/cache/redis.go @@ -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, + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d349f3f --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/database.go b/internal/config/database.go new file mode 100644 index 0000000..6cfe74e --- /dev/null +++ b/internal/config/database.go @@ -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 +} diff --git a/internal/config/meilisearch.go b/internal/config/meilisearch.go new file mode 100644 index 0000000..e495945 --- /dev/null +++ b/internal/config/meilisearch.go @@ -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 +} diff --git a/internal/config/redis.go b/internal/config/redis.go new file mode 100644 index 0000000..dc2ae83 --- /dev/null +++ b/internal/config/redis.go @@ -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 +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go new file mode 100644 index 0000000..c6705cd --- /dev/null +++ b/internal/database/mysql.go @@ -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 +} diff --git a/internal/handler/brand.go b/internal/handler/brand.go new file mode 100644 index 0000000..db25151 --- /dev/null +++ b/internal/handler/brand.go @@ -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) +} diff --git a/internal/handler/device.go b/internal/handler/device.go new file mode 100644 index 0000000..59a9cdf --- /dev/null +++ b/internal/handler/device.go @@ -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": "操作成功", + }) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..ffb839d --- /dev/null +++ b/internal/handler/health.go @@ -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", + }) +} diff --git a/internal/handler/model.go b/internal/handler/model.go new file mode 100644 index 0000000..2a37508 --- /dev/null +++ b/internal/handler/model.go @@ -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) +} diff --git a/internal/handler/model_list.go b/internal/handler/model_list.go new file mode 100644 index 0000000..80a0109 --- /dev/null +++ b/internal/handler/model_list.go @@ -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) +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..e416f01 --- /dev/null +++ b/internal/middleware/cors.go @@ -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() + } +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..42e31c3 --- /dev/null +++ b/internal/middleware/logger.go @@ -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...) + } + } +} diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go new file mode 100644 index 0000000..a9c3a26 --- /dev/null +++ b/internal/middleware/request_id.go @@ -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() + } +} diff --git a/internal/model/brand.go b/internal/model/brand.go new file mode 100644 index 0000000..7471fea --- /dev/null +++ b/internal/model/brand.go @@ -0,0 +1,6 @@ +package model + +type Brand struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..86d8f8e --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/repository/brand.go b/internal/repository/brand.go new file mode 100644 index 0000000..219ba4c --- /dev/null +++ b/internal/repository/brand.go @@ -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 +} diff --git a/internal/repository/model.go b/internal/repository/model.go new file mode 100644 index 0000000..92ced9d --- /dev/null +++ b/internal/repository/model.go @@ -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 +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..020cb43 --- /dev/null +++ b/internal/response/response.go @@ -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) +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..8aea660 --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/internal/search/meilisearch.go b/internal/search/meilisearch.go new file mode 100644 index 0000000..f3c8d74 --- /dev/null +++ b/internal/search/meilisearch.go @@ -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 +} diff --git a/pkg/encode/base64.go b/pkg/encode/base64.go new file mode 100644 index 0000000..0781ba2 --- /dev/null +++ b/pkg/encode/base64.go @@ -0,0 +1,51 @@ +package encode + +import ( + "encoding/base64" + "encoding/json" +) + +const ( + standardBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + customBase64 = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/" +) + +var base64Map = func() map[byte]byte { + m := make(map[byte]byte, len(standardBase64)) + for i := 0; i < len(standardBase64); i++ { + m[standardBase64[i]] = customBase64[i] + } + return m +}() + +// CustomBase64Encode 使用自定义字符映射对标准 Base64 进行转换 +func CustomBase64Encode(data []byte) string { + standard := base64.StdEncoding.EncodeToString(data) + result := make([]byte, len(standard)) + for i := 0; i < len(standard); i++ { + if c, ok := base64Map[standard[i]]; ok { + result[i] = c + } else { + result[i] = standard[i] + } + } + return string(result) +} + +// EncodeJSON 将任意数据编码为 JSON 后,再进行自定义 Base64 编码 +func EncodeJSON(v any) (string, error) { + jsonBytes, err := json.Marshal(v) + if err != nil { + return "", err + } + return CustomBase64Encode(jsonBytes), nil +} + +// ParseBase64Param 解析 base64Resp 参数,默认为 true +func ParseBase64Param(c interface{ Query(string) string }) bool { + v := c.Query("base64Resp") + if v == "" || v == "true" || v == "1" { + return true + } + return false +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..7e55b7c --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,19 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func New(env string) (*zap.Logger, error) { + var cfg zap.Config + if env == "production" { + cfg = zap.NewProductionConfig() + cfg.EncoderConfig.TimeKey = "time" + cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + } else { + cfg = zap.NewDevelopmentConfig() + cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + } + return cfg.Build() +} diff --git a/sql/model.sql b/sql/model.sql new file mode 100644 index 0000000..3c1c2bd --- /dev/null +++ b/sql/model.sql @@ -0,0 +1,37 @@ +/* + Navicat Premium Dump SQL + + Source Server : localhost + Source Server Type : MySQL + Source Server Version : 90600 (9.6.0) + Source Host : localhost:3306 + Source Schema : audio + + Target Server Type : MySQL + Target Server Version : 90600 (9.6.0) + File Encoding : 65001 + + Date: 21/05/2026 14:36:41 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for model +-- ---------------------------- +DROP TABLE IF EXISTS `model`; +CREATE TABLE `model` ( + `id` int NOT NULL AUTO_INCREMENT, + `brand_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `form` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `rig` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `source` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `eq_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `model_name`(`brand_name` ASC, `name` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 6680 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '耳机型号' ROW_FORMAT = DYNAMIC; + +SET FOREIGN_KEY_CHECKS = 1;