首次提交
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
+29
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Vendored
+16
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "操作成功",
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type Brand struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user