From b2aed2a6b5796c4622996ec10fa8b28caf61f471 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Fri, 12 Jun 2026 17:07:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(share):=20=E6=96=B0=E5=A2=9E=20shareList?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=20+=20=E5=88=86=E4=BA=AB=E7=A0=81?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=A8=E9=93=BE=E8=B7=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /audio/shareList?mac=xx 查询未过期分享码(纯 Redis,ZSET 索引) - share:mac:{mac} 从 SET 改为 ZSET(score=expire_at),支持 ZREMRANGEBYSCORE 精确过期清理 - 空 ZSET 自动 DEL,避免 key 累积 - share:import:pending 增加 12h 兜底 TTL,防止 DB 不可用时内存泄漏 - 导入日志 field 改为 mac:code(去掉 nanotime),同 MAC+code 多次导入幂等去重 - MarkPersisted 保存/恢复 PTTL,防御性编程 - shareCreate 改为 POST + JSON body - 全量 handler 补充 Swagger 注释,集成 swag 文档生成 - Makefile 使用 $(go env GOPATH)/bin/swag 解决 PATH 问题 --- Makefile | 4 +- cmd/server/main.go | 2 +- docs/docs.go | 44 +++++++++++++++ docs/swagger.json | 44 +++++++++++++++ docs/swagger.yaml | 30 ++++++++++ internal/cache/share_code_cache.go | 87 +++++++++++++++++++++++++---- internal/handler/curve.go | 42 +++++++------- internal/handler/device.go | 6 +- internal/handler/model_csv.go | 22 ++++---- internal/handler/ota.go | 22 ++++---- internal/handler/share_code.go | 88 ++++++++++++++++++++++++------ internal/router/router.go | 1 + 12 files changed, 315 insertions(+), 77 deletions(-) diff --git a/Makefile b/Makefile index 96d718b..9c16bdf 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +SWAG := $(shell go env GOPATH)/bin/swag + .PHONY: run build test tidy swag run: @@ -13,4 +15,4 @@ tidy: go mod tidy swag: - swag init -g cmd/server/main.go -o docs --parseDependency --parseGoList + $(SWAG) init -g cmd/server/main.go -o docs --parseDependency --parseGoList diff --git a/cmd/server/main.go b/cmd/server/main.go index 0ccc4ce..e5e71ed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,7 +31,7 @@ import ( // @version 1.0 // @description 耳机音频参数 EQ 后端服务 // -// @BasePath / +// @BasePath / // // @contact.name Luxsin diff --git a/docs/docs.go b/docs/docs.go index eaffc4c..e600337 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -571,6 +571,50 @@ const docTemplate = `{ } } } + }, + "/audio/shareList": { + "get": { + "description": "根据设备 MAC 地址查询该设备尚未过期的所有分享码(仅查询 Redis,依赖 TTL 自动过期)", + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "查询未过期分享码", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功返回 share_codes 列表", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "参数校验失败", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "系统错误", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } } } }` diff --git a/docs/swagger.json b/docs/swagger.json index 7b4e74b..b7a607e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -564,6 +564,50 @@ } } } + }, + "/audio/shareList": { + "get": { + "description": "根据设备 MAC 地址查询该设备尚未过期的所有分享码(仅查询 Redis,依赖 TTL 自动过期)", + "produces": [ + "application/json" + ], + "tags": [ + "ShareCode" + ], + "summary": "查询未过期分享码", + "parameters": [ + { + "type": "string", + "description": "设备 MAC 地址", + "name": "mac", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功返回 share_codes 列表", + "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 index 96daac7..b48f3bb 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -376,4 +376,34 @@ paths: summary: 创建 EQ 分享码 tags: - ShareCode + /audio/shareList: + get: + description: 根据设备 MAC 地址查询该设备尚未过期的所有分享码(仅查询 Redis,依赖 TTL 自动过期) + parameters: + - description: 设备 MAC 地址 + in: query + name: mac + required: true + type: string + produces: + - application/json + responses: + "200": + description: 成功返回 share_codes 列表 + schema: + additionalProperties: true + type: object + "400": + description: 参数校验失败 + schema: + additionalProperties: true + type: object + "500": + description: 系统错误 + schema: + additionalProperties: true + type: object + summary: 查询未过期分享码 + tags: + - ShareCode swagger: "2.0" diff --git a/internal/cache/share_code_cache.go b/internal/cache/share_code_cache.go index dcd7e42..296df94 100644 --- a/internal/cache/share_code_cache.go +++ b/internal/cache/share_code_cache.go @@ -7,21 +7,24 @@ import ( "errors" "fmt" "math/big" + "strings" "time" "github.com/redis/go-redis/v9" ) const ( - shareCodeKeyPrefix = "share:" - sharePendingSet = "share:pending" - shareImportPendingHash = "share:import:pending" - shareFlushLockPref = "share:flush:lock:" + 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 + shareMacIndexPrefix = "share:mac:" + shareCodeLength = 5 + shareCodeTTL = 30 * time.Minute + shareImportPendingTTL = 12 * time.Hour + shareCodeCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + shareCodeMaxRetries = 20 fieldMacAddr = "mac_addr" fieldIPAddr = "ip_addr" @@ -43,6 +46,8 @@ redis.call('HSET', KEYS[1], ) redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5])) redis.call('SADD', KEYS[2], ARGV[6]) +-- ZSET: score = expire_at unix timestamp, member = share code +redis.call('ZADD', KEYS[3], tonumber(ARGV[7]), ARGV[6]) return 1 `) @@ -86,8 +91,9 @@ func (c *ShareCodeCache) Create(ctx context.Context, macAddr, ipAddr string, eqD } key := shareCodeKey(code) - ok, err := shareCreateScript.Run(ctx, c.rdb, []string{key, sharePendingSet}, - macAddr, ipAddr, eqJSON, expireAt.Format(time.RFC3339), int(shareCodeTTL.Seconds()), code, + macIdxKey := shareMacIndexKey(macAddr) + ok, err := shareCreateScript.Run(ctx, c.rdb, []string{key, sharePendingSet, macIdxKey}, + macAddr, ipAddr, eqJSON, expireAt.Format(time.RFC3339), int(shareCodeTTL.Seconds()), code, expireAt.Unix(), ).Int() if err != nil { return nil, fmt.Errorf("create share code in redis: %w", err) @@ -151,14 +157,27 @@ func (c *ShareCodeCache) ReleaseFlushLock(ctx context.Context, shareCode string) func (c *ShareCodeCache) MarkPersisted(ctx context.Context, shareCode string) error { key := shareCodeKey(shareCode) + + // preserve remaining TTL before HSET (HSET clears TTL in some Redis versions) + remainTTL, err := c.rdb.PTTL(ctx, key).Result() + if err != nil { + return fmt.Errorf("get share code ttl: %w", err) + } + if err := c.rdb.HSet(ctx, key, fieldPersisted, "1").Err(); err != nil { return fmt.Errorf("mark share code persisted: %w", err) } + + // restore TTL + if remainTTL > 0 { + _ = c.rdb.PExpire(ctx, key, remainTTL).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()) + field := fmt.Sprintf("%s:%s", macAddr, shareCode) // 同 MAC+code 幂等,避免重复导入产生多条记录 payload, err := json.Marshal(ShareImportPendingLog{ MacAddr: macAddr, ShareCode: shareCode, @@ -172,6 +191,8 @@ func (c *ShareCodeCache) EnqueueImportLog(ctx context.Context, macAddr, shareCod if err := c.rdb.HSet(ctx, shareImportPendingHash, field, payload).Err(); err != nil { return fmt.Errorf("enqueue import log: %w", err) } + // Fallback TTL: prevent unbounded growth if persist task is disabled or DB is down + c.rdb.Expire(ctx, shareImportPendingHash, shareImportPendingTTL) return nil } @@ -195,6 +216,50 @@ func (c *ShareCodeCache) ReleaseImportFlushLock(ctx context.Context, field strin return c.rdb.Del(ctx, shareImportFlushLockPref+field).Err() } +func (c *ShareCodeCache) ListByMac(ctx context.Context, macAddr string) ([]*ShareCodeData, error) { + key := shareMacIndexKey(macAddr) + now := time.Now() + + // Remove expired entries from ZSET (score = expire_at unix timestamp) + _, err := c.rdb.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%d", now.Unix())).Result() + if err != nil && strings.Contains(err.Error(), "WRONGTYPE") { + // Old SET-type key from previous version, delete it + _ = c.rdb.Del(ctx, key).Err() + return nil, nil + } + + // Get remaining (unexpired) codes + codes, err := c.rdb.ZRange(ctx, key, 0, -1).Result() + if err != nil { + return nil, fmt.Errorf("zrange mac index: %w", err) + } + if len(codes) == 0 { + // ZSET is empty, remove the key to avoid accumulating empty keys + _ = c.rdb.Del(ctx, key).Err() + return nil, nil + } + + var result []*ShareCodeData + for _, code := range codes { + data, err := c.Get(ctx, code) + if err != nil { + return nil, err + } + if data == nil { + // hash already expired/removed, clean from ZSET + _ = c.rdb.ZRem(ctx, key, code).Err() + continue + } + result = append(result, data) + } + + return result, nil +} + +func shareMacIndexKey(macAddr string) string { + return shareMacIndexPrefix + macAddr +} + func shareCodeKey(code string) string { return shareCodeKeyPrefix + code } diff --git a/internal/handler/curve.go b/internal/handler/curve.go index 0377ba0..6a898ce 100644 --- a/internal/handler/curve.go +++ b/internal/handler/curve.go @@ -40,17 +40,17 @@ func NewCurveHandler(repo *repository.CurveRepository, curveCache *cache.CurveCa // ModelCurve 获取机型默认曲线(固定 target: Harman over-ear 2018) // -// @Summary 获取机型默认频响曲线 +// @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] +// @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) { @@ -128,18 +128,18 @@ func (h *CurveHandler) ModelCurve(c *gin.Context) { // GetCurve 获取目标曲线 // -// @Summary 获取目标曲线参数化 EQ +// @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] +// @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) { diff --git a/internal/handler/device.go b/internal/handler/device.go index 3e8a2d5..7a48320 100644 --- a/internal/handler/device.go +++ b/internal/handler/device.go @@ -29,9 +29,9 @@ func NewDeviceHandler(redis *redis.Client, log *zap.Logger) *DeviceHandler { // @Summary 上报设备信息 // @Tags Device // @Produce json -// @Param mac query string true "设备 MAC 地址" -// @Param model query string true "设备型号" -// @Param ver query string false "固件版本号" +// @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] diff --git a/internal/handler/model_csv.go b/internal/handler/model_csv.go index 0e980bf..4f0e587 100644 --- a/internal/handler/model_csv.go +++ b/internal/handler/model_csv.go @@ -29,18 +29,18 @@ func NewModelCSVHandler(s3 *storage.S3Storage, log *zap.Logger) *ModelCSVHandler // GetModelCSV 从 S3 读取耳机 CSV 频响数据 // -// @Summary 获取耳机原始频响 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] +// @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) { diff --git a/internal/handler/ota.go b/internal/handler/ota.go index 4e84849..f225806 100644 --- a/internal/handler/ota.go +++ b/internal/handler/ota.go @@ -22,18 +22,18 @@ func NewOTAHandler(repo *repository.OTARepository, log *zap.Logger) *OTAHandler // GetOTA 获取 OTA 升级信息 // -// @Summary 获取 OTA 升级信息 +// @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] +// @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 index 68c8314..b8c39e9 100644 --- a/internal/handler/share_code.go +++ b/internal/handler/share_code.go @@ -22,16 +22,16 @@ func NewShareCodeHandler(shareCache *cache.ShareCodeCache, log *zap.Logger) *Sha // ExportShareCode 导出分享码 // -// @Summary 创建 EQ 分享码 +// @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] +// @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": {...}} @@ -93,18 +93,70 @@ func (h *ShareCodeHandler) ExportShareCode(c *gin.Context) { }) } +// ListShareCodesByMac 查询某 MAC 尚未过期的分享码 +// +// @Summary 查询未过期分享码 +// @Description 根据设备 MAC 地址查询该设备尚未过期的所有分享码(仅查询 Redis,依赖 TTL 自动过期) +// @Tags ShareCode +// @Produce json +// @Param mac query string true "设备 MAC 地址" +// @Success 200 {object} map[string]any "成功返回 share_codes 列表" +// @Failure 400 {object} map[string]any "参数校验失败" +// @Failure 500 {object} map[string]any "系统错误" +// @Router /audio/shareList [get] +func (h *ShareCodeHandler) ListShareCodesByMac(c *gin.Context) { + mac := strings.TrimSpace(c.Query("mac")) + if mac == "" { + c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "参数校验失败"}) + return + } + + ctx := c.Request.Context() + + items, err := h.cache.ListByMac(ctx, mac) + if err != nil { + h.log.Error("list share codes by mac failed", zap.String("mac", mac), zap.Error(err)) + c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "系统错误"}) + return + } + + type shareItem struct { + ShareCode string `json:"share_code"` + ExpireAt string `json:"expire_at"` + EqData any `json:"eq_data"` + } + list := make([]shareItem, 0, len(items)) + for _, item := range items { + var eqData any + if err := json.Unmarshal([]byte(item.EqData), &eqData); err != nil { + eqData = item.EqData + } + list = append(list, shareItem{ + ShareCode: item.ShareCode, + ExpireAt: item.ExpireAt.Format("2006-01-02 15:04:05"), + EqData: eqData, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "操作成功", + "share_codes": list, + }) +} + // ImportShareCode 导入分享码 // -// @Summary 导入 EQ 分享码 +// @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] +// @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) { diff --git a/internal/router/router.go b/internal/router/router.go index 0eb92b1..0eb3c64 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -68,6 +68,7 @@ func New(log *zap.Logger, db *sql.DB, searchClient *search.Client, rdb *redis.Cl audio.GET("/modelCurve", curve.ModelCurve) audio.GET("/getModelCSV", modelCSV.GetModelCSV) audio.POST("/shareCreate", shareCode.ExportShareCode) + audio.GET("/shareList", shareCode.ListShareCodesByMac) audio.GET("/shareAccept", shareCode.ImportShareCode) }