diff --git a/Makefile b/Makefile index 2c81960..96d718b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: run build test tidy +.PHONY: run build test tidy swag run: go run ./cmd/server -build: +build: swag go build -o bin/server ./cmd/server test: @@ -11,3 +11,6 @@ test: tidy: go mod tidy + +swag: + swag init -g cmd/server/main.go -o docs --parseDependency --parseGoList diff --git a/cmd/server/main.go b/cmd/server/main.go index f6098a4..0ccc4ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" + _ "github.com/luxsin/app-api/docs" "github.com/luxsin/app-api/internal/cache" "github.com/luxsin/app-api/internal/config" "github.com/luxsin/app-api/internal/database" @@ -26,6 +27,14 @@ import ( "go.uber.org/zap" ) +// @title App Audio API +// @version 1.0 +// @description 耳机音频参数 EQ 后端服务 +// +// @BasePath / +// +// @contact.name Luxsin + func main() { _ = godotenv.Load() @@ -78,6 +87,11 @@ func main() { deviceRepo := repository.NewDeviceRepository(db) devicePersistTask := task.NewDevicePersistTask(rdb, deviceRepo, log) devicePersistTask.Start(5 * time.Minute) + + shareCodeCache := cache.NewShareCodeCache(rdb) + shareCodeRepo := repository.NewShareCodeRepository(db) + shareCodePersistTask := task.NewShareCodePersistTask(shareCodeCache, shareCodeRepo, log) + shareCodePersistTask.Start(5 * time.Minute) } else { log.Info("device persist task disabled") } diff --git a/docker-compose.yml b/docker-compose.yml index d863155..29222bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - DATABASE_PASSWORD=${DATABASE_PASSWORD} - REDIS_PASSWORD=${REDIS_PASSWORD:-eafon123!} - MEILISEARCH_API_KEY=${MEILISEARCH_API_KEY:-young9#!UJsD219921031} - - ENABLE_PERSIST_TASK=false + - ENABLE_PERSIST_TASK=true - AWS_REGION=eu-central-1 - S3_BUCKET=luxsin-app-bucket healthcheck: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..eaffc4c --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,594 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": { + "name": "Luxsin" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getBrand": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Brand" + ], + "summary": "获取品牌列表", + "parameters": [ + { + "type": "string", + "description": "品牌名称(模糊匹配)", + "name": "brandName", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getCurve": { + "get": { + "description": "根据机型和目标曲线名称,计算并返回 parametric_eq 数据", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取目标曲线参数化 EQ", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "目标曲线名称", + "name": "target", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 parametric_eq 数据", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getModel": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Model" + ], + "summary": "获取型号列表", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brandName", + "in": "query" + }, + { + "type": "string", + "description": "型号名称", + "name": "modelName", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getModelCSV": { + "get": { + "description": "从 S3 读取指定机型的测量 CSV 数据,返回 frequency 和 raw 数组", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取耳机原始频响 CSV 数据", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "耳机类型 (in-ear/over-ear)", + "name": "form", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 frequency 和 raw 数组", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/modelCurve": { + "get": { + "description": "返回指定机型的频响曲线数据(fr),固定使用 Harman over-ear 2018 target", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取机型默认频响曲线", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 fr 数据", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/modelList": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Model" + ], + "summary": "搜索型号列表(基于 Meilisearch)", + "parameters": [ + { + "type": "string", + "description": "搜索关键词", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "返回数量上限", + "name": "count", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/ota": { + "get": { + "description": "根据设备型号和硬件版本查询最新 OTA 记录,支持黑名单过滤和定向升级逻辑", + "produces": [ + "application/json" + ], + "tags": [ + "OTA" + ], + "summary": "获取 OTA 升级信息", + "parameters": [ + { + "type": "string", + "description": "设备型号", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "硬件版本", + "name": "hw", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query" + }, + { + "type": "integer", + "description": "是否 beta 通道 (0=否,1=是)", + "name": "beta", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 OTA 信息", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/reportDevInfo": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Device" + ], + "summary": "上报设备信息", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "设备型号", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "固件版本号", + "name": "ver", + "in": "query" + } + ], + "responses": { + "200": { + "description": "操作成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/shareAccept": { + "get": { + "description": "根据分享码获取他人分享的 EQ 数据", + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "导入 EQ 分享码", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "5 位分享码", + "name": "shareCode", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功返回 eq_data", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "系统错误", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/audio/shareCreate": { + "post": { + "description": "将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "创建 EQ 分享码", + "parameters": [ + { + "description": "分享请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "eq_data": { + "type": "object" + }, + "mac": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "成功返回 share_code、expire_at、eq_data", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "系统错误", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "App Audio API", + Description: "耳机音频参数 EQ 后端服务", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..7b4e74b --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,569 @@ +{ + "swagger": "2.0", + "info": { + "description": "耳机音频参数 EQ 后端服务", + "title": "App Audio API", + "contact": { + "name": "Luxsin" + }, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/v1/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getBrand": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Brand" + ], + "summary": "获取品牌列表", + "parameters": [ + { + "type": "string", + "description": "品牌名称(模糊匹配)", + "name": "brandName", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getCurve": { + "get": { + "description": "根据机型和目标曲线名称,计算并返回 parametric_eq 数据", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取目标曲线参数化 EQ", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "目标曲线名称", + "name": "target", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 parametric_eq 数据", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getModel": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Model" + ], + "summary": "获取型号列表", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brandName", + "in": "query" + }, + { + "type": "string", + "description": "型号名称", + "name": "modelName", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/getModelCSV": { + "get": { + "description": "从 S3 读取指定机型的测量 CSV 数据,返回 frequency 和 raw 数组", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取耳机原始频响 CSV 数据", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "耳机类型 (in-ear/over-ear)", + "name": "form", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 frequency 和 raw 数组", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/modelCurve": { + "get": { + "description": "返回指定机型的频响曲线数据(fr),固定使用 Harman over-ear 2018 target", + "produces": [ + "application/json" + ], + "tags": [ + "Curve" + ], + "summary": "获取机型默认频响曲线", + "parameters": [ + { + "type": "string", + "description": "品牌名称", + "name": "brand", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "型号名称", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 fr 数据", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/modelList": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Model" + ], + "summary": "搜索型号列表(基于 Meilisearch)", + "parameters": [ + { + "type": "string", + "description": "搜索关键词", + "name": "key", + "in": "query" + }, + { + "type": "integer", + "description": "返回数量上限", + "name": "count", + "in": "query" + }, + { + "type": "string", + "description": "是否返回 base64 编码响应", + "name": "base64Resp", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/ota": { + "get": { + "description": "根据设备型号和硬件版本查询最新 OTA 记录,支持黑名单过滤和定向升级逻辑", + "produces": [ + "application/json" + ], + "tags": [ + "OTA" + ], + "summary": "获取 OTA 升级信息", + "parameters": [ + { + "type": "string", + "description": "设备型号", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "硬件版本", + "name": "hw", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query" + }, + { + "type": "integer", + "description": "是否 beta 通道 (0=否,1=是)", + "name": "beta", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功返回 OTA 信息", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/reportDevInfo": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Device" + ], + "summary": "上报设备信息", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "设备型号", + "name": "model", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "固件版本号", + "name": "ver", + "in": "query" + } + ], + "responses": { + "200": { + "description": "操作成功", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object" + } + } + } + } + }, + "/audio/shareAccept": { + "get": { + "description": "根据分享码获取他人分享的 EQ 数据", + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "导入 EQ 分享码", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "5 位分享码", + "name": "shareCode", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功返回 eq_data", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "系统错误", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/audio/shareCreate": { + "post": { + "description": "将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "创建 EQ 分享码", + "parameters": [ + { + "description": "分享请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "eq_data": { + "type": "object" + }, + "mac": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "成功返回 share_code、expire_at、eq_data", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "系统错误", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..96daac7 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,379 @@ +basePath: / +info: + contact: + name: Luxsin + description: 耳机音频参数 EQ 后端服务 + title: App Audio API + version: "1.0" +paths: + /api/v1/health: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + summary: 健康检查 + tags: + - System + /audio/getBrand: + get: + parameters: + - description: 品牌名称(模糊匹配) + in: query + name: brandName + type: string + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: object + type: array + "500": + description: Internal Server Error + schema: + type: object + summary: 获取品牌列表 + tags: + - Brand + /audio/getCurve: + get: + description: 根据机型和目标曲线名称,计算并返回 parametric_eq 数据 + parameters: + - description: 品牌名称 + in: query + name: brand + required: true + type: string + - description: 型号名称 + in: query + name: name + required: true + type: string + - description: 目标曲线名称 + in: query + name: target + required: true + type: string + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: 成功返回 parametric_eq 数据 + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + type: object + summary: 获取目标曲线参数化 EQ + tags: + - Curve + /audio/getModel: + get: + parameters: + - description: 品牌名称 + in: query + name: brandName + type: string + - description: 型号名称 + in: query + name: modelName + type: string + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: object + type: array + "500": + description: Internal Server Error + schema: + type: object + summary: 获取型号列表 + tags: + - Model + /audio/getModelCSV: + get: + description: 从 S3 读取指定机型的测量 CSV 数据,返回 frequency 和 raw 数组 + parameters: + - description: 品牌名称 + in: query + name: brand + required: true + type: string + - description: 型号名称 + in: query + name: model + required: true + type: string + - description: 耳机类型 (in-ear/over-ear) + in: query + name: form + required: true + type: string + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: 成功返回 frequency 和 raw 数组 + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + type: object + summary: 获取耳机原始频响 CSV 数据 + tags: + - Curve + /audio/modelCurve: + get: + description: 返回指定机型的频响曲线数据(fr),固定使用 Harman over-ear 2018 target + parameters: + - description: 品牌名称 + in: query + name: brand + required: true + type: string + - description: 型号名称 + in: query + name: name + required: true + type: string + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: 成功返回 fr 数据 + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + type: object + summary: 获取机型默认频响曲线 + tags: + - Curve + /audio/modelList: + get: + parameters: + - description: 搜索关键词 + in: query + name: key + type: string + - description: 返回数量上限 + in: query + name: count + type: integer + - description: 是否返回 base64 编码响应 + in: query + name: base64Resp + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: string + type: array + "500": + description: Internal Server Error + schema: + type: object + summary: 搜索型号列表(基于 Meilisearch) + tags: + - Model + /audio/ota: + get: + description: 根据设备型号和硬件版本查询最新 OTA 记录,支持黑名单过滤和定向升级逻辑 + parameters: + - description: 设备型号 + in: query + name: model + required: true + type: string + - description: 硬件版本 + in: query + name: hw + required: true + type: integer + - description: 设备 MAC 地址 + in: query + name: mac + type: string + - description: 是否 beta 通道 (0=否,1=是) + in: query + name: beta + type: integer + produces: + - application/json + responses: + "200": + description: 成功返回 OTA 信息 + schema: + type: object + "400": + description: Bad Request + schema: + type: object + "500": + description: Internal Server Error + schema: + type: object + summary: 获取 OTA 升级信息 + tags: + - OTA + /audio/reportDevInfo: + get: + parameters: + - description: 设备 MAC 地址 + in: query + name: mac + required: true + type: string + - description: 设备型号 + in: query + name: model + required: true + type: string + - description: 固件版本号 + in: query + name: ver + type: string + produces: + - application/json + responses: + "200": + description: 操作成功 + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + type: object + summary: 上报设备信息 + tags: + - Device + /audio/shareAccept: + get: + description: 根据分享码获取他人分享的 EQ 数据 + parameters: + - description: 设备 MAC 地址 + in: query + name: mac + required: true + type: string + - description: 5 位分享码 + in: query + name: shareCode + required: true + type: string + produces: + - application/json + responses: + "200": + description: 成功返回 eq_data + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: 系统错误 + schema: + additionalProperties: true + type: object + summary: 导入 EQ 分享码 + tags: + - ShareCode + /audio/shareCreate: + post: + consumes: + - application/json + description: 将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟 + parameters: + - description: 分享请求 + in: body + name: body + required: true + schema: + properties: + eq_data: + type: object + mac: + type: string + type: object + produces: + - application/json + responses: + "200": + description: 成功返回 share_code、expire_at、eq_data + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: 系统错误 + schema: + additionalProperties: true + type: object + summary: 创建 EQ 分享码 + tags: + - ShareCode +swagger: "2.0" diff --git a/go.mod b/go.mod index 6a51883..b6e965b 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,17 @@ require ( github.com/joho/godotenv v1.5.1 github.com/meilisearch/meilisearch-go v0.36.2 github.com/redis/go-redis/v9 v9.19.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 go.uber.org/zap v1.27.0 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect @@ -39,14 +45,20 @@ require ( 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-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // 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/josharian/intern v1.0.0 // 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/mailru/easyjson v0.7.6 // 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 @@ -56,10 +68,13 @@ require ( 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 + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e865b81..a219b89 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/aws/aws-sdk-go-v2 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck= @@ -52,15 +58,28 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= 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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 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= @@ -75,19 +94,30 @@ 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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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= @@ -97,6 +127,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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= @@ -108,6 +140,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= @@ -116,12 +149,19 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl 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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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= @@ -135,22 +175,61 @@ 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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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= diff --git a/internal/cache/share_code_cache.go b/internal/cache/share_code_cache.go new file mode 100644 index 0000000..dcd7e42 --- /dev/null +++ b/internal/cache/share_code_cache.go @@ -0,0 +1,213 @@ +package cache + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + shareCodeKeyPrefix = "share:" + sharePendingSet = "share:pending" + shareImportPendingHash = "share:import:pending" + shareFlushLockPref = "share:flush:lock:" + shareImportFlushLockPref = "share:import:flush:lock:" + shareCodeLength = 5 + shareCodeTTL = 30 * time.Minute + shareCodeCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + shareCodeMaxRetries = 20 + + fieldMacAddr = "mac_addr" + fieldIPAddr = "ip_addr" + fieldEqData = "eq_data" + fieldExpireAt = "expire_at" + fieldPersisted = "persisted" +) + +var shareCreateScript = redis.NewScript(` +if redis.call('EXISTS', KEYS[1]) == 1 then + return 0 +end +redis.call('HSET', KEYS[1], + 'mac_addr', ARGV[1], + 'ip_addr', ARGV[2], + 'eq_data', ARGV[3], + 'expire_at', ARGV[4], + 'persisted', '0' +) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5])) +redis.call('SADD', KEYS[2], ARGV[6]) +return 1 +`) + +type ShareImportPendingLog struct { + MacAddr string `json:"mac_addr"` + ShareCode string `json:"share_code"` + IpAddr string `json:"ip_addr"` + EqData string `json:"eq_data"` + ExpireAt string `json:"expire_at"` +} + +type ShareCodeData struct { + ShareCode string + MacAddr string + IpAddr string + EqData string + ExpireAt time.Time + Persisted bool +} + +type ShareCodeCache struct { + rdb *redis.Client +} + +func NewShareCodeCache(rdb *redis.Client) *ShareCodeCache { + return &ShareCodeCache{rdb: rdb} +} + +func ShareCodeTTL() time.Duration { + return shareCodeTTL +} + +func (c *ShareCodeCache) Create(ctx context.Context, macAddr, ipAddr string, eqData []byte) (*ShareCodeData, error) { + expireAt := time.Now().Add(shareCodeTTL) + eqJSON := string(eqData) + + for i := 0; i < shareCodeMaxRetries; i++ { + code, err := randomShareCode() + if err != nil { + return nil, err + } + + key := shareCodeKey(code) + ok, err := shareCreateScript.Run(ctx, c.rdb, []string{key, sharePendingSet}, + macAddr, ipAddr, eqJSON, expireAt.Format(time.RFC3339), int(shareCodeTTL.Seconds()), code, + ).Int() + if err != nil { + return nil, fmt.Errorf("create share code in redis: %w", err) + } + if ok == 1 { + return &ShareCodeData{ + ShareCode: code, + MacAddr: macAddr, + IpAddr: ipAddr, + EqData: eqJSON, + ExpireAt: expireAt, + Persisted: false, + }, nil + } + } + + return nil, errors.New("failed to allocate unique share code") +} + +func (c *ShareCodeCache) Get(ctx context.Context, shareCode string) (*ShareCodeData, error) { + key := shareCodeKey(shareCode) + values, err := c.rdb.HGetAll(ctx, key).Result() + if err != nil { + return nil, fmt.Errorf("hgetall share code: %w", err) + } + if len(values) == 0 { + return nil, nil + } + + expireAt, err := time.Parse(time.RFC3339, values[fieldExpireAt]) + if err != nil { + return nil, fmt.Errorf("parse share code expire_at: %w", err) + } + + return &ShareCodeData{ + ShareCode: shareCode, + MacAddr: values[fieldMacAddr], + IpAddr: values[fieldIPAddr], + EqData: values[fieldEqData], + ExpireAt: expireAt, + Persisted: values[fieldPersisted] == "1", + }, nil +} + +func (c *ShareCodeCache) ListPending(ctx context.Context) ([]string, error) { + codes, err := c.rdb.SMembers(ctx, sharePendingSet).Result() + if err != nil { + return nil, fmt.Errorf("smembers share pending: %w", err) + } + return codes, nil +} + +func (c *ShareCodeCache) AcquireFlushLock(ctx context.Context, shareCode string) (bool, error) { + lockKey := shareFlushLockPref + shareCode + return c.rdb.SetNX(ctx, lockKey, "1", 30*time.Second).Result() +} + +func (c *ShareCodeCache) ReleaseFlushLock(ctx context.Context, shareCode string) error { + return c.rdb.Del(ctx, shareFlushLockPref+shareCode).Err() +} + +func (c *ShareCodeCache) MarkPersisted(ctx context.Context, shareCode string) error { + key := shareCodeKey(shareCode) + if err := c.rdb.HSet(ctx, key, fieldPersisted, "1").Err(); err != nil { + return fmt.Errorf("mark share code persisted: %w", err) + } + return c.rdb.SRem(ctx, sharePendingSet, shareCode).Err() +} + +func (c *ShareCodeCache) EnqueueImportLog(ctx context.Context, macAddr, shareCode, ipAddr, eqData string, expireAt time.Time) error { + field := fmt.Sprintf("%s:%s:%d", macAddr, shareCode, time.Now().UnixNano()) + payload, err := json.Marshal(ShareImportPendingLog{ + MacAddr: macAddr, + ShareCode: shareCode, + IpAddr: ipAddr, + EqData: eqData, + ExpireAt: expireAt.Format(time.RFC3339), + }) + if err != nil { + return fmt.Errorf("marshal import pending log: %w", err) + } + if err := c.rdb.HSet(ctx, shareImportPendingHash, field, payload).Err(); err != nil { + return fmt.Errorf("enqueue import log: %w", err) + } + return nil +} + +func (c *ShareCodeCache) ListPendingImports(ctx context.Context) (map[string]string, error) { + logs, err := c.rdb.HGetAll(ctx, shareImportPendingHash).Result() + if err != nil { + return nil, fmt.Errorf("hgetall import pending: %w", err) + } + return logs, nil +} + +func (c *ShareCodeCache) RemovePendingImport(ctx context.Context, field string) error { + return c.rdb.HDel(ctx, shareImportPendingHash, field).Err() +} + +func (c *ShareCodeCache) AcquireImportFlushLock(ctx context.Context, field string) (bool, error) { + return c.rdb.SetNX(ctx, shareImportFlushLockPref+field, "1", 30*time.Second).Result() +} + +func (c *ShareCodeCache) ReleaseImportFlushLock(ctx context.Context, field string) error { + return c.rdb.Del(ctx, shareImportFlushLockPref+field).Err() +} + +func shareCodeKey(code string) string { + return shareCodeKeyPrefix + code +} + +func randomShareCode() (string, error) { + b := make([]byte, shareCodeLength) + max := big.NewInt(int64(len(shareCodeCharset))) + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b[i] = shareCodeCharset[n.Int64()] + } + return string(b), nil +} diff --git a/internal/handler/brand.go b/internal/handler/brand.go index 52b8714..3dfaee0 100644 --- a/internal/handler/brand.go +++ b/internal/handler/brand.go @@ -22,6 +22,16 @@ func NewBrandHandler(repo *repository.BrandRepository, log *zap.Logger) *BrandHa } } +// GetBrand 获取品牌列表 +// +// @Summary 获取品牌列表 +// @Tags Brand +// @Produce json +// @Param brandName query string false "品牌名称(模糊匹配)" +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {array} object +// @Failure 500 {object} object +// @Router /audio/getBrand [get] func (h *BrandHandler) GetBrand(c *gin.Context) { brandName := c.Query("brandName") base64Resp := encode.ParseBase64Param(c) diff --git a/internal/handler/curve.go b/internal/handler/curve.go index f4cfc01..0377ba0 100644 --- a/internal/handler/curve.go +++ b/internal/handler/curve.go @@ -39,6 +39,19 @@ func NewCurveHandler(repo *repository.CurveRepository, curveCache *cache.CurveCa } // ModelCurve 获取机型默认曲线(固定 target: Harman over-ear 2018) +// +// @Summary 获取机型默认频响曲线 +// @Description 返回指定机型的频响曲线数据(fr),固定使用 Harman over-ear 2018 target +// @Tags Curve +// @Produce json +// @Param brand query string true "品牌名称" +// @Param name query string true "型号名称" +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {object} map[string]any "成功返回 fr 数据" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} object +// @Router /audio/modelCurve [get] +// // GET /audio/modelCurve?brand=xxx&name=xxx&base64Resp=true func (h *CurveHandler) ModelCurve(c *gin.Context) { brand := strings.TrimSpace(queryParam(c, "brand")) @@ -114,6 +127,20 @@ func (h *CurveHandler) ModelCurve(c *gin.Context) { } // GetCurve 获取目标曲线 +// +// @Summary 获取目标曲线参数化 EQ +// @Description 根据机型和目标曲线名称,计算并返回 parametric_eq 数据 +// @Tags Curve +// @Produce json +// @Param brand query string true "品牌名称" +// @Param name query string true "型号名称" +// @Param target query string true "目标曲线名称" +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {object} map[string]any "成功返回 parametric_eq 数据" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} object +// @Router /audio/getCurve [get] +// // GET /audio/getCurve?brand=xxx&name=xxx&target=xxx&base64Resp=true func (h *CurveHandler) GetCurve(c *gin.Context) { brand := strings.TrimSpace(queryParam(c, "brand")) diff --git a/internal/handler/device.go b/internal/handler/device.go index 1bd9c1b..3e8a2d5 100644 --- a/internal/handler/device.go +++ b/internal/handler/device.go @@ -24,6 +24,17 @@ func NewDeviceHandler(redis *redis.Client, log *zap.Logger) *DeviceHandler { } } +// ReportDevInfo 上报设备信息 +// +// @Summary 上报设备信息 +// @Tags Device +// @Produce json +// @Param mac query string true "设备 MAC 地址" +// @Param model query string true "设备型号" +// @Param ver query string false "固件版本号" +// @Success 200 {object} map[string]any "操作成功" +// @Failure 500 {object} object +// @Router /audio/reportDevInfo [get] func (h *DeviceHandler) ReportDevInfo(c *gin.Context) { mac := strings.TrimSpace(c.Query("mac")) model := strings.TrimSpace(c.Query("model")) diff --git a/internal/handler/health.go b/internal/handler/health.go index ffb839d..a3c4608 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -11,6 +11,13 @@ func NewHealthHandler() *HealthHandler { return &HealthHandler{} } +// Check 健康检查 +// +// @Summary 健康检查 +// @Tags System +// @Produce json +// @Success 200 {object} object +// @Router /api/v1/health [get] 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 index 5d5a9fd..d206894 100644 --- a/internal/handler/model.go +++ b/internal/handler/model.go @@ -22,6 +22,17 @@ func NewModelHandler(repo *repository.ModelRepository, log *zap.Logger) *ModelHa } } +// GetModel 获取型号列表 +// +// @Summary 获取型号列表 +// @Tags Model +// @Produce json +// @Param brandName query string false "品牌名称" +// @Param modelName query string false "型号名称" +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {array} object +// @Failure 500 {object} object +// @Router /audio/getModel [get] func (h *ModelHandler) GetModel(c *gin.Context) { brandName := c.Query("brandName") modelName := c.Query("modelName") diff --git a/internal/handler/model_csv.go b/internal/handler/model_csv.go index 7e5ce80..0e980bf 100644 --- a/internal/handler/model_csv.go +++ b/internal/handler/model_csv.go @@ -28,6 +28,20 @@ func NewModelCSVHandler(s3 *storage.S3Storage, log *zap.Logger) *ModelCSVHandler } // GetModelCSV 从 S3 读取耳机 CSV 频响数据 +// +// @Summary 获取耳机原始频响 CSV 数据 +// @Description 从 S3 读取指定机型的测量 CSV 数据,返回 frequency 和 raw 数组 +// @Tags Curve +// @Produce json +// @Param brand query string true "品牌名称" +// @Param model query string true "型号名称" +// @Param form query string true "耳机类型 (in-ear/over-ear)" +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {object} map[string]any "成功返回 frequency 和 raw 数组" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} object +// @Router /audio/getModelCSV [get] +// // GET /audio/getModelCSV?brand=Abyss&model=Dinan DZ&form=over-ear&base64Resp=true func (h *ModelCSVHandler) GetModelCSV(c *gin.Context) { brand := strings.TrimSpace(queryParam(c, "brand")) diff --git a/internal/handler/model_list.go b/internal/handler/model_list.go index 80a0109..367f2a3 100644 --- a/internal/handler/model_list.go +++ b/internal/handler/model_list.go @@ -23,6 +23,17 @@ func NewModelListHandler(searchClient *search.Client, log *zap.Logger) *ModelLis } } +// ModelList 搜索型号列表 +// +// @Summary 搜索型号列表(基于 Meilisearch) +// @Tags Model +// @Produce json +// @Param key query string false "搜索关键词" +// @Param count query int false "返回数量上限"default(100) +// @Param base64Resp query string false "是否返回 base64 编码响应" +// @Success 200 {array} string +// @Failure 500 {object} object +// @Router /audio/modelList [get] func (h *ModelListHandler) ModelList(c *gin.Context) { key := c.Query("key") base64Resp := encode.ParseBase64Param(c) diff --git a/internal/handler/ota.go b/internal/handler/ota.go index f41ca8e..4e84849 100644 --- a/internal/handler/ota.go +++ b/internal/handler/ota.go @@ -21,7 +21,19 @@ func NewOTAHandler(repo *repository.OTARepository, log *zap.Logger) *OTAHandler } // GetOTA 获取 OTA 升级信息 -// GET /audio/ota?model=xxx&hw=1&mac=xx:xx:xx&beta=0 +// +// @Summary 获取 OTA 升级信息 +// @Description 根据设备型号和硬件版本查询最新 OTA 记录,支持黑名单过滤和定向升级逻辑 +// @Tags OTA +// @Produce json +// @Param model query string true "设备型号" +// @Param hw query int true "硬件版本" +// @Param mac query string false "设备 MAC 地址" +// @Param beta query int false "是否 beta 通道 (0=否,1=是)" +// @Success 200 {object} object "成功返回 OTA 信息" +// @Failure 400 {object} object +// @Failure 500 {object} object +// @Router /audio/ota [get] func (h *OTAHandler) GetOTA(c *gin.Context) { modelVal := strings.TrimSpace(c.Query("model")) hwStr := strings.TrimSpace(c.Query("hw")) diff --git a/internal/handler/share_code.go b/internal/handler/share_code.go new file mode 100644 index 0000000..68c8314 --- /dev/null +++ b/internal/handler/share_code.go @@ -0,0 +1,170 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/luxsin/app-api/internal/cache" + "github.com/luxsin/app-api/pkg/encode" + "go.uber.org/zap" +) + +type ShareCodeHandler struct { + cache *cache.ShareCodeCache + log *zap.Logger +} + +func NewShareCodeHandler(shareCache *cache.ShareCodeCache, log *zap.Logger) *ShareCodeHandler { + return &ShareCodeHandler{cache: shareCache, log: log} +} + +// ExportShareCode 导出分享码 +// +// @Summary 创建 EQ 分享码 +// @Description 将用户的 EQ 数据生成一个 5 位分享码,有效期 30 分钟 +// @Tags ShareCode +// @Accept json +// @Produce json +// @Param body body object{mac=string,eq_data=object} true "分享请求" +// @Success 200 {object} map[string]any "成功返回 share_code、expire_at、eq_data" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} map[string]any "系统错误" +// @Router /audio/shareCreate [post] +// +// POST /audio/shareCreate +// Body: {"mac": "xx", "eq_data": {...}} +func (h *ShareCodeHandler) ExportShareCode(c *gin.Context) { + var req struct { + Mac string `json:"mac"` + EqData json.RawMessage `json:"eq_data"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "参数校验失败", + }) + return + } + + mac := strings.TrimSpace(req.Mac) + eqDataRaw := strings.TrimSpace(string(req.EqData)) + clientIP := encode.ClientPublicIP(c) + + if mac == "" || eqDataRaw == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "参数校验失败", + }) + return + } + + if !json.Valid([]byte(eqDataRaw)) { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "eq_data 格式错误", + }) + return + } + + ctx := c.Request.Context() + data, err := h.cache.Create(ctx, mac, clientIP, []byte(eqDataRaw)) + if err != nil { + h.log.Error("create share code failed", zap.Error(err)) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "系统错误", + }) + return + } + + var eqData any + if err := json.Unmarshal([]byte(data.EqData), &eqData); err != nil { + eqData = data.EqData + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "操作成功", + "share_code": data.ShareCode, + "expire_at": data.ExpireAt.Format("2006-01-02 15:04:05"), + "eq_data": eqData, + }) +} + +// ImportShareCode 导入分享码 +// +// @Summary 导入 EQ 分享码 +// @Description 根据分享码获取他人分享的 EQ 数据 +// @Tags ShareCode +// @Produce json +// @Param mac query string true "设备 MAC 地址" +// @Param shareCode query string true "5 位分享码" +// @Success 200 {object} map[string]any "成功返回 eq_data" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} map[string]any "系统错误" +// @Router /audio/shareAccept [get] +// +// GET /audio/shareAccept?mac=xx&shareCode=ABC12 +func (h *ShareCodeHandler) ImportShareCode(c *gin.Context) { + mac := strings.TrimSpace(c.Query("mac")) + shareCode := strings.TrimSpace(c.Query("shareCode")) + clientIP := encode.ClientPublicIP(c) + + if mac == "" || shareCode == "" { + c.JSON(http.StatusOK, gin.H{ + "code": 400, + "msg": "参数校验失败", + }) + return + } + if len(shareCode) != 5 { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "分享码无效或已过期", + }) + return + } + + ctx := c.Request.Context() + data, err := h.cache.Get(ctx, shareCode) + if err != nil { + h.log.Error("get share code failed", zap.String("share_code", shareCode), zap.Error(err)) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "系统错误", + }) + return + } + if data == nil { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "分享码无效或已过期", + }) + return + } + + if err := h.cache.EnqueueImportLog(ctx, mac, shareCode, clientIP, data.EqData, data.ExpireAt); err != nil { + h.log.Error("enqueue share import log failed", + zap.String("share_code", shareCode), + zap.Error(err), + ) + c.JSON(http.StatusOK, gin.H{ + "code": 500, + "msg": "系统错误", + }) + return + } + + var eqData any + if err := json.Unmarshal([]byte(data.EqData), &eqData); err != nil { + eqData = data.EqData + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "操作成功", + "eq_data": eqData, + }) +} diff --git a/internal/model/share_code.go b/internal/model/share_code.go new file mode 100644 index 0000000..2dd63ce --- /dev/null +++ b/internal/model/share_code.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type ShareCodeLog struct { + ID int + MacAddr string + ShareCode string + Action string // export | import + IpAddr string + EqData []byte + ExpireAt *time.Time + CreateAt time.Time +} diff --git a/internal/repository/share_code.go b/internal/repository/share_code.go new file mode 100644 index 0000000..72a2ded --- /dev/null +++ b/internal/repository/share_code.go @@ -0,0 +1,58 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/luxsin/app-api/internal/model" +) + +type ShareCodeRepository struct { + db *sql.DB +} + +func NewShareCodeRepository(db *sql.DB) *ShareCodeRepository { + return &ShareCodeRepository{db: db} +} + +func (r *ShareCodeRepository) InsertLog(ctx context.Context, log model.ShareCodeLog) error { + const query = `INSERT INTO share_code_log (mac_addr, share_code, action, ip_addr, eq_data, expire_at) + VALUES (?, ?, ?, ?, ?, ?)` + + var expireAt any + if log.ExpireAt != nil { + expireAt = *log.ExpireAt + } + + _, err := r.db.ExecContext(ctx, query, + log.MacAddr, log.ShareCode, log.Action, log.IpAddr, log.EqData, expireAt, + ) + if err != nil { + return fmt.Errorf("insert share_code_log: %w", err) + } + return nil +} + +func (r *ShareCodeRepository) HasExportLog(ctx context.Context, shareCode string) (bool, error) { + const query = `SELECT 1 FROM share_code_log WHERE share_code = ? AND action = 'export' LIMIT 1` + + var one int + err := r.db.QueryRowContext(ctx, query, shareCode).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("query share export log: %w", err) + } + return true, nil +} + +func ParseShareExpireAt(expireAt time.Time) *time.Time { + if expireAt.IsZero() { + return nil + } + t := expireAt + return &t +} diff --git a/internal/router/router.go b/internal/router/router.go index a434721..0eb92b1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,9 @@ import ( "github.com/luxsin/app-api/internal/storage" "github.com/redis/go-redis/v9" "go.uber.org/zap" + + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" ) func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Client, eqCfg config.EqualizeConfig, s3 *storage.S3Storage) *gin.Engine { @@ -33,6 +36,8 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl otaRepo := repository.NewOTARepository(db) curveRepo := repository.NewCurveRepository(db) + shareCodeCache := cache.NewShareCodeCache(rdb) + // Handler health := handler.NewHealthHandler() brand := handler.NewBrandHandler(brandRepo, log) @@ -42,12 +47,16 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl ota := handler.NewOTAHandler(otaRepo, log) curve := handler.NewCurveHandler(curveRepo, curveCache, eqCfg, s3, log) modelCSV := handler.NewModelCSVHandler(s3, log) + shareCode := handler.NewShareCodeHandler(shareCodeCache, log) v1 := r.Group("/api/v1") { v1.GET("/health", health.Check) } + // Swagger + r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + audio := r.Group("/audio") { audio.GET("/getBrand", brand.GetBrand) @@ -58,6 +67,8 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl audio.GET("/getCurve", curve.GetCurve) audio.GET("/modelCurve", curve.ModelCurve) audio.GET("/getModelCSV", modelCSV.GetModelCSV) + audio.POST("/shareCreate", shareCode.ExportShareCode) + audio.GET("/shareAccept", shareCode.ImportShareCode) } return r diff --git a/internal/task/share_code_persist.go b/internal/task/share_code_persist.go new file mode 100644 index 0000000..74e2595 --- /dev/null +++ b/internal/task/share_code_persist.go @@ -0,0 +1,229 @@ +package task + +import ( + "context" + "encoding/json" + "time" + + "github.com/luxsin/app-api/internal/cache" + "github.com/luxsin/app-api/internal/model" + "github.com/luxsin/app-api/internal/repository" + "go.uber.org/zap" +) + +type ShareCodePersistTask struct { + cache *cache.ShareCodeCache + repo *repository.ShareCodeRepository + log *zap.Logger +} + +func NewShareCodePersistTask(shareCache *cache.ShareCodeCache, repo *repository.ShareCodeRepository, log *zap.Logger) *ShareCodePersistTask { + return &ShareCodePersistTask{cache: shareCache, repo: repo, log: log} +} + +func (t *ShareCodePersistTask) Start(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + t.Persist() + } + }() + t.log.Info("share code persist task started", zap.String("interval", interval.String())) +} + +func (t *ShareCodePersistTask) Persist() { + t.persistExports() + t.persistImports() +} + +func (t *ShareCodePersistTask) persistExports() { + ctx := context.Background() + + codes, err := t.cache.ListPending(ctx) + if err != nil { + t.log.Error("list pending share codes failed", zap.Error(err)) + return + } + if len(codes) == 0 { + return + } + + t.log.Info("persisting share code export logs", zap.Int("count", len(codes))) + + var persisted, skipped, failed int + + for _, code := range codes { + ok := t.persistExportOne(ctx, code) + switch ok { + case 1: + persisted++ + case 0: + skipped++ + default: + failed++ + } + } + + if persisted > 0 || failed > 0 { + t.log.Info("share code export logs persist finished", + zap.Int("persisted", persisted), + zap.Int("skipped", skipped), + zap.Int("failed", failed), + ) + } +} + +func (t *ShareCodePersistTask) persistImports() { + ctx := context.Background() + + logs, err := t.cache.ListPendingImports(ctx) + if err != nil { + t.log.Error("list pending share import logs failed", zap.Error(err)) + return + } + if len(logs) == 0 { + return + } + + t.log.Info("persisting share code import logs", zap.Int("count", len(logs))) + + var persisted, skipped, failed int + + for field, payload := range logs { + ok := t.persistImportOne(ctx, field, payload) + switch ok { + case 1: + persisted++ + case 0: + skipped++ + default: + failed++ + } + } + + if persisted > 0 || failed > 0 { + t.log.Info("share code import logs persist finished", + zap.Int("persisted", persisted), + zap.Int("skipped", skipped), + zap.Int("failed", failed), + ) + } +} + +// persistExportOne 返回值:1=成功刷入,0=跳过,-1=失败 +func (t *ShareCodePersistTask) persistExportOne(ctx context.Context, shareCode string) int { + acquired, err := t.cache.AcquireFlushLock(ctx, shareCode) + if err != nil { + t.log.Error("acquire share flush lock failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + if !acquired { + return 0 + } + defer func() { + if err := t.cache.ReleaseFlushLock(ctx, shareCode); err != nil { + t.log.Warn("release share flush lock failed", zap.String("share_code", shareCode), zap.Error(err)) + } + }() + + data, err := t.cache.Get(ctx, shareCode) + if err != nil { + t.log.Error("get share code from redis failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + if data == nil { + _ = t.cache.MarkPersisted(ctx, shareCode) + return 0 + } + if data.Persisted { + return 0 + } + + exists, err := t.repo.HasExportLog(ctx, shareCode) + if err != nil { + t.log.Error("check share export log failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + if exists { + if err := t.cache.MarkPersisted(ctx, shareCode); err != nil { + t.log.Error("mark share code persisted failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + return 0 + } + + logEntry := model.ShareCodeLog{ + MacAddr: data.MacAddr, + ShareCode: data.ShareCode, + Action: "export", + IpAddr: data.IpAddr, + EqData: []byte(data.EqData), + ExpireAt: repository.ParseShareExpireAt(data.ExpireAt), + } + if err := t.repo.InsertLog(ctx, logEntry); err != nil { + t.log.Error("insert share export log failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + + if err := t.cache.MarkPersisted(ctx, shareCode); err != nil { + t.log.Error("mark share code persisted failed", zap.String("share_code", shareCode), zap.Error(err)) + return -1 + } + + return 1 +} + +// persistImportOne 返回值:1=成功刷入,0=跳过,-1=失败 +func (t *ShareCodePersistTask) persistImportOne(ctx context.Context, field, payload string) int { + acquired, err := t.cache.AcquireImportFlushLock(ctx, field) + if err != nil { + t.log.Error("acquire share import flush lock failed", zap.String("field", field), zap.Error(err)) + return -1 + } + if !acquired { + return 0 + } + defer func() { + if err := t.cache.ReleaseImportFlushLock(ctx, field); err != nil { + t.log.Warn("release share import flush lock failed", zap.String("field", field), zap.Error(err)) + } + }() + + var pending cache.ShareImportPendingLog + if err := json.Unmarshal([]byte(payload), &pending); err != nil { + t.log.Error("unmarshal share import pending log failed", zap.String("field", field), zap.Error(err)) + return -1 + } + + expireAt, err := time.Parse(time.RFC3339, pending.ExpireAt) + if err != nil { + t.log.Error("parse share import expire_at failed", zap.String("field", field), zap.Error(err)) + return -1 + } + + logEntry := model.ShareCodeLog{ + MacAddr: pending.MacAddr, + ShareCode: pending.ShareCode, + Action: "import", + IpAddr: pending.IpAddr, + EqData: []byte(pending.EqData), + ExpireAt: repository.ParseShareExpireAt(expireAt), + } + if err := t.repo.InsertLog(ctx, logEntry); err != nil { + t.log.Error("insert share import log failed", + zap.String("share_code", pending.ShareCode), + zap.String("field", field), + zap.Error(err), + ) + return -1 + } + + if err := t.cache.RemovePendingImport(ctx, field); err != nil { + t.log.Error("remove pending share import log failed", zap.String("field", field), zap.Error(err)) + return -1 + } + + return 1 +} diff --git a/scripts/upload.sh b/scripts/upload.sh index 041c673..989f963 100755 --- a/scripts/upload.sh +++ b/scripts/upload.sh @@ -1,55 +1,124 @@ #!/usr/bin/env bash set -euo pipefail -KEY="$HOME/.ssh/aws_eafon.pem" REMOTE_DIR="/data/project/app-api/" -SERVER="api1" -if [ $# -gt 0 ] && { [ "$1" = "api1" ] || [ "$1" = "api2" ]; }; then - SERVER="$1" +DEFAULT_PATHS=( + ./cmd/ + ./internal/ + ./pkg + ./go.mod + ./go.sum + ./Dockerfile + ./docker-compose.yml +) + +show_help() { + cat <<'EOF' +用法: upload.sh [选项] [服务器] [路径...] + +将本地代码同步到远程服务器(rsync)。 + +选项: + -n 虚拟执行,不实际传输或删除远程文件 + -h, --help 显示此帮助信息 + +服务器: + api1 仅上传到 api1 (luxsin-api1) + api2 仅上传到 api2 (luxsin-api2) + (省略) 同时上传到 api1 和 api2 + +路径: + 指定要同步的文件或目录,可传多个。 + 省略时使用默认路径: cmd/ internal/ pkg go.mod go.sum Dockerfile docker-compose.yml + +示例: + upload.sh 同步默认路径到 api1 和 api2 + upload.sh api1 同步默认路径到 api1 + upload.sh api2 ./internal 同步 internal/ 到 api2 + upload.sh -n 虚拟执行,预览同步到 api1 和 api2 + upload.sh -n api1 ./internal 虚拟执行,预览同步到 api1 +EOF +} + +remote_host_for() { + case "$1" in + api1) echo "luxsin-api1" ;; + api2) echo "luxsin-api2" ;; + *) + echo "未知服务器: $1(可选: api1, api2)" >&2 + exit 1 + ;; + esac +} + +upload_to() { + local server="$1" shift -fi + local paths=("$@") + local remote_host + remote_host="$(remote_host_for "$server")" -case "$SERVER" in - api1) - REMOTE_HOST="ubuntu@ec2-18-184-205-87.eu-central-1.compute.amazonaws.com" - ;; - api2) - REMOTE_HOST="ubuntu@ec2-3-71-153-45.eu-central-1.compute.amazonaws.com" - ;; - *) - echo "未知服务器: $SERVER(可选: api1, api2)" >&2 - exit 1 - ;; -esac - -REMOTE="${REMOTE_HOST}:${REMOTE_DIR}" - -if [ $# -eq 0 ]; then - PATHS=( - ./cmd/ - ./internal/ - ./pkg - ./go.mod - ./go.sum - ./Dockerfile - ./docker-compose.yml - ) -else - PATHS=("$@") -fi - -echo "上传到 ${SERVER} (${REMOTE_HOST}) ..." - -SSH_OPTS="-i $KEY -o StrictHostKeyChecking=no" - -# 上传前先删除远程同名目录,避免 scp 嵌套 -for item in "${PATHS[@]}"; do - if [[ -d "$item" ]]; then - remote_item="${REMOTE_DIR}${item#./}" - echo " 删除远程目录: ${REMOTE_HOST}:${remote_item}" - ssh $SSH_OPTS "$REMOTE_HOST" "rm -rf '${remote_item}'" + if [ "$DRY_RUN" = true ]; then + echo "虚拟上传到 ${server} (${remote_host}) ..." + else + echo "上传到 ${server} (${remote_host}) ..." fi + + local rsync_opts=(-avz --progress) + if [ "$DRY_RUN" = true ]; then + rsync_opts+=(-n) + fi + + local item rel remote_path + for item in "${paths[@]}"; do + rel="${item#./}" + remote_path="${REMOTE_DIR}${rel}" + + if [[ -d "$item" ]]; then + echo " 同步目录: $item -> ${remote_host}:${remote_path}/" + rsync "${rsync_opts[@]}" --delete "${item}/" "${remote_host}:${remote_path}/" + else + echo " 同步文件: $item -> ${remote_host}:${remote_path}" + rsync "${rsync_opts[@]}" "$item" "${remote_host}:${remote_path}" + fi + done +} + +DRY_RUN=false +ARGS=() +for arg in "$@"; do + case "$arg" in + -n) + DRY_RUN=true + ;; + -h|--help) + show_help + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac done -scp -i "$KEY" -r "${PATHS[@]}" "$REMOTE" +SERVERS=() +if [ ${#ARGS[@]} -gt 0 ] && { [ "${ARGS[0]}" = "api1" ] || [ "${ARGS[0]}" = "api2" ]; }; then + SERVERS=("${ARGS[0]}") + ARGS=("${ARGS[@]:1}") +else + SERVERS=(api1 api2) +fi + +if [ ${#ARGS[@]} -eq 0 ]; then + PATHS=("${DEFAULT_PATHS[@]}") +else + PATHS=("${ARGS[@]}") +fi + +for server in "${SERVERS[@]}"; do + upload_to "$server" "${PATHS[@]}" + echo +done + +echo "完成。" diff --git a/sql/share_code_log.sql b/sql/share_code_log.sql new file mode 100644 index 0000000..094fb23 --- /dev/null +++ b/sql/share_code_log.sql @@ -0,0 +1,24 @@ +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for share_code_log +-- 分享码流水:记录导出/导入操作及 EQ 数据快照 +-- ---------------------------- +DROP TABLE IF EXISTS `share_code_log`; +CREATE TABLE `share_code_log` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', + `mac_addr` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '设备 MAC 地址,如 AA:BB:CC:DD:EE:FF', + `share_code` char(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分享码(5位字符)', + `action` enum('export','import') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作类型:export=导出分享码,import=导入分享码', + `ip_addr` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '用户 IP 地址(支持 IPv4/IPv6)', + `eq_data` json NOT NULL COMMENT 'EQ 数据快照(JSON)', + `expire_at` datetime NULL DEFAULT NULL COMMENT '分享码到期时间(导出时快照,导入可为空)', + `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_mac_addr` (`mac_addr` ASC) USING BTREE, + INDEX `idx_share_code` (`share_code` ASC) USING BTREE, + INDEX `idx_create_at` (`create_at` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC COMMENT = '分享码流水表'; + +SET FOREIGN_KEY_CHECKS = 1;