From cbefd85f25e24c1d4349a69bfecf5d196fd6e86d Mon Sep 17 00:00:00 2001 From: hucan <951870319@qq.com> Date: Thu, 11 Sep 2025 20:01:00 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=B9=B3=E5=8F=B0=E5=A4=9AApiKey?= =?UTF-8?q?=E6=94=AF=E6=8C=81=202=E3=80=81=E5=B9=B3=E5=8F=B0=E5=8F=B7?= =?UTF-8?q?=E7=A0=81=E8=B7=9F=E7=B3=BB=E7=BB=9F=E5=8F=B7=E7=A0=81=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E8=87=AA=E5=8A=A8=E7=BB=AD=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/apis/sms_abnormal_number.go | 214 ++++++++++ app/admin/apis/sms_platform_key.go | 192 +++++++++ app/admin/models/sms_abnormal_number.go | 30 ++ app/admin/models/sms_phone.go | 1 + app/admin/models/sms_platform_key.go | 31 ++ app/admin/router/sms_abnormal_number.go | 29 ++ app/admin/router/sms_platform_key.go | 27 ++ app/admin/service/dto/sms_abnormal_number.go | 93 +++++ app/admin/service/dto/sms_platform_key.go | 117 ++++++ app/admin/service/dto/text_verified.go | 5 +- app/admin/service/sms_abnormal_number.go | 250 ++++++++++++ app/admin/service/sms_abnormal_number_test.go | 20 + app/admin/service/sms_daisysms.go | 96 +---- app/admin/service/sms_phone.go | 102 +++-- app/admin/service/sms_phone_extend.go | 1 + app/admin/service/sms_platform_key.go | 314 +++++++++++++++ app/admin/service/sms_platform_key_redis.go | 327 +++++++++++++++ .../service/sms_platform_key_redis_test.go | 23 ++ app/admin/service/sms_receive_log.go | 10 +- app/admin/service/sms_receive_log_test.go | 3 +- app/admin/service/sms_text_verified.go | 203 ++++++---- .../service/sms_text_verified_enhanced.go | 224 +++++++++++ app/admin/service/sms_text_verified_test.go | 20 +- cmd/api/server.go | 8 + cmd/proxy-server | Bin 93604 -> 0 bytes common/global/text_verified.go | 4 +- common/rediskey/sms-platform-key.go | 6 + utils/utility/crypto_helper.go | 221 +++++++++++ utils/utility/generic_queue.go | 371 ++++++++++++++++++ 29 files changed, 2762 insertions(+), 180 deletions(-) create mode 100644 app/admin/apis/sms_abnormal_number.go create mode 100644 app/admin/apis/sms_platform_key.go create mode 100644 app/admin/models/sms_abnormal_number.go create mode 100644 app/admin/models/sms_platform_key.go create mode 100644 app/admin/router/sms_abnormal_number.go create mode 100644 app/admin/router/sms_platform_key.go create mode 100644 app/admin/service/dto/sms_abnormal_number.go create mode 100644 app/admin/service/dto/sms_platform_key.go create mode 100644 app/admin/service/sms_abnormal_number.go create mode 100644 app/admin/service/sms_abnormal_number_test.go create mode 100644 app/admin/service/sms_phone_extend.go create mode 100644 app/admin/service/sms_platform_key.go create mode 100644 app/admin/service/sms_platform_key_redis.go create mode 100644 app/admin/service/sms_platform_key_redis_test.go create mode 100644 app/admin/service/sms_text_verified_enhanced.go delete mode 100644 cmd/proxy-server create mode 100644 common/rediskey/sms-platform-key.go create mode 100644 utils/utility/crypto_helper.go create mode 100644 utils/utility/generic_queue.go diff --git a/app/admin/apis/sms_abnormal_number.go b/app/admin/apis/sms_abnormal_number.go new file mode 100644 index 0000000..aa85b14 --- /dev/null +++ b/app/admin/apis/sms_abnormal_number.go @@ -0,0 +1,214 @@ +package apis + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/api" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" + _ "github.com/go-admin-team/go-admin-core/sdk/pkg/response" + + "go-admin/app/admin/models" + "go-admin/app/admin/service" + "go-admin/app/admin/service/dto" + "go-admin/common/actions" +) + +type SmsAbnormalNumber struct { + api.Api +} + +// GetPage 获取异常号码统计列表 +// @Summary 获取异常号码统计列表 +// @Description 获取异常号码统计列表 +// @Tags 异常号码统计 +// @Param platformCode query string false "平台code" +// @Param phone query string false "电话号码" +// @Param pageSize query int false "页条数" +// @Param pageIndex query int false "页码" +// @Success 200 {object} response.Response{data=response.Page{list=[]models.SmsAbnormalNumber}} "{"code": 200, "data": [...]}" +// @Router /api/v1/sms-abnormal-number [get] +// @Security Bearer +func (e SmsAbnormalNumber) GetPage(c *gin.Context) { + req := dto.SmsAbnormalNumberGetPageReq{} + s := service.SmsAbnormalNumber{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + + p := actions.GetPermissionFromContext(c) + list := make([]models.SmsAbnormalNumber, 0) + var count int64 + + err = s.GetPage(&req, p, &list, &count) + if err != nil { + e.Error(500, err, fmt.Sprintf("获取异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + + e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功") +} + +// Get 获取异常号码统计 +// @Summary 获取异常号码统计 +// @Description 获取异常号码统计 +// @Tags 异常号码统计 +// @Param id path int false "id" +// @Success 200 {object} response.Response{data=models.SmsAbnormalNumber} "{"code": 200, "data": [...]}" +// @Router /api/v1/sms-abnormal-number/{id} [get] +// @Security Bearer +func (e SmsAbnormalNumber) Get(c *gin.Context) { + req := dto.SmsAbnormalNumberGetReq{} + s := service.SmsAbnormalNumber{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + var object models.SmsAbnormalNumber + + p := actions.GetPermissionFromContext(c) + err = s.Get(&req, p, &object) + if err != nil { + e.Error(500, err, fmt.Sprintf("获取异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + + e.OK(object, "查询成功") +} + +// Insert 创建异常号码统计 +// @Summary 创建异常号码统计 +// @Description 创建异常号码统计 +// @Tags 异常号码统计 +// @Accept application/json +// @Product application/json +// @Param data body dto.SmsAbnormalNumberInsertReq true "data" +// @Success 200 {object} response.Response "{"code": 200, "message": "添加成功"}" +// @Router /api/v1/sms-abnormal-number [post] +// @Security Bearer +func (e SmsAbnormalNumber) Insert(c *gin.Context) { + req := dto.SmsAbnormalNumberInsertReq{} + s := service.SmsAbnormalNumber{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + // 设置创建人 + req.SetCreateBy(user.GetUserId(c)) + + err = s.Insert(&req) + if err != nil { + e.Error(500, err, fmt.Sprintf("创建异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + + e.OK(req.GetId(), "创建成功") +} + +// Update 修改异常号码统计 +// @Summary 修改异常号码统计 +// @Description 修改异常号码统计 +// @Tags 异常号码统计 +// @Accept application/json +// @Product application/json +// @Param id path int true "id" +// @Param data body dto.SmsAbnormalNumberUpdateReq true "body" +// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}" +// @Router /api/v1/sms-abnormal-number/{id} [put] +// @Security Bearer +func (e SmsAbnormalNumber) Update(c *gin.Context) { + req := dto.SmsAbnormalNumberUpdateReq{} + s := service.SmsAbnormalNumber{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + req.SetUpdateBy(user.GetUserId(c)) + p := actions.GetPermissionFromContext(c) + + err = s.Update(&req, p) + if err != nil { + e.Error(500, err, fmt.Sprintf("修改异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + e.OK(req.GetId(), "修改成功") +} + +// Delete 删除异常号码统计 +// @Summary 删除异常号码统计 +// @Description 删除异常号码统计 +// @Tags 异常号码统计 +// @Param data body dto.SmsAbnormalNumberDeleteReq true "body" +// @Success 200 {object} response.Response "{"code": 200, "message": "删除成功"}" +// @Router /api/v1/sms-abnormal-number [delete] +// @Security Bearer +func (e SmsAbnormalNumber) Delete(c *gin.Context) { + s := service.SmsAbnormalNumber{} + req := dto.SmsAbnormalNumberDeleteReq{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + + // req.SetUpdateBy(user.GetUserId(c)) + p := actions.GetPermissionFromContext(c) + + err = s.Remove(&req, p) + if err != nil { + e.Error(500, err, fmt.Sprintf("删除异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + e.OK(req.GetId(), "删除成功") +} + +// 同步平台和系统的差异 +func (e SmsAbnormalNumber) SyncState(c *gin.Context) { + s := service.SmsAbnormalNumber{} + err := e.MakeContext(c). + MakeOrm(). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + + err = s.SyncState() + if err != nil { + e.Error(500, err, fmt.Sprintf("同步异常号码统计失败,\r\n失败信息 %s", err.Error())) + return + } + e.OK(nil, "同步成功") +} diff --git a/app/admin/apis/sms_platform_key.go b/app/admin/apis/sms_platform_key.go new file mode 100644 index 0000000..f1b74a4 --- /dev/null +++ b/app/admin/apis/sms_platform_key.go @@ -0,0 +1,192 @@ +package apis + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/api" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" + _ "github.com/go-admin-team/go-admin-core/sdk/pkg/response" + + "go-admin/app/admin/models" + "go-admin/app/admin/service" + "go-admin/app/admin/service/dto" + "go-admin/common/actions" +) + +type SmsPlatformKey struct { + api.Api +} + +// GetPage 获取平台密钥管理列表 +// @Summary 获取平台密钥管理列表 +// @Description 获取平台密钥管理列表 +// @Tags 平台密钥管理 +// @Param platformCode query string false "平台code" +// @Param pageSize query int false "页条数" +// @Param pageIndex query int false "页码" +// @Success 200 {object} response.Response{data=response.Page{list=[]models.SmsPlatformKey}} "{"code": 200, "data": [...]}" +// @Router /api/v1/sms-platform-key [get] +// @Security Bearer +func (e SmsPlatformKey) GetPage(c *gin.Context) { + req := dto.SmsPlatformKeyGetPageReq{} + s := service.SmsPlatformKey{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + + p := actions.GetPermissionFromContext(c) + list := make([]models.SmsPlatformKey, 0) + var count int64 + + err = s.GetPage(&req, p, &list, &count) + if err != nil { + e.Error(500, err, fmt.Sprintf("获取平台密钥管理失败,\r\n失败信息 %s", err.Error())) + return + } + + e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功") +} + +// Get 获取平台密钥管理 +// @Summary 获取平台密钥管理 +// @Description 获取平台密钥管理 +// @Tags 平台密钥管理 +// @Param id path int false "id" +// @Success 200 {object} response.Response{data=models.SmsPlatformKey} "{"code": 200, "data": [...]}" +// @Router /api/v1/sms-platform-key/{id} [get] +// @Security Bearer +func (e SmsPlatformKey) Get(c *gin.Context) { + req := dto.SmsPlatformKeyGetReq{} + s := service.SmsPlatformKey{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + var object models.SmsPlatformKey + + p := actions.GetPermissionFromContext(c) + err = s.Get(&req, p, &object) + if err != nil { + e.Error(500, err, fmt.Sprintf("获取平台密钥管理失败,\r\n失败信息 %s", err.Error())) + return + } + + e.OK(object, "查询成功") +} + +// Insert 创建平台密钥管理 +// @Summary 创建平台密钥管理 +// @Description 创建平台密钥管理 +// @Tags 平台密钥管理 +// @Accept application/json +// @Product application/json +// @Param data body dto.SmsPlatformKeyInsertReq true "data" +// @Success 200 {object} response.Response "{"code": 200, "message": "添加成功"}" +// @Router /api/v1/sms-platform-key [post] +// @Security Bearer +func (e SmsPlatformKey) Insert(c *gin.Context) { + req := dto.SmsPlatformKeyInsertReq{} + s := service.SmsPlatformKey{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + // 设置创建人 + req.SetCreateBy(user.GetUserId(c)) + + err = s.Insert(&req) + if err != nil { + e.Error(500, err, fmt.Sprintf("创建平台密钥管理失败,\r\n失败信息 %s", err.Error())) + return + } + + e.OK(req.GetId(), "创建成功") +} + +// Update 修改平台密钥管理 +// @Summary 修改平台密钥管理 +// @Description 修改平台密钥管理 +// @Tags 平台密钥管理 +// @Accept application/json +// @Product application/json +// @Param id path int true "id" +// @Param data body dto.SmsPlatformKeyUpdateReq true "body" +// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}" +// @Router /api/v1/sms-platform-key/{id} [put] +// @Security Bearer +func (e SmsPlatformKey) Update(c *gin.Context) { + req := dto.SmsPlatformKeyUpdateReq{} + s := service.SmsPlatformKey{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + req.SetUpdateBy(user.GetUserId(c)) + p := actions.GetPermissionFromContext(c) + + err = s.Update(&req, p) + if err != nil { + e.Error(500, err, fmt.Sprintf("修改平台密钥管理失败,\r\n失败信息 %s", err.Error())) + return + } + e.OK(req.GetId(), "修改成功") +} + +// Delete 删除平台密钥管理 +// @Summary 删除平台密钥管理 +// @Description 删除平台密钥管理 +// @Tags 平台密钥管理 +// @Param data body dto.SmsPlatformKeyDeleteReq true "body" +// @Success 200 {object} response.Response "{"code": 200, "message": "删除成功"}" +// @Router /api/v1/sms-platform-key [delete] +// @Security Bearer +func (e SmsPlatformKey) Delete(c *gin.Context) { + s := service.SmsPlatformKey{} + req := dto.SmsPlatformKeyDeleteReq{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + if err != nil { + e.Logger.Error(err) + e.Error(500, err, err.Error()) + return + } + + // req.SetUpdateBy(user.GetUserId(c)) + p := actions.GetPermissionFromContext(c) + + err = s.Remove(&req, p) + if err != nil { + e.Error(500, err, fmt.Sprintf("删除平台密钥管理失败,\r\n失败信息 %s", err.Error())) + return + } + e.OK(req.GetId(), "删除成功") +} diff --git a/app/admin/models/sms_abnormal_number.go b/app/admin/models/sms_abnormal_number.go new file mode 100644 index 0000000..3f75385 --- /dev/null +++ b/app/admin/models/sms_abnormal_number.go @@ -0,0 +1,30 @@ +package models + +import ( + + "go-admin/common/models" + +) + +type SmsAbnormalNumber struct { + models.Model + + Account string `json:"account" gorm:"type:varchar(255);comment:账号"` + PlatformCode string `json:"platformCode" gorm:"type:varchar(20);comment:平台code"` + Phone string `json:"phone" gorm:"type:varchar(30);comment:电话号码"` + models.ModelTime + models.ControlBy +} + +func (SmsAbnormalNumber) TableName() string { + return "sms_abnormal_number" +} + +func (e *SmsAbnormalNumber) Generate() models.ActiveRecord { + o := *e + return &o +} + +func (e *SmsAbnormalNumber) GetId() interface{} { + return e.Id +} \ No newline at end of file diff --git a/app/admin/models/sms_phone.go b/app/admin/models/sms_phone.go index 7115e06..4bedcbd 100644 --- a/app/admin/models/sms_phone.go +++ b/app/admin/models/sms_phone.go @@ -12,6 +12,7 @@ type SmsPhone struct { PlatformCode string `json:"platformCode" gorm:"type:varchar(20);comment:平台code"` UserId int `json:"userId" gorm:"type:bigint;comment:用户Id"` + ApiKey string `json:"apiKey" gorm:"type:varchar(255);comment:apiKey"` Service string `json:"service" gorm:"type:varchar(50);comment:sms 服务"` ServiceCode string `json:"serviceCode" gorm:"type:varchar(30);comment:服务code"` Type int `json:"type" gorm:"type:tinyint;comment:类型 0-短效 1-长效"` diff --git a/app/admin/models/sms_platform_key.go b/app/admin/models/sms_platform_key.go new file mode 100644 index 0000000..471a725 --- /dev/null +++ b/app/admin/models/sms_platform_key.go @@ -0,0 +1,31 @@ +package models + +import ( + "go-admin/common/models" +) + +type SmsPlatformKey struct { + models.Model + + PlatformCode string `json:"platformCode" gorm:"type:varchar(20);comment:平台code"` + Account string `json:"account" gorm:"type:varchar(50);comment:账号"` + ApiKey string `json:"apiKey" gorm:"type:varchar(500);comment:平台key"` + ApiSecret string `json:"apiSecret" gorm:"type:varchar(255);comment:平台私钥"` + Status int64 `json:"status" gorm:"type:tinyint;comment:状态 1-启用 2-禁用"` + Remark string `json:"remark" gorm:"type:varchar(255);comment:备注"` + models.ModelTime + models.ControlBy +} + +func (SmsPlatformKey) TableName() string { + return "sms_platform_key" +} + +func (e *SmsPlatformKey) Generate() models.ActiveRecord { + o := *e + return &o +} + +func (e *SmsPlatformKey) GetId() interface{} { + return e.Id +} diff --git a/app/admin/router/sms_abnormal_number.go b/app/admin/router/sms_abnormal_number.go new file mode 100644 index 0000000..c39efba --- /dev/null +++ b/app/admin/router/sms_abnormal_number.go @@ -0,0 +1,29 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "go-admin/app/admin/apis" + "go-admin/common/actions" + "go-admin/common/middleware" +) + +func init() { + routerCheckRole = append(routerCheckRole, registerSmsAbnormalNumberRouter) +} + +// registerSmsAbnormalNumberRouter +func registerSmsAbnormalNumberRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + api := apis.SmsAbnormalNumber{} + r := v1.Group("/sms-abnormal-number").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + { + r.GET("", actions.PermissionAction(), api.GetPage) + r.GET("/:id", actions.PermissionAction(), api.Get) + r.POST("", api.Insert) + r.PUT("/:id", actions.PermissionAction(), api.Update) + r.DELETE("", api.Delete) + + r.POST("/sync-state", api.SyncState) //同步差异 + } +} diff --git a/app/admin/router/sms_platform_key.go b/app/admin/router/sms_platform_key.go new file mode 100644 index 0000000..72a6f07 --- /dev/null +++ b/app/admin/router/sms_platform_key.go @@ -0,0 +1,27 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "go-admin/app/admin/apis" + "go-admin/common/middleware" + "go-admin/common/actions" +) + +func init() { + routerCheckRole = append(routerCheckRole, registerSmsPlatformKeyRouter) +} + +// registerSmsPlatformKeyRouter +func registerSmsPlatformKeyRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + api := apis.SmsPlatformKey{} + r := v1.Group("/sms-platform-key").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + { + r.GET("", actions.PermissionAction(), api.GetPage) + r.GET("/:id", actions.PermissionAction(), api.Get) + r.POST("", api.Insert) + r.PUT("/:id", actions.PermissionAction(), api.Update) + r.DELETE("", api.Delete) + } +} \ No newline at end of file diff --git a/app/admin/service/dto/sms_abnormal_number.go b/app/admin/service/dto/sms_abnormal_number.go new file mode 100644 index 0000000..0c3ee72 --- /dev/null +++ b/app/admin/service/dto/sms_abnormal_number.go @@ -0,0 +1,93 @@ +package dto + +import ( + + "go-admin/app/admin/models" + "go-admin/common/dto" + common "go-admin/common/models" +) + +type SmsAbnormalNumberGetPageReq struct { + dto.Pagination `search:"-"` + PlatformCode string `form:"platformCode" search:"type:exact;column:platform_code;table:sms_abnormal_number" comment:"平台code"` + Phone string `form:"phone" search:"type:contains;column:phone;table:sms_abnormal_number" comment:"电话号码"` + SmsAbnormalNumberOrder +} + +type SmsAbnormalNumberOrder struct { + Id string `form:"idOrder" search:"type:order;column:id;table:sms_abnormal_number"` + Account string `form:"accountOrder" search:"type:order;column:account;table:sms_abnormal_number"` + PlatformCode string `form:"platformCodeOrder" search:"type:order;column:platform_code;table:sms_abnormal_number"` + Phone string `form:"phoneOrder" search:"type:order;column:phone;table:sms_abnormal_number"` + CreatedAt string `form:"createdAtOrder" search:"type:order;column:created_at;table:sms_abnormal_number"` + UpdatedAt string `form:"updatedAtOrder" search:"type:order;column:updated_at;table:sms_abnormal_number"` + DeletedAt string `form:"deletedAtOrder" search:"type:order;column:deleted_at;table:sms_abnormal_number"` + CreateBy string `form:"createByOrder" search:"type:order;column:create_by;table:sms_abnormal_number"` + UpdateBy string `form:"updateByOrder" search:"type:order;column:update_by;table:sms_abnormal_number"` + +} + +func (m *SmsAbnormalNumberGetPageReq) GetNeedSearch() interface{} { + return *m +} + +type SmsAbnormalNumberInsertReq struct { + Id int `json:"-" comment:"主键id"` // 主键id + Account string `json:"account" comment:"账号"` + PlatformCode string `json:"platformCode" comment:"平台code"` + Phone string `json:"phone" comment:"电话号码"` + common.ControlBy +} + +func (s *SmsAbnormalNumberInsertReq) Generate(model *models.SmsAbnormalNumber) { + if s.Id == 0 { + model.Model = common.Model{ Id: s.Id } + } + model.Account = s.Account + model.PlatformCode = s.PlatformCode + model.Phone = s.Phone + model.CreateBy = s.CreateBy // 添加这而,需要记录是被谁创建的 +} + +func (s *SmsAbnormalNumberInsertReq) GetId() interface{} { + return s.Id +} + +type SmsAbnormalNumberUpdateReq struct { + Id int `uri:"id" comment:"主键id"` // 主键id + Account string `json:"account" comment:"账号"` + PlatformCode string `json:"platformCode" comment:"平台code"` + Phone string `json:"phone" comment:"电话号码"` + common.ControlBy +} + +func (s *SmsAbnormalNumberUpdateReq) Generate(model *models.SmsAbnormalNumber) { + if s.Id == 0 { + model.Model = common.Model{ Id: s.Id } + } + model.Account = s.Account + model.PlatformCode = s.PlatformCode + model.Phone = s.Phone + model.UpdateBy = s.UpdateBy // 添加这而,需要记录是被谁更新的 +} + +func (s *SmsAbnormalNumberUpdateReq) GetId() interface{} { + return s.Id +} + +// SmsAbnormalNumberGetReq 功能获取请求参数 +type SmsAbnormalNumberGetReq struct { + Id int `uri:"id"` +} +func (s *SmsAbnormalNumberGetReq) GetId() interface{} { + return s.Id +} + +// SmsAbnormalNumberDeleteReq 功能删除请求参数 +type SmsAbnormalNumberDeleteReq struct { + Ids []int `json:"ids"` +} + +func (s *SmsAbnormalNumberDeleteReq) GetId() interface{} { + return s.Ids +} diff --git a/app/admin/service/dto/sms_platform_key.go b/app/admin/service/dto/sms_platform_key.go new file mode 100644 index 0000000..f337f0e --- /dev/null +++ b/app/admin/service/dto/sms_platform_key.go @@ -0,0 +1,117 @@ +package dto + +import ( + "go-admin/app/admin/models" + "go-admin/common/dto" + common "go-admin/common/models" +) + +type SmsPlatformKeyGetPageReq struct { + dto.Pagination `search:"-"` + PlatformCode string `form:"platformCode" search:"type:exact;column:platform_code;table:sms_platform_key" comment:"平台code"` + SmsPlatformKeyOrder +} + +type SmsPlatformKeyOrder struct { + Id string `form:"idOrder" search:"type:order;column:id;table:sms_platform_key"` + PlatformCode string `form:"platformCodeOrder" search:"type:order;column:platform_code;table:sms_platform_key"` + ApiKey string `form:"apiKeyOrder" search:"type:order;column:api_key;table:sms_platform_key"` + ApiSecret string `form:"apiSecretOrder" search:"type:order;column:api_secret;table:sms_platform_key"` + Status string `form:"statusOrder" search:"type:order;column:status;table:sms_platform_key"` + Remark string `form:"remarkOrder" search:"type:order;column:remark;table:sms_platform_key"` + CreatedAt string `form:"createdAtOrder" search:"type:order;column:created_at;table:sms_platform_key"` + UpdatedAt string `form:"updatedAtOrder" search:"type:order;column:updated_at;table:sms_platform_key"` + DeletedAt string `form:"deletedAtOrder" search:"type:order;column:deleted_at;table:sms_platform_key"` + CreateBy string `form:"createByOrder" search:"type:order;column:create_by;table:sms_platform_key"` + UpdateBy string `form:"updateByOrder" search:"type:order;column:update_by;table:sms_platform_key"` +} + +func (m *SmsPlatformKeyGetPageReq) GetNeedSearch() interface{} { + return *m +} + +type SmsPlatformKeyInsertReq struct { + Id int `json:"-" comment:"主键id"` // 主键id + PlatformCode string `json:"platformCode" comment:"平台code"` + Account string `json:"account" comment:"account"` + ApiKey string `json:"apiKey" comment:"平台key"` + ApiSecret string `json:"apiSecret" comment:"平台私钥"` + Status int64 `json:"status" comment:"状态 1-启用 2-禁用"` + Remark string `json:"remark" comment:"备注"` + common.ControlBy +} + +func (s *SmsPlatformKeyInsertReq) Generate(model *models.SmsPlatformKey) { + if s.Id == 0 { + model.Model = common.Model{Id: s.Id} + } + model.PlatformCode = s.PlatformCode + model.Account = s.Account + model.ApiKey = s.ApiKey + model.ApiSecret = s.ApiSecret + model.Status = s.Status + model.Remark = s.Remark + model.CreateBy = s.CreateBy // 添加这而,需要记录是被谁创建的 +} + +func (s *SmsPlatformKeyInsertReq) GetId() interface{} { + return s.Id +} + +type SmsPlatformKeyUpdateReq struct { + Id int `uri:"id" comment:"主键id"` // 主键id + PlatformCode string `json:"platformCode" comment:"平台code"` + Account string `json:"account" comment:"平台账号"` + ApiKey string `json:"apiKey" comment:"平台key"` + ApiSecret string `json:"apiSecret" comment:"平台私钥"` + Status int64 `json:"status" comment:"状态 1-启用 2-禁用"` + Remark string `json:"remark" comment:"备注"` + common.ControlBy +} + +func (s *SmsPlatformKeyUpdateReq) Generate(model *models.SmsPlatformKey) { + if s.Id == 0 { + model.Model = common.Model{Id: s.Id} + } + model.PlatformCode = s.PlatformCode + model.Account = s.Account + model.ApiKey = s.ApiKey + model.ApiSecret = s.ApiSecret + model.Status = s.Status + model.Remark = s.Remark + model.UpdateBy = s.UpdateBy // 添加这而,需要记录是被谁更新的 +} + +func (s *SmsPlatformKeyUpdateReq) GetId() interface{} { + return s.Id +} + +// SmsPlatformKeyGetReq 功能获取请求参数 +type SmsPlatformKeyGetReq struct { + Id int `uri:"id"` +} + +func (s *SmsPlatformKeyGetReq) GetId() interface{} { + return s.Id +} + +// SmsPlatformKeyDeleteReq 功能删除请求参数 +type SmsPlatformKeyDeleteReq struct { + Ids []int `json:"ids"` +} + +func (s *SmsPlatformKeyDeleteReq) GetId() interface{} { + return s.Ids +} + +type SmsPlatformKeyQueueDto struct { + PlatformCode string `json:"platformCode" comment:"平台code"` + Account string `json:"account" comment:"平台账号"` + ApiKey string `json:"apiKey" comment:"平台key"` + ApiSecret string `json:"apiSecret" comment:"平台私钥"` +} + +type SmsPlatformKeyGroupDto struct { + PlatformCode string `json:"platformCode"` + Count int `json:"count"` +} diff --git a/app/admin/service/dto/text_verified.go b/app/admin/service/dto/text_verified.go index c57f8bf..58e120c 100644 --- a/app/admin/service/dto/text_verified.go +++ b/app/admin/service/dto/text_verified.go @@ -152,8 +152,9 @@ type VerificationDTO struct { // * nonrenewableActive: 服务活跃中。 // * nonrenewableExpired: 服务已过期。 // * nonrenewableRefunded: 服务已退款。 - State string `json:"state" comment:"verificationPending┃verificationCompleted┃verificationCanceled┃verificationTimedOut┃verificationReported┃verificationRefunded┃verificationReused┃verificationReactivated┃renewableActive┃renewableOverdue┃renewableExpired┃renewableRefunded┃nonrenewableActive┃nonrenewableExpired┃nonrenewableRefunded"` - TotalCost float64 `json:"totalCost"` + State string `json:"state" comment:"verificationPending┃verificationCompleted┃verificationCanceled┃verificationTimedOut┃verificationReported┃verificationRefunded┃verificationReused┃verificationReactivated┃renewableActive┃renewableOverdue┃renewableExpired┃renewableRefunded┃nonrenewableActive┃nonrenewableExpired┃nonrenewableRefunded"` + TotalCost float64 `json:"totalCost"` + IsIncludedForNextRenewal bool `json:"isIncludedForNextRenewal" comment:"是否自动续期"` } // 长效详情 diff --git a/app/admin/service/sms_abnormal_number.go b/app/admin/service/sms_abnormal_number.go new file mode 100644 index 0000000..618386d --- /dev/null +++ b/app/admin/service/sms_abnormal_number.go @@ -0,0 +1,250 @@ +package service + +import ( + "errors" + "time" + + "github.com/go-admin-team/go-admin-core/sdk/service" + "gorm.io/gorm" + + "go-admin/app/admin/models" + "go-admin/app/admin/service/dto" + "go-admin/common/actions" + cDto "go-admin/common/dto" + "go-admin/common/global" +) + +type SmsAbnormalNumber struct { + service.Service +} + +// GetPage 获取SmsAbnormalNumber列表 +func (e *SmsAbnormalNumber) GetPage(c *dto.SmsAbnormalNumberGetPageReq, p *actions.DataPermission, list *[]models.SmsAbnormalNumber, count *int64) error { + var err error + var data models.SmsAbnormalNumber + + err = e.Orm.Model(&data). + Scopes( + cDto.MakeCondition(c.GetNeedSearch()), + cDto.Paginate(c.GetPageSize(), c.GetPageIndex()), + actions.Permission(data.TableName(), p), + ). + Find(list).Limit(-1).Offset(-1). + Count(count).Error + if err != nil { + e.Log.Errorf("SmsAbnormalNumberService GetPage error:%s \r\n", err) + return err + } + return nil +} + +// Get 获取SmsAbnormalNumber对象 +func (e *SmsAbnormalNumber) Get(d *dto.SmsAbnormalNumberGetReq, p *actions.DataPermission, model *models.SmsAbnormalNumber) error { + var data models.SmsAbnormalNumber + + err := e.Orm.Model(&data). + Scopes( + actions.Permission(data.TableName(), p), + ). + First(model, d.GetId()).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + err = errors.New("查看对象不存在或无权查看") + e.Log.Errorf("Service GetSmsAbnormalNumber error:%s \r\n", err) + return err + } + if err != nil { + e.Log.Errorf("db error:%s", err) + return err + } + return nil +} + +// Insert 创建SmsAbnormalNumber对象 +func (e *SmsAbnormalNumber) Insert(c *dto.SmsAbnormalNumberInsertReq) error { + var err error + var data models.SmsAbnormalNumber + c.Generate(&data) + err = e.Orm.Create(&data).Error + if err != nil { + e.Log.Errorf("SmsAbnormalNumberService Insert error:%s \r\n", err) + return err + } + return nil +} + +// Update 修改SmsAbnormalNumber对象 +func (e *SmsAbnormalNumber) Update(c *dto.SmsAbnormalNumberUpdateReq, p *actions.DataPermission) error { + var err error + var data = models.SmsAbnormalNumber{} + e.Orm.Scopes( + actions.Permission(data.TableName(), p), + ).First(&data, c.GetId()) + c.Generate(&data) + + db := e.Orm.Save(&data) + if err = db.Error; err != nil { + e.Log.Errorf("SmsAbnormalNumberService Save error:%s \r\n", err) + return err + } + if db.RowsAffected == 0 { + return errors.New("无权更新该数据") + } + return nil +} + +// Remove 删除SmsAbnormalNumber +func (e *SmsAbnormalNumber) Remove(d *dto.SmsAbnormalNumberDeleteReq, p *actions.DataPermission) error { + var data models.SmsAbnormalNumber + + db := e.Orm.Model(&data). + Scopes( + actions.Permission(data.TableName(), p), + ).Delete(&data, d.GetId()) + if err := db.Error; err != nil { + e.Log.Errorf("Service RemoveSmsAbnormalNumber error:%s \r\n", err) + return err + } + if db.RowsAffected == 0 { + return errors.New("无权删除该数据") + } + return nil +} + +// 获取平台上不再系统内的自动续期号码 +func (e *SmsAbnormalNumber) SyncState() error { + textVerified := SmsTextVerified{Service: e.Service} + // daiSysms := SmsDaisysms{Service: e.Service} 没有列表api + + textVerifiedNumbers, err := textVerified.GetAllPlatformNumbers() + + if err != nil { + e.Log.Errorf("获取长效号码列表失败 %v", err) + } else { + // 获取系统内textverified平台的自动续期号码 + // 参数: 平台代码, 号码类型(1=长效), 自动续费(1=开启), 激活状态(2=已激活) + systemNumbers, err := e.GetPhoneByPlatformCode(global.SmsPlatformTextVerified, 1, 1, 2) + if err != nil { + e.Log.Errorf("获取系统内textverified自动续期号码失败: %v", err) + return err + } + + e.Log.Infof("系统内textverified自动续期号码数量: %d", len(systemNumbers)) + + // 比较平台号码和系统号码,找出差异 + missingNumbers := e.findMissingNumbers(textVerifiedNumbers, systemNumbers) + + if len(missingNumbers) > 0 { + e.Log.Warnf("发现%d个平台开启自动续费但系统内缺失的号码", len(missingNumbers)) + + err = e.Orm.Transaction(func(tx *gorm.DB) error { + if err1 := tx.Model(&models.SmsAbnormalNumber{}). + Unscoped(). + Where(" platform_code =?", global.SmsPlatformTextVerified). + Delete(&models.SmsAbnormalNumber{}). + Error; err1 != nil { + return err1 + } + + if err1 := tx.CreateInBatches(missingNumbers, 100).Error; err1 != nil { + return err1 + } + + return nil + }) + + if err != nil { + e.Log.Errorf("同步后保存差异数据失败 %v", err) + } + } else { + e.Log.Infof("所有平台自动续期号码在系统内都存在") + } + } + + return nil +} + +// findMissingNumbers 比较平台号码和系统号码,找出平台上有但系统内没有的号码 +// 参数: +// - platformNumbers: 平台上的号码列表 +// - systemNumbers: 系统内的号码列表 +// +// 返回: +// - []dto.VerificationDTO: 平台上有但系统内缺失的号码列表 +func (e *SmsAbnormalNumber) findMissingNumbers(platformNumbers map[string][]dto.VerificationDTO, systemNumbers []models.SmsPhone) []models.SmsAbnormalNumber { + // 创建系统号码的映射表,用于快速查找 + systemNumberMap := make(map[string]bool) + for _, sysPhone := range systemNumbers { + // 使用号码作为key + systemNumberMap[sysPhone.Phone] = true + } + + // 找出平台上有但系统内没有的号码 + missingNumbers := make([]models.SmsAbnormalNumber, 0) + for account, platformPhone := range platformNumbers { + for _, v := range platformPhone { + if v.State == "renewableActive" && v.IsIncludedForNextRenewal && !systemNumberMap[v.Number] { + missingNumbers = append(missingNumbers, models.SmsAbnormalNumber{ + PlatformCode: global.SmsPlatformTextVerified, + Account: account, + Phone: v.Number, + }) + } + } + } + + return missingNumbers +} + +// GetPhoneByPlatformCode 循环获取对应平台自动续期的号码 +// 为避免一次性查询过多数据,使用分页查询,每次最多查询100条记录 +// @param platformCode 平台代码 (如: textverified, daisysms) +// @return []models.SmsPhone 号码列表 +// @return error 错误信息 +func (e *SmsAbnormalNumber) GetPhoneByPlatformCode(platformCode string, numberType, autoRenewal, status int) ([]models.SmsPhone, error) { + var phones []models.SmsPhone + var allPhones []models.SmsPhone + + // 分页参数 + pageSize := 1000 // 每页最多1000条记录 + offset := 0 + + // 循环分页查询,避免一次性查询过多数据 + for { + // 查询条件: + // 1. platform_code = platformCode (指定平台) + // 2. type = numberType (号码类型) + // 3. auto_renewal = autoRenewal (自动续费状态) + // 4. actived = status (激活状态) + err := e.Orm.Model(&models.SmsPhone{}). + Where("platform_code = ? AND type = ? AND auto_renewal = ? AND actived = ? AND expire_time > ?", + platformCode, numberType, autoRenewal, status, time.Now()). + Order("id ASC"). + Limit(pageSize). + Offset(offset). + Find(&phones).Error + + if err != nil { + e.Log.Errorf("查询平台[%s]自动续期号码失败: %v", platformCode, err) + return nil, err + } + + // 如果没有查询到数据,说明已经查询完毕 + if len(phones) == 0 { + break + } + + // 将本次查询结果添加到总结果中 + allPhones = append(allPhones, phones...) + + // 如果本次查询结果少于pageSize,说明已经是最后一页 + if len(phones) < pageSize { + break + } + + // 更新偏移量,准备查询下一页 + offset += pageSize + } + + e.Log.Infof("平台[%s]查询到%d个自动续期号码", platformCode, len(allPhones)) + return allPhones, nil +} diff --git a/app/admin/service/sms_abnormal_number_test.go b/app/admin/service/sms_abnormal_number_test.go new file mode 100644 index 0000000..c180105 --- /dev/null +++ b/app/admin/service/sms_abnormal_number_test.go @@ -0,0 +1,20 @@ +package service + +import ( + "testing" + + "github.com/go-admin-team/go-admin-core/logger" +) + +// 同步差异号码 +func TestSyncSat(t *testing.T) { + db := initSetting() + + smsAbnormalNumber := SmsAbnormalNumber{} + smsAbnormalNumber.Orm = db + smsAbnormalNumber.Log = logger.NewHelper(logger.DefaultLogger) + + if err := smsAbnormalNumber.SyncState(); err != nil { + t.Errorf("同步差异号码失败 %v", err) + } +} diff --git a/app/admin/service/sms_daisysms.go b/app/admin/service/sms_daisysms.go index c1a742f..e92e9e8 100644 --- a/app/admin/service/sms_daisysms.go +++ b/app/admin/service/sms_daisysms.go @@ -25,7 +25,9 @@ type SmsDaisysms struct { // 同步价格 func (e SmsDaisysms) SyncPrices() { - prices, err := e.GetPrices() + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetRoundRobinKey(global.SmsPlatformDaisysms) + prices, err := e.GetPrices(apiInfo) if err != nil { e.Log.Errorf("GetPrices error: %v", err) @@ -44,16 +46,12 @@ func (e SmsDaisysms) SyncPrices() { // service 服务code // maxPrice 最大价格 // period 时长(月) -func (e *SmsDaisysms) GetNumberForApi(getType int, serviceCode string, maxPrice decimal.Decimal, period int) (int, string, int) { +func (e *SmsDaisysms) GetNumberForApi(apiInfo *dto.SmsPlatformKeyQueueDto, getType int, serviceCode string, maxPrice decimal.Decimal, period int) (int, string, int) { acitvationId := 0 result := "" resultCode := statuscode.Success - configResp, code := GetApiKey(e) - if code != statuscode.Success { - return acitvationId, result, code - } - url := fmt.Sprintf("?api_key=%s&action=getNumber&service=%s", configResp.ConfigValue, serviceCode) + url := fmt.Sprintf("?api_key=%s&action=getNumber&service=%s", apiInfo.ApiKey, serviceCode) if getType == 1 { url = fmt.Sprintf("%s&duration=%dM", url, period) @@ -103,13 +101,8 @@ func (e *SmsDaisysms) GetNumberForApi(getType int, serviceCode string, maxPrice } // 设置租赁结束 -func (e *SmsDaisysms) setStatus(id int) int { - configResp, code := GetApiKey(e) - if code != statuscode.Success { - return code - } - - url := fmt.Sprintf("?api_key=%s&action=setStatus&id=%d&status=6", configResp.ConfigValue, id) +func (e *SmsDaisysms) setStatus(id int, apiInfo *dto.SmsPlatformKeyQueueDto) int { + url := fmt.Sprintf("?api_key=%s&action=setStatus&id=%d&status=6", apiInfo.ApiKey, id) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err := client.GetRaw(url, nil) @@ -127,34 +120,14 @@ func (e *SmsDaisysms) setStatus(id int) int { } } -func GetApiKey(e *SmsDaisysms) (dto.GetSysConfigByKEYForServiceResp, int) { - configService := SysConfig{Service: e.Service} - configResp := dto.GetSysConfigByKEYForServiceResp{} - err := configService.GetWithKey(&dto.SysConfigByKeyReq{ConfigKey: "sms_key"}, &configResp) - - if err != nil { - e.Log.Errorf("获取短信api失败, %s", err) - return dto.GetSysConfigByKEYForServiceResp{}, statuscode.ServerError - } - - if configResp.ConfigValue == "" { - e.Log.Error("短信api不能为空") - return dto.GetSysConfigByKEYForServiceResp{}, statuscode.ServerError - } - return configResp, statuscode.Success -} - // GetCodeForApi 获取验证码 // messageId 短信id // return 验证码, 状态码 -func (e *SmsDaisysms) GetCodeForApi(messageId string) (string, int) { +func (e *SmsDaisysms) GetCodeForApi(messageId string, apiInfo *dto.SmsPlatformKeyQueueDto) (string, int) { result := "" - key, code := GetApiKey(e) + code := statuscode.Success - if code != statuscode.Success { - return result, code - } - url := fmt.Sprintf("?api_key=%s&action=getStatus&id=%s", key.ConfigValue, messageId) + url := fmt.Sprintf("?api_key=%s&action=getStatus&id=%s", apiInfo.ApiKey, messageId) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err := client.GetRaw(url, nil) @@ -183,13 +156,10 @@ func (e *SmsDaisysms) GetCodeForApi(messageId string) (string, int) { // getExtraActivation 获取额外的激活 // messageId 短信id // return 验证码, 状态码 -func (e *SmsDaisysms) getExtraActivation(activationId string) (int, int) { +func (e *SmsDaisysms) getExtraActivation(activationId string, apiInfo *dto.SmsPlatformKeyQueueDto) (int, int) { result := 0 - key, err := GetApiKey(e) - if err != statuscode.Success { - return 0, statuscode.ServerError - } - url := fmt.Sprintf("?api_key=%s&action=getExtraActivation&activationId=%s", key.ConfigValue, activationId) + + url := fmt.Sprintf("?api_key=%s&action=getExtraActivation&activationId=%s", apiInfo.ApiKey, activationId) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err1 := client.GetRaw(url, nil) @@ -224,14 +194,9 @@ func (e *SmsDaisysms) getExtraActivation(activationId string) (int, int) { } // KeepLongTerm 长期租赁 -func (e *SmsDaisysms) KeepLongTerm(activationId string) int { - key, code := GetApiKey(e) +func (e *SmsDaisysms) KeepLongTerm(activationId string, apiInfo *dto.SmsPlatformKeyQueueDto) int { - if code != statuscode.Success { - return statuscode.ServerError - } - - url := fmt.Sprintf("?api_key=%s&action=keep&id=%s", key.ConfigValue, activationId) + url := fmt.Sprintf("?api_key=%s&action=keep&id=%s", apiInfo.ApiKey, activationId) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err := client.GetRaw(url, nil) @@ -250,15 +215,8 @@ func (e *SmsDaisysms) KeepLongTerm(activationId string) int { } // 取消租赁 -func (e *SmsDaisysms) CancelRental(activationId string) int { - key, code := GetApiKey(e) - - if code != statuscode.Success { - e.Log.Errorf("租赁api请求失败 %s") - return statuscode.ServerError - } - - url := fmt.Sprintf("?api_key=%s&action=setStatus&id=%s&status=8", key.ConfigValue, activationId) +func (e *SmsDaisysms) CancelRental(activationId string, apiInfo *dto.SmsPlatformKeyQueueDto) int { + url := fmt.Sprintf("?api_key=%s&action=setStatus&id=%s&status=8", apiInfo.ApiKey, activationId) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err := client.GetRaw(url, nil) @@ -277,16 +235,10 @@ func (e *SmsDaisysms) CancelRental(activationId string) int { } // 获取价格 -func (e *SmsDaisysms) GetPrices() ([]dto.DaisysmsPriceResp, error) { +func (e *SmsDaisysms) GetPrices(apiInfo *dto.SmsPlatformKeyQueueDto) ([]dto.DaisysmsPriceResp, error) { result := make([]dto.DaisysmsPriceResp, 0) - key, code := GetApiKey(e) - if code != statuscode.Success { - e.Log.Errorf("租赁api请求失败 %s") - return result, errors.New("获取租赁ApiKey失败") - } - - url := fmt.Sprintf("?api_key=%s&action=getPrices", key.ConfigValue) + url := fmt.Sprintf("?api_key=%s&action=getPrices", apiInfo.ApiKey) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, status, err := client.GetRaw(url, nil) @@ -327,15 +279,9 @@ func (e *SmsDaisysms) GetPrices() ([]dto.DaisysmsPriceResp, error) { // ChangeAutoRenew 修改自动续期 // activationId 短信id // status 状态 -func (e *SmsDaisysms) ChangeAutoRenewForApi(activationId string, status bool) int { - key, err := GetApiKey(e) +func (e *SmsDaisysms) ChangeAutoRenewForApi(activationId string, status bool, apiInfo *dto.SmsPlatformKeyQueueDto) int { - if err != statuscode.Success { - e.Log.Errorf("查询sms api请求失败 %d", activationId) - return statuscode.ServerError - } - - url := fmt.Sprintf("?api_key=%s&action=setAutoRenew&id=%s&value=%t", key.ConfigValue, activationId, status) + url := fmt.Sprintf("?api_key=%s&action=setAutoRenew&id=%s&value=%t", apiInfo.ApiKey, activationId, status) client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.DaisysmsUrl, nil) bytes, _, err1 := client.GetRaw(url, nil) diff --git a/app/admin/service/sms_phone.go b/app/admin/service/sms_phone.go index 98885d0..f2b08fc 100644 --- a/app/admin/service/sms_phone.go +++ b/app/admin/service/sms_phone.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "net/http" "strconv" "time" @@ -93,15 +94,22 @@ func (e *SmsPhone) CancelNumber(req *dto.SmsPhoneCancelNumberReq, userId int) in // 聚合取消 func (e *SmsPhone) CancelNumberManage(data *models.SmsPhone) int { + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetApiInfo(data.PlatformCode, data.ApiKey) + if err != nil { + e.Log.Errorf("获取平台密钥失败, %s", err) + return statuscode.ServerError + } + switch data.PlatformCode { case global.SmsPlatformDaisysms: service := SmsDaisysms{Service: e.Service} - return service.CancelRental(data.NewActivationId) + return service.CancelRental(data.NewActivationId, &apiInfo) case global.SmsPlatformTextVerified: service := SmsTextVerified{Service: e.Service} - return service.CancelRental(data.NewActivationId, data.Type) + return service.CancelRental(data.NewActivationId, data.Type, &apiInfo) default: return statuscode.SmsPlatformUnavailable } @@ -139,13 +147,21 @@ func (e *SmsPhone) DeleteMyNumber(req *dto.DeleteMyNumberReq, userId int) int { func (e *SmsPhone) WeakUp(req *dto.WeakUpReq, userId int, defult bool) (dto.WeakUpResp, int) { smsPhone := models.SmsPhone{} result := dto.WeakUpResp{} - if err := e.Orm.Model(smsPhone).Where("activation_id =? and user_id= ?", req.ActivationId, userId).First(&smsPhone).Error; err != nil { + if err := e.Orm.Model(smsPhone). + Where("activation_id =? and user_id= ?", req.ActivationId, userId). + First(&smsPhone).Error; err != nil { e.Log.Errorf("获取短信号码失败, %s", err) return result, statuscode.ServerError } if smsPhone.Status != 1 || defult { - newActivationId, messageId, startTime, endTime, code := e.WeakUpManage(&smsPhone) + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetApiInfo(smsPhone.PlatformCode, smsPhone.ApiKey) + if err != nil { + e.Log.Errorf("获取平台密钥失败, %s", err) + return result, statuscode.ServerError + } + newActivationId, messageId, startTime, endTime, code := e.WeakUpManage(&smsPhone, &apiInfo) if code == statuscode.ServerError { return result, code @@ -179,15 +195,15 @@ func (e *SmsPhone) WeakUp(req *dto.WeakUpReq, userId int, defult bool) (dto.Weak // 唤醒号码 // returns newActivationId,messageId, code -func (e *SmsPhone) WeakUpManage(req *models.SmsPhone) (string, string, *time.Time, *time.Time, int) { +func (e *SmsPhone) WeakUpManage(req *models.SmsPhone, apiInfo *dto.SmsPlatformKeyQueueDto) (string, string, *time.Time, *time.Time, int) { switch req.PlatformCode { case global.SmsPlatformDaisysms: service := SmsDaisysms{Service: e.Service} - id, code := service.getExtraActivation(req.ActivationId) + id, code := service.getExtraActivation(req.ActivationId, apiInfo) return strconv.Itoa(id), "", nil, nil, code case global.SmsPlatformTextVerified: service := SmsTextVerified{Service: e.Service} - resp, code := service.getExtraActivation(req.NewActivationId) + resp, code := service.getExtraActivation(req.NewActivationId, apiInfo) return resp.ReservationId, resp.Id, resp.UsageWindowStart, resp.UsageWindowEnd, code default: return "", "", nil, nil, statuscode.SmsPlatformUnavailable @@ -300,7 +316,7 @@ func (e *SmsPhone) DoGetNumber(balanceService *MemberBalance, req *dto.GetNumber } now := time.Now() - activationId, phone, code, expireTime, startTime, endTime := e.GetNumberManage(req.PlatformCode, req.Type, req.ServiceCode, price, req.Period) + activationId, phone, code, expireTime, startTime, endTime, apiInfo := e.GetNumberManage(req.PlatformCode, req.Type, req.ServiceCode, price, req.Period) if code != statuscode.Success { return decimal.Decimal{}, models.SmsPhone{}, code @@ -320,6 +336,7 @@ func (e *SmsPhone) DoGetNumber(balanceService *MemberBalance, req *dto.GetNumber smsPhone.Actived = 1 smsPhone.StartTime = startTime smsPhone.EndTime = endTime + smsPhone.ApiKey = apiInfo.ApiKey smsPhone.Price = price if req.Type == 1 { @@ -361,7 +378,7 @@ func (e *SmsPhone) DoGetNumber(balanceService *MemberBalance, req *dto.GetNumber if req.Type == 1 { var code int if req.PlatformCode == global.SmsPlatformDaisysms { - code = e.ChangeAutoRenewManage(smsPhone.PlatformCode, smsPhone.NewActivationId, true) + code = e.ChangeAutoRenewManage(smsPhone.PlatformCode, smsPhone.ApiKey, smsPhone.NewActivationId, true) } else { code = http.StatusOK e.WeakUp(&dto.WeakUpReq{ActivationId: smsPhone.NewActivationId}, userId, true) @@ -386,24 +403,32 @@ func (e *SmsPhone) DoGetNumber(balanceService *MemberBalance, req *dto.GetNumber // *expirateTime 号码过期时间 // *startTime 长效号码单次接码开始使用(textverified有用) // *endTime 长效号码单次接码过期时间(textverified有用) -func (e *SmsPhone) GetNumberManage(platformCode string, typ int, serviceCode string, price decimal.Decimal, period int) (string, string, int, *time.Time, *time.Time, *time.Time) { +func (e *SmsPhone) GetNumberManage(platformCode string, typ int, serviceCode string, + price decimal.Decimal, period int) (string, string, int, *time.Time, *time.Time, *time.Time, *dto.SmsPlatformKeyQueueDto) { + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + queue, err := smsPlatformKeyRedis.GetRoundRobinKey(platformCode) + if err != nil { + e.Log.Errorf("获取短信平台队列失败, %s", err) + return "", "", statuscode.ServerError, nil, nil, nil, queue + } + switch platformCode { case global.SmsPlatformDaisysms: service := SmsDaisysms{Service: e.Service} - activationId, phone, code := service.GetNumberForApi(typ, serviceCode, price, period) + activationId, phone, code := service.GetNumberForApi(queue, typ, serviceCode, price, period) - return strconv.Itoa(activationId), phone, code, nil, nil, nil + return strconv.Itoa(activationId), phone, code, nil, nil, nil, queue case global.SmsPlatformTextVerified: service := SmsTextVerified{Service: e.Service} - resp, code := service.GetNumberAndWakeUp(typ, serviceCode, price, period) + resp, code := service.GetNumberAndWakeUp(typ, serviceCode, price, period, queue) if code != statuscode.Success { - return "", "", code, nil, resp.StartTime, resp.EndTime + return "", "", code, nil, resp.StartTime, resp.EndTime, queue } - return resp.Id, resp.Phone, code, resp.EndAt, resp.StartTime, resp.EndTime + return resp.Id, resp.Phone, code, resp.EndAt, resp.StartTime, resp.EndTime, queue default: - return "", "", statuscode.SmsPlatformUnavailable, nil, nil, nil + return "", "", statuscode.SmsPlatformUnavailable, nil, nil, nil, queue } } @@ -445,10 +470,29 @@ func (e *SmsPhone) SyncCodes() error { if err := e.Orm.Model(models.SmsPhone{}).Where("status =1").Find(&phones).Error; err != nil { return err } + mapData := make(map[string]*dto.SmsPlatformKeyQueueDto) + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) for _, item := range phones { + key := fmt.Sprintf("%s:%s", item.PlatformCode, item.ApiKey) + + if _, ok := mapData[key]; !ok { + apiInfo, _ := smsPlatformKeyRedis.GetApiInfo(item.PlatformCode, item.ApiKey) + + if apiInfo.ApiKey != "" { + mapData[key] = &apiInfo + } + } + } + + for _, item := range phones { + apiInfo, ok := mapData[fmt.Sprintf("%s:%s", item.PlatformCode, item.ApiKey)] + if !ok { + continue + } + code, codeStatus := e.GetCodeManage(item.PlatformCode, item.NewActivationId, - item.Type, item.UserId, item.Service, item.ServiceCode) + item.Type, item.UserId, item.Service, item.ServiceCode, apiInfo) if code == "" { var expireTime time.Time @@ -510,15 +554,15 @@ func (e *SmsPhone) SyncCodes() error { } // 获取验证码 -func (e *SmsPhone) GetCodeManage(platformCode string, messageId string, typ int, userId int, smsService, serviceCode string) (string, int) { +func (e *SmsPhone) GetCodeManage(platformCode string, messageId string, typ int, userId int, smsService, serviceCode string, apiInfo *dto.SmsPlatformKeyQueueDto) (string, int) { switch platformCode { case global.SmsPlatformDaisysms: service := SmsDaisysms{Service: e.Service} - return service.GetCodeForApi(messageId) + return service.GetCodeForApi(messageId, apiInfo) case global.SmsPlatformTextVerified: service := SmsTextVerified{Service: e.Service} - return service.GetCode(messageId, typ, userId, smsService, serviceCode) + return service.GetCode(messageId, typ, userId, smsService, serviceCode, apiInfo) default: return "", statuscode.SmsPlatformUnavailable } @@ -685,7 +729,7 @@ func (e *SmsPhone) handleInsufficientBalance(phone models.SmsPhone) error { } // 调用平台取消续费接口 - code := e.ChangeAutoRenewManage(phone.PlatformCode, phone.ActivationId, false) + code := e.ChangeAutoRenewManage(phone.PlatformCode, phone.ApiKey, phone.ActivationId, false) if code != statuscode.Success { params["auto_renewal"] = 1 params["remark"] = "" @@ -778,7 +822,7 @@ func (e *SmsPhone) DoChangeAutoRenewal(data models.SmsPhone, autoRenewal int) (b if data.Actived == 3 { code = http.StatusOK } else { - code = e.ChangeAutoRenewManage(data.PlatformCode, data.ActivationId, status) + code = e.ChangeAutoRenewManage(data.PlatformCode, data.ApiKey, data.ActivationId, status) if code != statuscode.Success { return true, statuscode.ServerError @@ -792,16 +836,24 @@ func (e *SmsPhone) DoChangeAutoRenewal(data models.SmsPhone, autoRenewal int) (b } // ChangeAutoRenewManage 修改自动续期管理 -func (e *SmsPhone) ChangeAutoRenewManage(platform string, activationId string, status bool) int { +func (e *SmsPhone) ChangeAutoRenewManage(platform, apiKey string, activationId string, status bool) int { + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetApiInfo(platform, apiKey) + + if err != nil { + e.Log.Errorf("获取平台密钥失败: %v", err) + return statuscode.ServerError + } + switch platform { case global.SmsPlatformDaisysms: service := SmsDaisysms{Service: e.Service} - return service.ChangeAutoRenewForApi(activationId, status) + return service.ChangeAutoRenewForApi(activationId, status, &apiInfo) case global.SmsPlatformTextVerified: service := SmsTextVerified{Service: e.Service} - return service.Renew(activationId, status) + return service.Renew(activationId, status, &apiInfo) default: return statuscode.SmsPlatformUnavailable } diff --git a/app/admin/service/sms_phone_extend.go b/app/admin/service/sms_phone_extend.go new file mode 100644 index 0000000..bb27ba5 --- /dev/null +++ b/app/admin/service/sms_phone_extend.go @@ -0,0 +1 @@ +package service diff --git a/app/admin/service/sms_platform_key.go b/app/admin/service/sms_platform_key.go new file mode 100644 index 0000000..fc1b28a --- /dev/null +++ b/app/admin/service/sms_platform_key.go @@ -0,0 +1,314 @@ +package service + +import ( + "errors" + "fmt" + + "github.com/go-admin-team/go-admin-core/sdk/service" + "gorm.io/gorm" + + "go-admin/app/admin/models" + "go-admin/app/admin/service/dto" + "go-admin/common/actions" + cDto "go-admin/common/dto" + "go-admin/utils/utility" +) + +type SmsPlatformKey struct { + service.Service +} + +// GetPage 获取SmsPlatformKey列表 +func (e *SmsPlatformKey) GetPage(c *dto.SmsPlatformKeyGetPageReq, p *actions.DataPermission, list *[]models.SmsPlatformKey, count *int64) error { + var err error + var data models.SmsPlatformKey + + err = e.Orm.Model(&data). + Scopes( + cDto.MakeCondition(c.GetNeedSearch()), + cDto.Paginate(c.GetPageSize(), c.GetPageIndex()), + actions.Permission(data.TableName(), p), + ). + Find(list).Limit(-1).Offset(-1). + Count(count).Error + if err != nil { + e.Log.Errorf("SmsPlatformKeyService GetPage error:%s \r\n", err) + return err + } + + for index := range *list { + (*list)[index].ApiKey = utility.DesensitizeGeneric((*list)[index].ApiKey, 3, 3, '*') + (*list)[index].ApiSecret = utility.DesensitizeGeneric((*list)[index].ApiSecret, 3, 3, '*') + } + + return nil +} + +// GetRandomKey 随机获取一个密钥 +func (e *SmsPlatformKey) GetRandomKey(platformCode string) (*dto.SmsPlatformKeyQueueDto, error) { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.GetRandomKey(platformCode) +} + +// GetRoundRobinKey 轮询获取密钥 +func (e *SmsPlatformKey) GetRoundRobinKey(platformCode string) (*dto.SmsPlatformKeyQueueDto, error) { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.GetRoundRobinKey(platformCode) +} + +// GetQueueLength 获取队列长度 +func (e *SmsPlatformKey) GetQueueLength(platformCode string) (int64, error) { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.GetQueueLength(platformCode) +} + +// ClearPlatformQueue 清空指定平台的队列 +func (e *SmsPlatformKey) ClearPlatformQueue(platformCode string) error { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.ClearPlatformQueue(platformCode) +} + +// Get 获取SmsPlatformKey对象 +func (e *SmsPlatformKey) Get(d *dto.SmsPlatformKeyGetReq, p *actions.DataPermission, model *models.SmsPlatformKey) error { + var data models.SmsPlatformKey + + err := e.Orm.Model(&data). + Scopes( + actions.Permission(data.TableName(), p), + ). + First(model, d.GetId()).Error + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + err = errors.New("查看对象不存在或无权查看") + e.Log.Errorf("Service GetSmsPlatformKey error:%s \r\n", err) + return err + } + if err != nil { + e.Log.Errorf("db error:%s", err) + return err + } + return nil +} + +// Insert 创建SmsPlatformKey对象 +func (e *SmsPlatformKey) Insert(c *dto.SmsPlatformKeyInsertReq) error { + var err error + var data models.SmsPlatformKey + var count int64 + c.Generate(&data) + + if err := e.Orm.Model(&models.SmsPlatformKey{}). + Where("platform_code = ? and api_key =? and account=?", data.PlatformCode, data.ApiKey, data.Account). + Count(&count).Error; err != nil { + e.Log.Errorf("SmsPlatformKeyService Insert error:%s \r\n", err) + return err + } + if count > 0 { + return errors.New("ApiKey或账号 已存在") + } + + err = e.Orm.Create(&data).Error + if err != nil { + e.Log.Errorf("SmsPlatformKeyService Insert error:%s \r\n", err) + return err + } + + if err := e.AddQueque(dto.SmsPlatformKeyQueueDto{ + PlatformCode: data.PlatformCode, + Account: data.Account, + ApiKey: data.ApiKey, + ApiSecret: data.ApiSecret, + }); err != nil { + e.Log.Errorf("添加队列失败,%v", err) + return err + } + + return nil +} + +// Update 修改SmsPlatformKey对象 +func (e *SmsPlatformKey) Update(c *dto.SmsPlatformKeyUpdateReq, p *actions.DataPermission) error { + var err error + var data = models.SmsPlatformKey{} + var count int64 + e.Orm.Scopes( + actions.Permission(data.TableName(), p), + ).First(&data, c.GetId()) + oldKey := data.ApiKey + oldSecret := data.ApiSecret + oldStatus := data.Status + oldPlatformCode := data.PlatformCode + + if err1 := e.Orm.Model(&models.SmsPlatformKey{}). + Where("platform_code = ? and api_key =? and account=? and id != ?", + data.PlatformCode, data.ApiKey, data.Account, data.Id). + Count(&count).Error; err1 != nil { + e.Log.Errorf("SmsPlatformKeyService Insert error:%s \r\n", err1) + return err1 + } + if count > 0 { + return errors.New("ApiKey或账号 已存在") + } + + c.Generate(&data) + + // 如果要将启用状态改为禁用状态,需要检查该平台是否还有其他启用的密钥 + if data.Status == 2 { + var remainingCount int64 + err1 := e.Orm.Model(&models.SmsPlatformKey{}). + Where("platform_code = ? AND status = ? AND id != ?", oldPlatformCode, 1, c.GetId()). + Count(&remainingCount).Error + if err1 != nil { + e.Log.Errorf("检查平台剩余启用密钥失败: %v", err1) + return errors.New("检查平台剩余启用密钥失败") + } + if remainingCount == 0 { + return fmt.Errorf("平台 %s 至少需要保留一个启用状态的密钥,无法禁用", oldPlatformCode) + } + } + + db := e.Orm.Save(&data) + if err = db.Error; err != nil { + e.Log.Errorf("SmsPlatformKeyService Save error:%s \r\n", err) + return err + } + if db.RowsAffected == 0 { + return errors.New("无权更新该数据") + } + + if oldStatus == 1 && data.Status == 2 { + if err := e.RemoveQueque(dto.SmsPlatformKeyQueueDto{ + PlatformCode: data.PlatformCode, + Account: data.Account, + ApiKey: data.ApiKey, + ApiSecret: data.ApiSecret, + }, false); err != nil { + e.Log.Errorf("删除失败,%v", err) + return err + } + } else if oldStatus == 2 && data.Status == 1 { + if err := e.AddQueque(dto.SmsPlatformKeyQueueDto{ + PlatformCode: data.PlatformCode, + Account: data.Account, + ApiKey: data.ApiKey, + ApiSecret: data.ApiSecret, + }); err != nil { + e.Log.Errorf("添加队列失败,%v", err) + return err + } + } else { + oldQueueData := dto.SmsPlatformKeyQueueDto{ + PlatformCode: data.PlatformCode, + Account: data.Account, + ApiKey: oldKey, + ApiSecret: oldSecret, + } + queueData := dto.SmsPlatformKeyQueueDto{ + PlatformCode: data.PlatformCode, + Account: data.Account, + ApiKey: data.ApiKey, + ApiSecret: data.ApiSecret, + } + if err := e.Replace(oldQueueData, queueData); err != nil { + e.Log.Errorf("替换队列失败,%v", err) + return err + } + } + + return nil +} + +// Remove 删除SmsPlatformKey +func (e *SmsPlatformKey) Remove(d *dto.SmsPlatformKeyDeleteReq, p *actions.DataPermission) error { + var data models.SmsPlatformKey + var datas []models.SmsPlatformKey + + if err1 := e.Orm.Where("id in (?)", d.GetId()).Find(&datas).Error; err1 != nil { + e.Log.Errorf("Service RemoveSmsPlatformKey error:%s \r\n", err1) + return err1 + } + + var platformCodes []string + + for _, item := range datas { + if utility.ContainsString(platformCodes, item.PlatformCode) { + continue + } + + platformCodes = append(platformCodes, item.PlatformCode) + } + + err := e.Orm.Transaction(func(tx *gorm.DB) error { + db := tx.Model(&data). + Scopes( + actions.Permission(data.TableName(), p), + ).Delete(&data, d.GetId()) + + var count []string + + if err1 := tx.Model(&data).Where("status =1"). + Group("platform_code"). + Select("platform_code"). + Scan(&count). + Error; err1 != nil { + e.Log.Errorf("Service RemoveSmsPlatformKey error:%s \r\n", err1) + return err1 + } + + for _, item := range platformCodes { + if !utility.ContainsString(count, item) { + return errors.New("删除失败,通道最少需要保留一个可用ApiKey") + } + } + + if err := db.Error; err != nil { + e.Log.Errorf("Service RemoveSmsPlatformKey error:%s \r\n", err) + return err + } + if db.RowsAffected == 0 { + return errors.New("无权删除该数据") + } + return nil + }) + if err != nil { + return err + } + + for _, item := range datas { + queueDta := dto.SmsPlatformKeyQueueDto{ + PlatformCode: item.PlatformCode, + Account: data.Account, + ApiKey: item.ApiKey, + ApiSecret: item.ApiSecret, + } + + if err := e.RemoveQueque(queueDta, true); err != nil { + e.Log.Errorf("移出队列失败,%v", err) + return errors.New("删除队列失败") + } + } + return nil +} + +// InitQueque 初始化Redis缓存队列 +func (e *SmsPlatformKey) InitQueque() error { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.InitRedisQueque() +} + +// Replace 替换Redis缓存中的密钥 +func (e *SmsPlatformKey) Replace(oldEntity dto.SmsPlatformKeyQueueDto, entity dto.SmsPlatformKeyQueueDto) error { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.ReplaceRedisKey(oldEntity, entity) +} + +// RemoveQueque 从Redis缓存中移出队列 +func (e *SmsPlatformKey) RemoveQueque(entity dto.SmsPlatformKeyQueueDto, shouldDel bool) error { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.RemoveRedisQueque(entity, shouldDel) +} + +// AddQueque 添加到Redis缓存队列 +func (e *SmsPlatformKey) AddQueque(entity dto.SmsPlatformKeyQueueDto) error { + redisService := NewSmsPlatformKeyRedis(e.Orm, e.Log) + return redisService.AddRedisQueque(entity) +} diff --git a/app/admin/service/sms_platform_key_redis.go b/app/admin/service/sms_platform_key_redis.go new file mode 100644 index 0000000..487743a --- /dev/null +++ b/app/admin/service/sms_platform_key_redis.go @@ -0,0 +1,327 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "time" + + "go-admin/app/admin/models" + "go-admin/app/admin/service/dto" + "go-admin/utils/redishelper" + + "github.com/bytedance/sonic" + "github.com/go-admin-team/go-admin-core/logger" + "github.com/go-admin-team/go-admin-core/sdk/service" + "gorm.io/gorm" +) + +// SmsPlatformKeyRedis Redis版本的SMS平台密钥管理服务 +type SmsPlatformKeyRedis struct { + SmsPlatformKey + redisHelper *redishelper.RedisHelper +} + +// NewSmsPlatformKeyRedis 创建Redis版本的SMS平台密钥服务 +func NewSmsPlatformKeyRedis(orm *gorm.DB, log logger.Logger) *SmsPlatformKeyRedis { + return &SmsPlatformKeyRedis{ + SmsPlatformKey: SmsPlatformKey{ + Service: service.Service{ + Orm: orm, + Log: logger.NewHelper(log), + }, + }, + redisHelper: redishelper.DefaultRedis, + } +} + +// getRedisKey 获取Redis键名 +func (e *SmsPlatformKeyRedis) getRedisKey(platformCode string) string { + return fmt.Sprintf("sms:platform:keys:%s", platformCode) +} + +// getRedisIndexKey 获取Redis索引键名(用于快速查找) +func (e *SmsPlatformKeyRedis) getRedisIndexKey(platformCode, apiKey string) string { + return fmt.Sprintf("sms:platform:index:%s:%s", platformCode, apiKey) +} + +func (e *SmsPlatformKeyRedis) GetApiInfo(platformCode, apiKey string) (dto.SmsPlatformKeyQueueDto, error) { + indexKey := e.getRedisIndexKey(platformCode, apiKey) + data, err := e.redisHelper.GetString(indexKey) + if err != nil { + e.Log.Errorf("获取Redis索引失败 [%s:%s]: %v", platformCode, apiKey, err) + return dto.SmsPlatformKeyQueueDto{}, err + } + + var apiInfo dto.SmsPlatformKeyQueueDto + err = sonic.Unmarshal([]byte(data), &apiInfo) + if err != nil { + e.Log.Errorf("解析Redis数据失败 [%s:%s]: %v", platformCode, apiKey, err) + return dto.SmsPlatformKeyQueueDto{}, err + } + + return apiInfo, nil +} + +// InitRedisQueque 初始化Redis缓存队列 +// 从数据库加载所有SMS平台密钥并存储到Redis中 +func (e *SmsPlatformKeyRedis) InitRedisQueque() error { + // 查询所有启用的SMS平台密钥 + var list []models.SmsPlatformKey + err := e.Orm.Where("status = ?", 1).Find(&list).Error + if err != nil { + e.Log.Errorf("查询SMS平台密钥失败: %v", err) + return err + } + + if len(list) == 0 { + e.Log.Warn("未找到启用的SMS平台密钥") + return nil + } + + // 按平台分组 + platformKeyMap := make(map[string][]dto.SmsPlatformKeyQueueDto) + for _, item := range list { + data := dto.SmsPlatformKeyQueueDto{ + PlatformCode: item.PlatformCode, + Account: item.Account, + ApiKey: item.ApiKey, + ApiSecret: item.ApiSecret, + } + platformKeyMap[item.PlatformCode] = append(platformKeyMap[item.PlatformCode], data) + } + + // 将每个平台的密钥存储到Redis + for platformCode, keys := range platformKeyMap { + redisKey := e.getRedisKey(platformCode) + + // 清空现有数据 + err := e.redisHelper.DeleteString(redisKey) + if err != nil { + e.Log.Errorf("清空Redis队列失败 [%s]: %v", platformCode, err) + } + + // 存储密钥列表 + for _, key := range keys { + keyJson, _ := json.Marshal(key) + err := e.redisHelper.RPushList(redisKey, string(keyJson)) + if err != nil { + e.Log.Errorf("添加密钥到Redis队列失败 [%s]: %v", platformCode, err) + continue + } + + // 创建索引,便于快速查找和删除 + indexKey := e.getRedisIndexKey(platformCode, key.ApiKey) + err = e.redisHelper.SetString(indexKey, string(keyJson)) + if err != nil { + e.Log.Errorf("创建密钥索引失败 [%s:%s]: %v", platformCode, key.ApiKey, err) + } + } + + // 设置过期时间(24小时) + err = e.redisHelper.SetKeyExpiration(redisKey, 24*time.Hour) + if err != nil { + e.Log.Errorf("设置Redis队列过期时间失败 [%s]: %v", platformCode, err) + } + + e.Log.Infof("平台 [%s] 初始化 %d 个密钥到Redis缓存", platformCode, len(keys)) + } + + return nil +} + +// AddRedisQueque 添加密钥到Redis缓存 +func (e *SmsPlatformKeyRedis) AddRedisQueque(entity dto.SmsPlatformKeyQueueDto) error { + redisKey := e.getRedisKey(entity.PlatformCode) + indexKey := e.getRedisIndexKey(entity.PlatformCode, entity.ApiKey) + + // 检查是否已存在 + // exists, err := e.redisHelper.GetString(indexKey) + // if err == nil && exists != "" { + // return errors.New("密钥已存在") + // } + + // 序列化密钥数据 + keyJson, err := json.Marshal(entity) + if err != nil { + return fmt.Errorf("序列化密钥数据失败: %v", err) + } + + // 添加到队列 + err = e.redisHelper.RPushList(redisKey, string(keyJson)) + if err != nil { + return fmt.Errorf("添加密钥到Redis队列失败: %v", err) + } + + // 创建索引 + err = e.redisHelper.SetString(indexKey, string(keyJson)) + if err != nil { + e.Log.Errorf("创建密钥索引失败: %v", err) + // 索引创建失败不影响主要功能 + } + + // 设置过期时间 + err = e.redisHelper.SetKeyExpiration(redisKey, 24*time.Hour) + if err != nil { + e.Log.Errorf("设置Redis队列过期时间失败: %v", err) + } + + e.Log.Infof("成功添加密钥到Redis缓存 [%s:%s]", entity.PlatformCode, entity.ApiKey) + return nil +} + +// RemoveRedisQueque 从Redis缓存中删除密钥 +// shouldDel 是否删除索引 +func (e *SmsPlatformKeyRedis) RemoveRedisQueque(entity dto.SmsPlatformKeyQueueDto, shouldDel bool) error { + redisKey := e.getRedisKey(entity.PlatformCode) + indexKey := e.getRedisIndexKey(entity.PlatformCode, entity.ApiKey) + + // 获取要删除的密钥数据 + keyData, err := e.redisHelper.GetString(indexKey) + if err != nil || keyData == "" { + return errors.New("密钥不存在") + } + + // 从队列中删除 + _, err = e.redisHelper.LRem(redisKey, keyData) + if err != nil { + return fmt.Errorf("从Redis队列删除密钥失败: %v", err) + } + + // 删除索引 + if shouldDel { + err = e.redisHelper.DeleteString(indexKey) + if err != nil { + e.Log.Errorf("删除密钥索引失败: %v", err) + // 索引删除失败不影响主要功能 + } + } + + // e.Log.Infof("成功从Redis缓存删除密钥 [%s:%s]", entity.PlatformCode, entity.ApiKey) + return nil +} + +// GetRandomKey 随机获取一个密钥 +func (e *SmsPlatformKeyRedis) GetRandomKey(platformCode string) (*dto.SmsPlatformKeyQueueDto, error) { + redisKey := e.getRedisKey(platformCode) + + // 获取所有密钥 + keys, err := e.redisHelper.GetAllList(redisKey) + if err != nil { + return nil, fmt.Errorf("获取Redis队列失败: %v", err) + } + + if len(keys) == 0 { + return nil, errors.New("队列为空") + } + + // 随机选择一个 + rand.Seed(time.Now().UnixNano()) + randomIndex := rand.Intn(len(keys)) + keyJson := keys[randomIndex] + + var keyDto dto.SmsPlatformKeyQueueDto + err = json.Unmarshal([]byte(keyJson), &keyDto) + if err != nil { + return nil, fmt.Errorf("反序列化密钥数据失败: %v", err) + } + + return &keyDto, nil +} + +// GetRoundRobinKey 轮询获取密钥 +func (e *SmsPlatformKeyRedis) GetRoundRobinKey(platformCode string) (*dto.SmsPlatformKeyQueueDto, error) { + redisKey := e.getRedisKey(platformCode) + + // 从队列头部取出一个密钥 + keyJson, err := e.redisHelper.LPopList(redisKey) + if err != nil { + return nil, fmt.Errorf("从Redis队列获取密钥失败: %v", err) + } + + if keyJson == "" { + return nil, errors.New("队列为空") + } + + // 将密钥重新放到队列尾部(实现轮询) + err = e.redisHelper.RPushList(redisKey, keyJson) + if err != nil { + e.Log.Errorf("轮询密钥回放失败: %v", err) + } + + var keyDto dto.SmsPlatformKeyQueueDto + err = json.Unmarshal([]byte(keyJson), &keyDto) + if err != nil { + return nil, fmt.Errorf("反序列化密钥数据失败: %v", err) + } + + return &keyDto, nil +} + +// GetQueueLength 获取队列长度 +func (e *SmsPlatformKeyRedis) GetQueueLength(platformCode string) (int64, error) { + redisKey := e.getRedisKey(platformCode) + keys, err := e.redisHelper.GetAllList(redisKey) + if err != nil { + return 0, fmt.Errorf("获取Redis队列长度失败: %v", err) + } + return int64(len(keys)), nil +} + +// ReplaceRedisKey 替换Redis缓存中的密钥 +func (e *SmsPlatformKeyRedis) ReplaceRedisKey(oldEntity, newEntity dto.SmsPlatformKeyQueueDto) error { + // 先删除旧密钥 + err := e.RemoveRedisQueque(oldEntity, true) + if err != nil { + return fmt.Errorf("删除旧密钥失败: %v", err) + } + + // 再添加新密钥 + err = e.AddRedisQueque(newEntity) + if err != nil { + return fmt.Errorf("添加新密钥失败: %v", err) + } + + // e.Log.Infof("成功替换Redis缓存密钥 [%s] %s -> %s", newEntity.PlatformCode, oldEntity.ApiKey, newEntity.ApiKey) + return nil +} + +// ClearPlatformQueue 清空指定平台的队列 +func (e *SmsPlatformKeyRedis) ClearPlatformQueue(platformCode string) error { + redisKey := e.getRedisKey(platformCode) + err := e.redisHelper.DeleteString(redisKey) + if err != nil { + return fmt.Errorf("清空平台队列失败: %v", err) + } + + // 清空相关索引 + pattern := fmt.Sprintf("sms:platform:index:%s:*", platformCode) + err = e.redisHelper.DeleteKeysByPrefix(pattern) + if err != nil { + e.Log.Errorf("清空平台索引失败: %v", err) + } + + e.Log.Infof("成功清空平台 [%s] 的Redis队列", platformCode) + return nil +} + +// 获取平台所有密钥 +func (e *SmsPlatformKeyRedis) GetPlatformKeys(platformCode string) ([]dto.SmsPlatformKeyQueueDto, error) { + redisKey := e.getRedisKey(platformCode) + keys, err := e.redisHelper.GetAllList(redisKey) + if err != nil { + return nil, fmt.Errorf("获取Redis队列失败: %v", err) + } + + var keyDtos []dto.SmsPlatformKeyQueueDto + for _, keyJson := range keys { + var keyDto dto.SmsPlatformKeyQueueDto + err = json.Unmarshal([]byte(keyJson), &keyDto) + if err != nil { + return nil, fmt.Errorf("反序列化密钥数据失败: %v", err) + } + keyDtos = append(keyDtos, keyDto) + } + return keyDtos, nil +} diff --git a/app/admin/service/sms_platform_key_redis_test.go b/app/admin/service/sms_platform_key_redis_test.go new file mode 100644 index 0000000..9363ae6 --- /dev/null +++ b/app/admin/service/sms_platform_key_redis_test.go @@ -0,0 +1,23 @@ +package service + +import ( + "fmt" + "go-admin/common/global" + "testing" + + "github.com/go-admin-team/go-admin-core/logger" +) + +func TestNextKey(t *testing.T) { + orm := initSetting() + + redisService := NewSmsPlatformKeyRedis(orm, logger.DefaultLogger) + + val, err := redisService.GetRoundRobinKey(global.SmsPlatformTextVerified) + + if err != nil { + t.Errorf("GetRoundRobinKey error:%s \r\n", err) + } + + fmt.Printf("val:%s \r\n", val) +} diff --git a/app/admin/service/sms_receive_log.go b/app/admin/service/sms_receive_log.go index 7989184..d0c716d 100644 --- a/app/admin/service/sms_receive_log.go +++ b/app/admin/service/sms_receive_log.go @@ -49,8 +49,16 @@ func (e SmsReceiveLog) WebHook(req *dto.SmsReceiveWebHookReq) error { return err } + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetApiInfo(phoneLog.PlatformCode, phoneLog.ApiKey) + if err != nil { + e.Log.Errorf("获取平台密钥失败, %s", err) + return err + } + phoneService := SmsDaisysms{Service: e.Service} - if code := phoneService.setStatus(req.ActivationID); code != statuscode.Success { + + if code := phoneService.setStatus(req.ActivationID, &apiInfo); code != statuscode.Success { e.Log.Errorf("接受验证码回调后修改状态失败 %d", code) } } diff --git a/app/admin/service/sms_receive_log_test.go b/app/admin/service/sms_receive_log_test.go index a8f22a7..4d6b6e6 100644 --- a/app/admin/service/sms_receive_log_test.go +++ b/app/admin/service/sms_receive_log_test.go @@ -13,9 +13,10 @@ import ( ) func initSetting() *gorm.DB { - dsn := "root:123456@tcp(127.0.0.1:3306)/proxy-server-prod?charset=utf8mb4&parseTime=True&loc=Local&timeout=1000ms" + dsn := "root:123456@tcp(127.0.0.1:3306)/proxy_server?charset=utf8mb4&parseTime=True&loc=Local&timeout=1000ms" db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{}) sdk.Runtime.SetDb("default", db) + // config.ExtConfig. config.ExtConfig.TrxGridUrl = "https://api.trongrid.io" config.ExtConfig.DaisysmsUrl = "https://daisysms.com/stubs/handler_api.php" config.ExtConfig.SmsTextVerified.ApiKey = "ZQ0swXnsaPpeGdwa3c7gT9U9I1Oh9WoDHx0amuYovvaHuqd5u6B4NBBUSUBjR" diff --git a/app/admin/service/sms_text_verified.go b/app/admin/service/sms_text_verified.go index d1989e8..1c9365b 100644 --- a/app/admin/service/sms_text_verified.go +++ b/app/admin/service/sms_text_verified.go @@ -32,7 +32,7 @@ type SmsTextVerified struct { // 获取收到的验证码 // messageId 短效或长效号码的ID // typ 0-短效 1-长效 -func (e SmsTextVerified) GetCode(messageId string, typ int, userId int, service, serviceCode string) (string, int) { +func (e SmsTextVerified) GetCode(messageId string, typ int, userId int, service, serviceCode string, apiInfo *dto.SmsPlatformKeyQueueDto) (string, int) { reservationType := "" if typ == 0 { @@ -41,7 +41,7 @@ func (e SmsTextVerified) GetCode(messageId string, typ int, userId int, service, reservationType = "renewable" } url := fmt.Sprintf(getSmsCode, messageId, reservationType) - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != http.StatusOK { e.Log.Errorf("获取授权请求失败,status %d", code) @@ -168,13 +168,13 @@ var ErrUnAuth = errors.New("未授权,请检查配置的账号和密码") var ErrOutOfStockOrUnavailable = errors.New("缺货或服务不可用") // Login 登录 -func (e *SmsTextVerified) Login() (string, error) { +func (e *SmsTextVerified) Login(apiInfo *dto.SmsPlatformKeyQueueDto) (string, error) { resp := dto.TextVerifiedLoginResp{} var token string client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.SmsTextVerified.Url, nil) headers := map[string]string{ - "X-API-USERNAME": config.ExtConfig.SmsTextVerified.UserName, - "X-API-KEY": config.ExtConfig.SmsTextVerified.ApiKey, + "X-API-USERNAME": apiInfo.Account, + "X-API-KEY": apiInfo.ApiKey, } _, err1 := client.Post(loginUrl, nil, headers, &resp) @@ -192,7 +192,8 @@ func (e *SmsTextVerified) Login() (string, error) { if resp.ExpiresIn >= 10 { resp.ExpiresIn = resp.ExpiresIn - 10 // 提前10秒过期 - if err := redishelper.DefaultRedis.SetStringExpire(global.TextVerifiedToken, token, time.Duration(resp.ExpiresIn)*time.Second); err != nil { + key := fmt.Sprintf(global.TextVerifiedToken, apiInfo.ApiKey) + if err := redishelper.DefaultRedis.SetStringExpire(key, token, time.Duration(resp.ExpiresIn)*time.Second); err != nil { e.Log.Errorf("TextVerified登录失败,缓存Token失败 error: %v", err) } } @@ -201,15 +202,16 @@ func (e *SmsTextVerified) Login() (string, error) { } // 获取token -func (e *SmsTextVerified) GetToken() (string, error) { - token, err := redishelper.DefaultRedis.GetString(global.TextVerifiedToken) +func (e *SmsTextVerified) GetToken(apiInfo *dto.SmsPlatformKeyQueueDto) (string, error) { + key := fmt.Sprintf(global.TextVerifiedToken, apiInfo.ApiKey) + token, err := redishelper.DefaultRedis.GetString(key) if err != nil && errors.Is(err, redis.Nil) { // token不存在,重新登录获取 - return e.Login() + return e.Login(apiInfo) } if token == "" { - return e.Login() + return e.Login(apiInfo) } return token, nil @@ -225,9 +227,9 @@ func (e *SmsTextVerified) GetAreas() ([]string, error) { } // 获取服务列表 -func (e *SmsTextVerified) GetServices() ([]dto.TextVerifiedServeResp, error) { +func (e *SmsTextVerified) GetServices(apiInfo *dto.SmsPlatformKeyQueueDto) ([]dto.TextVerifiedServeResp, error) { client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.SmsTextVerified.Url, nil) - headers, err := e.GetAuthHeader() + headers, err := e.GetAuthHeader(apiInfo) if err != nil { return nil, err } @@ -244,7 +246,15 @@ func (e *SmsTextVerified) GetServices() ([]dto.TextVerifiedServeResp, error) { // 同步服务列表 func (e *SmsTextVerified) SyncServices() error { - services, err := e.GetServices() + // 获取API信息 + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetRoundRobinKey(global.SmsPlatformTextVerified) + if err != nil { + e.Log.Errorf("获取API信息失败: %v", err) + return err + } + + services, err := e.GetServices(apiInfo) if err != nil { e.Log.Errorf("获取服务列表失败: %v", err) return err @@ -321,17 +331,25 @@ func (e *SmsTextVerified) SyncPrices() error { return err } + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetRoundRobinKey(global.SmsPlatformTextVerified) + + if err != nil { + e.Log.Errorf("获取API信息失败: %v", err) + return err + } + for _, v := range services { params := map[string]interface{}{} - price, code := e.GetPrice(0, v.Code) + price, code := e.GetPrice(0, v.Code, apiInfo) if code == http.StatusOK { params["price"] = price } time.Sleep(time.Microsecond * 500) // 避免请求过快被限制 - longPrice, code := e.GetPrice(1, v.Code) + longPrice, code := e.GetPrice(1, v.Code, apiInfo) if code == http.StatusOK { params["long_price"] = longPrice } @@ -347,14 +365,14 @@ func (e *SmsTextVerified) SyncPrices() error { } // 获取号码并唤醒 -func (e *SmsTextVerified) GetNumberAndWakeUp(tye int, serviceCode string, price decimal.Decimal, period int) (dto.SmsPhoneGetPhoneResp, int) { - resp, code := e.GetNumberForApi(tye, serviceCode, price, period) +func (e *SmsTextVerified) GetNumberAndWakeUp(tye int, serviceCode string, price decimal.Decimal, period int, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.SmsPhoneGetPhoneResp, int) { + resp, code := e.GetNumberForApi(tye, serviceCode, price, period, apiInfo) if code != statuscode.Success { return resp, code } - wakeUpResp, code := e.getExtraActivation(resp.Id) + wakeUpResp, code := e.getExtraActivation(resp.Id, apiInfo) if code != statuscode.Success { return resp, code @@ -372,7 +390,7 @@ func (e *SmsTextVerified) GetNumberAndWakeUp(tye int, serviceCode string, price // service 服务code // maxPrice 最大价格 // period 时长(月=30天) -func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price decimal.Decimal, period int) (dto.SmsPhoneGetPhoneResp, int) { +func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price decimal.Decimal, period int, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.SmsPhoneGetPhoneResp, int) { //这个平台的所有号码都是美国号码 默认国家代码都是 +1 var err error var createResp dto.TextVerifiedResp @@ -380,10 +398,10 @@ func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price dec switch typ { case 0: var code int - createResp, code = e.CreateVerification(serviceCode) + createResp, code = e.CreateVerification(serviceCode, apiInfo) if code == statuscode.Success && createResp.Href != "" { - bytes, err := e.doRequest(&createResp) + bytes, err := e.doRequest(&createResp, apiInfo) if err != nil { e.Log.Errorf("短效号码 获取号码失败:%v", e) @@ -410,11 +428,11 @@ func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price dec return result, statuscode.ServerError } case 1: - createResp, err = e.CreateRental(serviceCode) + createResp, err = e.CreateRental(serviceCode, apiInfo) if err == nil && createResp.Href != "" { saleId := getIdByUrl(createResp.Href) - bytes, err := e.doRequest(&createResp) + bytes, err := e.doRequest(&createResp, apiInfo) if err != nil { e.Log.Errorf("通过销售id [%s] 获取号码失败:%v", saleId, err) @@ -441,7 +459,7 @@ func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price dec return result, statuscode.ServerError } - detail, code := e.GetRentalDetail(result.Id) + detail, code := e.GetRentalDetail(result.Id, apiInfo) if code != statuscode.Success { return result, code @@ -477,13 +495,13 @@ func (e *SmsTextVerified) GetNumberForApi(typ int, serviceCode string, price dec } // 执行查询请求 -func (e *SmsTextVerified) doRequest(resp *dto.TextVerifiedResp) ([]byte, error) { +func (e *SmsTextVerified) doRequest(resp *dto.TextVerifiedResp, apiInfo *dto.SmsPlatformKeyQueueDto) ([]byte, error) { bytes := []byte{} if resp.Href == "" { return bytes, errors.New("成功请求没有返回url") } - header, err := e.GetAuthHeader() + header, err := e.GetAuthHeader(apiInfo) if err != nil { return bytes, fmt.Errorf("获取授权失败:%v", err) @@ -501,7 +519,7 @@ func (e *SmsTextVerified) doRequest(resp *dto.TextVerifiedResp) ([]byte, error) } // 长号码租赁 -func (e *SmsTextVerified) CreateRental(serviceCode string) (dto.TextVerifiedResp, error) { +func (e *SmsTextVerified) CreateRental(serviceCode string, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.TextVerifiedResp, error) { req := dto.TextVerifiedCreateRewalReq{ ServiceName: serviceCode, Capability: "sms", @@ -513,7 +531,7 @@ func (e *SmsTextVerified) CreateRental(serviceCode string) (dto.TextVerifiedResp resp := dto.TextVerifiedResp{} - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { e.Log.Errorf("获取头信息失败 error: %d", code) @@ -541,10 +559,10 @@ func (e *SmsTextVerified) CreateRental(serviceCode string) (dto.TextVerifiedResp } // 获取长效号码详情 -func (e *SmsTextVerified) GetRentalDetail(id string) (dto.VerificationRentalDetailResp, int) { +func (e *SmsTextVerified) GetRentalDetail(id string, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.VerificationRentalDetailResp, int) { result := dto.VerificationRentalDetailResp{} - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { return result, code } @@ -561,8 +579,8 @@ func (e *SmsTextVerified) GetRentalDetail(id string) (dto.VerificationRentalDeta // 唤醒号码 // returns activationId,messageId,startTime(可用开始时间),endTime(可用结束时间), code, -func (e SmsTextVerified) getExtraActivation(id string) (dto.TextVerifiedWakeUpResp, int) { - client, code := e.GetTextVerifiedAuthClient() +func (e SmsTextVerified) getExtraActivation(id string, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.TextVerifiedWakeUpResp, int) { + client, code := e.GetTextVerifiedAuthClient(apiInfo) result := dto.TextVerifiedWakeUpResp{} if code != statuscode.Success { @@ -584,7 +602,7 @@ func (e SmsTextVerified) getExtraActivation(id string) (dto.TextVerifiedWakeUpRe return result, statuscode.ServerError } else if status == http.StatusCreated || status == http.StatusOK { if resp.Method != "" && resp.Href != "" { - bytes, err := e.doRequest(&resp) + bytes, err := e.doRequest(&resp, apiInfo) if err != nil { e.Log.Errorf("唤醒号码失败 id:%s error: %v", id, err) @@ -615,19 +633,19 @@ func (e SmsTextVerified) getExtraActivation(id string) (dto.TextVerifiedWakeUpRe // 获取价格 // getType 0-短效 1-长效 // returns decimal.Decimal(单价), int(状态code) -func (e *SmsTextVerified) GetPrice(typ int, serviceName string) (decimal.Decimal, int) { +func (e *SmsTextVerified) GetPrice(typ int, serviceName string, apiInfo *dto.SmsPlatformKeyQueueDto) (decimal.Decimal, int) { switch typ { case 1: - return e.GetRentalPrice(serviceName) + return e.GetRentalPrice(serviceName, apiInfo) case 0: - return e.GetVerificationPrice(serviceName) + return e.GetVerificationPrice(serviceName, apiInfo) default: return decimal.Zero, statuscode.SmsInvalidType } } // 获取长租价格 -func (e *SmsTextVerified) GetRentalPrice(serviceName string) (decimal.Decimal, int) { +func (e *SmsTextVerified) GetRentalPrice(serviceName string, apiInfo *dto.SmsPlatformKeyQueueDto) (decimal.Decimal, int) { req := dto.TextVerifiedPriceReq{ ServiceName: serviceName, Capability: "sms", @@ -636,7 +654,7 @@ func (e *SmsTextVerified) GetRentalPrice(serviceName string) (decimal.Decimal, i Duration: "thirtyDay", } - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { e.Log.Errorf("获取授权请求失败,status %d", code) @@ -659,7 +677,7 @@ func (e *SmsTextVerified) GetRentalPrice(serviceName string) (decimal.Decimal, i } // 获取单次验证码价格 -func (e *SmsTextVerified) GetVerificationPrice(sericeName string) (decimal.Decimal, int) { +func (e *SmsTextVerified) GetVerificationPrice(sericeName string, apiInfo *dto.SmsPlatformKeyQueueDto) (decimal.Decimal, int) { params := map[string]interface{}{ "serviceName": sericeName, "areaCode": false, @@ -668,7 +686,7 @@ func (e *SmsTextVerified) GetVerificationPrice(sericeName string) (decimal.Decim "numberType": "mobile", } - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { e.Log.Errorf("获取授权请求失败,status %d", code) @@ -690,14 +708,14 @@ func (e *SmsTextVerified) GetVerificationPrice(sericeName string) (decimal.Decim } // 单次接收 -func (e *SmsTextVerified) CreateVerification(serviceCode string) (dto.TextVerifiedResp, int) { +func (e *SmsTextVerified) CreateVerification(serviceCode string, apiInfo *dto.SmsPlatformKeyQueueDto) (dto.TextVerifiedResp, int) { req := dto.TextVerifiedCreateRewalReq{ ServiceName: serviceCode, Capability: "sms", } resp := dto.TextVerifiedResp{} - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { return resp, code @@ -722,8 +740,8 @@ func (e *SmsTextVerified) CreateVerification(serviceCode string) (dto.TextVerifi // 取消验证码 // typ 0-短效 1-长效 -func (e SmsTextVerified) CancelRental(id string, typ int) int { - client, code := e.GetTextVerifiedAuthClient() +func (e SmsTextVerified) CancelRental(id string, typ int, apiInfo *dto.SmsPlatformKeyQueueDto) int { + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { return code @@ -758,8 +776,8 @@ func (e SmsTextVerified) CancelRental(id string, typ int) int { return statuscode.Success } -func (e *SmsTextVerified) GetTextVerifiedAuthClient() (*httphelper.HTTPClient, int) { - header, err := e.GetAuthHeader() +func (e *SmsTextVerified) GetTextVerifiedAuthClient(apiInfo *dto.SmsPlatformKeyQueueDto) (*httphelper.HTTPClient, int) { + header, err := e.GetAuthHeader(apiInfo) if err != nil { e.Log.Errorf("取消验证码获取token失败 error: %v", err) @@ -882,10 +900,10 @@ func (e *SmsTextVerified) TextVerifiedWebHook(req *dto.TextVerifiedWebHookReq) e // 续期 // typ 0-短效 1-长效 -func (e *SmsTextVerified) Renew(activationId string, status bool) int { +func (e *SmsTextVerified) Renew(activationId string, status bool, apiInfo *dto.SmsPlatformKeyQueueDto) int { url := fmt.Sprintf(updateRentalRenewStatus, activationId) - client, code := e.GetTextVerifiedAuthClient() + client, code := e.GetTextVerifiedAuthClient(apiInfo) if code != statuscode.Success { e.Log.Errorf("获取长效续期失败 %d", code) @@ -916,18 +934,13 @@ func (e *SmsTextVerified) Renew(activationId string, status bool) int { // 重新初始化短信记录 func (e *SmsTextVerified) InitSmsLogs() error { - // phones, err := e.GetNumbers() - - // if err != nil { - // e.Log.Errorf("获取长效号码列表失败 %v", err) - // return fmt.Errorf("获取长效号码列表失败 %v", err) - // } - - // numbers := make([]string, 0) - - // for _, v := range phones { - // numbers = append(numbers, v.Number) - // } + // 获取平台密钥信息 + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + apiInfo, err := smsPlatformKeyRedis.GetApiInfo("textverified", "") + if err != nil { + e.Log.Errorf("获取平台密钥失败: %v", err) + return fmt.Errorf("获取平台密钥失败: %v", err) + } phoneLogs, err := e.GetUserByNumber("textverified") @@ -938,7 +951,7 @@ func (e *SmsTextVerified) InitSmsLogs() error { if len(phoneLogs) > 0 { for _, v := range phoneLogs { - e.GetCode(v.ActivationId, v.Type, v.UserId, v.Service, v.ServiceCode) + e.GetCode(v.ActivationId, v.Type, v.UserId, v.Service, v.ServiceCode, &apiInfo) } } @@ -956,9 +969,48 @@ func (e *SmsTextVerified) GetUserByNumber(platformCode string) ([]models.SmsPhon return result, nil } +// 获取平台下 所有账号的获取记录 +// map[string][]dto.VerificationDTO key:account +func (e *SmsTextVerified) GetAllPlatformNumbers() (map[string][]dto.VerificationDTO, error) { + // 获取平台密钥信息 + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(e.Orm, e.Log) + platformKeys, err := smsPlatformKeyRedis.GetPlatformKeys(global.SmsPlatformTextVerified) + + if err != nil { + e.Log.Errorf("获取平台密钥失败: %v", err) + return nil, fmt.Errorf("获取平台密钥失败: %v", err) + } + + allNumbers := make(map[string][]dto.VerificationDTO) + + for _, v := range platformKeys { + numbersw, err1 := e.GetAllNumbers(&v) + + if err1 != nil { + e.Log.Errorf("获取平台密钥失败: %v", err1) + continue + } + + allNumbers[v.Account] = numbersw + } + + return allNumbers, nil +} + +func (e *SmsTextVerified) GetAllNumbers(apiInfo *dto.SmsPlatformKeyQueueDto) ([]dto.VerificationDTO, error) { + phones, err := e.GetNumbers(apiInfo) + + if err != nil { + e.Log.Errorf("获取长效号码列表失败 %v", err) + return phones, fmt.Errorf("获取长效号码列表失败 %v", err) + } + + return phones, nil +} + // 获取号码 -func (e *SmsTextVerified) GetNumbers() ([]dto.VerificationDTO, error) { - client, code := e.GetTextVerifiedAuthClient() +func (e *SmsTextVerified) GetNumbers(apiInfo *dto.SmsPlatformKeyQueueDto) ([]dto.VerificationDTO, error) { + client, code := e.GetTextVerifiedAuthClient(apiInfo) result := make([]dto.VerificationDTO, 0) if code != statuscode.Success { @@ -967,7 +1019,7 @@ func (e *SmsTextVerified) GetNumbers() ([]dto.VerificationDTO, error) { } resp := dto.TextVerifiedGetNumbersResp{} - statusCode, err := client.Get(getNumbers, map[string]string{}, resp) + statusCode, err := client.Get(getNumbers, map[string]string{}, &resp) if err != nil { e.Log.Errorf("获取长效号码列表失败 %v", err) @@ -976,11 +1028,15 @@ func (e *SmsTextVerified) GetNumbers() ([]dto.VerificationDTO, error) { if statusCode == http.StatusOK { // 添加第一页数据 - result = append(result, resp.Data...) + for _, item := range resp.Data { + item.Number = "1" + item.Number + + result = append(result, item) + } // 处理分页数据 if resp.HasNext { - paginatedData, err := e.fetchPaginatedData(resp.Links.Next) + paginatedData, err := e.fetchPaginatedData(resp.Links.Next, apiInfo) if err != nil { return result, err } @@ -989,7 +1045,7 @@ func (e *SmsTextVerified) GetNumbers() ([]dto.VerificationDTO, error) { return result, nil } else if statusCode == http.StatusUnauthorized { - return e.GetNumbers() + return e.GetNumbers(apiInfo) } else { e.Log.Errorf("获取长效号码列表失败 %d", statusCode) return result, fmt.Errorf("获取长效号码列表失败 %d", statusCode) @@ -999,11 +1055,11 @@ func (e *SmsTextVerified) GetNumbers() ([]dto.VerificationDTO, error) { // 获取授权header头 // fetchPaginatedData 获取分页数据的通用方法 // 处理TextVerified API的分页响应,自动获取所有页面的数据 -func (e *SmsTextVerified) fetchPaginatedData(nextLink *dto.TextVerifiedResp) ([]dto.VerificationDTO, error) { +func (e *SmsTextVerified) fetchPaginatedData(nextLink *dto.TextVerifiedResp, apiInfo *dto.SmsPlatformKeyQueueDto) ([]dto.VerificationDTO, error) { var result []dto.VerificationDTO for nextLink != nil { - nextData, err := e.doRequest(nextLink) + nextData, err := e.doRequest(nextLink, apiInfo) if err != nil { e.Log.Errorf("获取分页数据失败: %v", err) return result, fmt.Errorf("获取分页数据失败: %v", err) @@ -1016,7 +1072,10 @@ func (e *SmsTextVerified) fetchPaginatedData(nextLink *dto.TextVerifiedResp) ([] return result, fmt.Errorf("解析分页数据失败: %v", err) } - result = append(result, nextResp.Data...) + for _, item := range nextResp.Data { + item.Number = "1" + item.Number + result = append(result, item) + } // 检查是否还有下一页 if nextResp.HasNext { @@ -1029,8 +1088,8 @@ func (e *SmsTextVerified) fetchPaginatedData(nextLink *dto.TextVerifiedResp) ([] return result, nil } -func (e *SmsTextVerified) GetAuthHeader() (map[string]string, error) { - token, err := e.GetToken() +func (e *SmsTextVerified) GetAuthHeader(apiInfo *dto.SmsPlatformKeyQueueDto) (map[string]string, error) { + token, err := e.GetToken(apiInfo) if err != nil { return nil, err } diff --git a/app/admin/service/sms_text_verified_enhanced.go b/app/admin/service/sms_text_verified_enhanced.go new file mode 100644 index 0000000..cebfcf8 --- /dev/null +++ b/app/admin/service/sms_text_verified_enhanced.go @@ -0,0 +1,224 @@ +package service + +import ( + "errors" + "fmt" + "go-admin/app/admin/service/dto" + "go-admin/common/global" + "go-admin/common/statuscode" + "go-admin/config" + "go-admin/utils/httphelper" + "go-admin/utils/redishelper" + "net/http" + "strings" + "time" + + "github.com/go-admin-team/go-admin-core/sdk/service" + "github.com/go-redis/redis/v8" +) + +// SmsTextVerifiedEnhanced 增强版TextVerified服务,支持token自动刷新 +type SmsTextVerifiedEnhanced struct { + service.Service + maxRetries int // 最大重试次数 +} + +// NewSmsTextVerifiedEnhanced 创建增强版TextVerified服务实例 +func NewSmsTextVerifiedEnhanced() *SmsTextVerifiedEnhanced { + return &SmsTextVerifiedEnhanced{ + maxRetries: 2, // 默认最大重试2次 + } +} + +// AuthenticatedRequest 带自动token刷新的HTTP请求包装器 +// 当遇到401未授权错误时,会自动刷新token并重试请求 +func (e *SmsTextVerifiedEnhanced) AuthenticatedRequest( + apiInfo *dto.SmsPlatformKeyQueueDto, + requestFunc func(client *httphelper.HTTPClient) (int, error), +) (int, error) { + var lastErr error + + // 尝试执行请求,包含重试逻辑 + for attempt := 0; attempt <= e.maxRetries; attempt++ { + // 获取认证客户端 + client, code := e.GetTextVerifiedAuthClient(apiInfo) + if code != statuscode.Success { + e.Log.Errorf("获取授权客户端失败,状态码: %d", code) + return code, fmt.Errorf("获取授权客户端失败,状态码: %d", code) + } + + // 执行请求 + status, err := requestFunc(client) + + // 如果请求成功,直接返回 + if err == nil { + return status, nil + } + + // 检查是否为401未授权错误 + if e.isUnauthorizedError(status, err) { + e.Log.Warnf("检测到token过期或未授权错误 (尝试 %d/%d): %v", attempt+1, e.maxRetries+1, err) + + // 如果不是最后一次尝试,清除token缓存并重试 + if attempt < e.maxRetries { + if clearErr := e.clearTokenCache(apiInfo); clearErr != nil { + e.Log.Errorf("清除token缓存失败: %v", clearErr) + } + e.Log.Infof("正在重试请求... (尝试 %d/%d)", attempt+2, e.maxRetries+1) + continue + } + } + + // 如果不是401错误或已达到最大重试次数,记录错误并退出循环 + lastErr = err + break + } + + // 返回最后一次的错误 + return statuscode.ServerError, fmt.Errorf("请求失败,已重试%d次: %v", e.maxRetries, lastErr) +} + +// isUnauthorizedError 检查错误是否为401未授权错误 +func (e *SmsTextVerifiedEnhanced) isUnauthorizedError(status int, err error) bool { + if status == http.StatusUnauthorized { + return true + } + + if err != nil { + errorMsg := strings.ToLower(err.Error()) + // 检查常见的未授权错误消息 + return strings.Contains(errorMsg, "unauthorized") || + strings.Contains(errorMsg, "401") || + strings.Contains(errorMsg, "invalid token") || + strings.Contains(errorMsg, "token expired") || + strings.Contains(errorMsg, "authentication failed") + } + + return false +} + +// clearTokenCache 清除Redis中的token缓存 +func (e *SmsTextVerifiedEnhanced) clearTokenCache(apiInfo *dto.SmsPlatformKeyQueueDto) error { + key := fmt.Sprintf(global.TextVerifiedToken, apiInfo.ApiKey) + err := redishelper.DefaultRedis.DeleteString(key) + if err != nil { + return fmt.Errorf("删除token缓存失败: %v", err) + } + e.Log.Infof("已清除token缓存: %s", key) + return nil +} + +// GetTextVerifiedAuthClient 获取认证的HTTP客户端 +// 这个方法与原版相同,但可以在这里添加额外的逻辑 +func (e *SmsTextVerifiedEnhanced) GetTextVerifiedAuthClient(apiInfo *dto.SmsPlatformKeyQueueDto) (*httphelper.HTTPClient, int) { + header, err := e.GetAuthHeader(apiInfo) + if err != nil { + e.Log.Errorf("获取授权头失败: %v", err) + return nil, statuscode.ServerError + } + client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.SmsTextVerified.Url, header) + return client, statuscode.Success +} + +// GetAuthHeader 获取认证头 +func (e *SmsTextVerifiedEnhanced) GetAuthHeader(apiInfo *dto.SmsPlatformKeyQueueDto) (map[string]string, error) { + token, err := e.GetToken(apiInfo) + if err != nil { + return nil, err + } + headers := map[string]string{ + "Authorization": "Bearer " + token, + } + return headers, nil +} + +// GetToken 获取token(与原版相同的逻辑) +func (e *SmsTextVerifiedEnhanced) GetToken(apiInfo *dto.SmsPlatformKeyQueueDto) (string, error) { + key := fmt.Sprintf(global.TextVerifiedToken, apiInfo.ApiKey) + token, err := redishelper.DefaultRedis.GetString(key) + if err != nil && errors.Is(err, redis.Nil) { + // token不存在,重新登录获取 + return e.Login(apiInfo) + } + + if token == "" { + return e.Login(apiInfo) + } + + return token, nil +} + +// Login 登录获取token(与原版相同的逻辑) +func (e *SmsTextVerifiedEnhanced) Login(apiInfo *dto.SmsPlatformKeyQueueDto) (string, error) { + resp := dto.TextVerifiedLoginResp{} + var token string + client := httphelper.NewHTTPClient(10*time.Second, config.ExtConfig.SmsTextVerified.Url, nil) + headers := map[string]string{ + "X-API-USERNAME": apiInfo.ApiSecret, + "X-API-KEY": apiInfo.ApiKey, + } + _, err1 := client.Post("/api/pub/v2/auth", nil, headers, &resp) + + if err1 != nil { + e.Log.Errorf("TextVerified登录失败 error: %v", err1) + return "", err1 + } + + if resp.Token == "" { + e.Log.Errorf("TextVerified登录失败,返回的Token为空") + return "", errors.New("TextVerified登录失败,返回的Token为空") + } + + token = resp.Token + + if resp.ExpiresIn >= 10 { + resp.ExpiresIn = resp.ExpiresIn - 10 // 提前10秒过期 + key := fmt.Sprintf(global.TextVerifiedToken, apiInfo.ApiKey) + if err := redishelper.DefaultRedis.SetStringExpire(key, token, time.Duration(resp.ExpiresIn)*time.Second); err != nil { + e.Log.Errorf("TextVerified登录失败,缓存Token失败 error: %v", err) + } + } + + return token, nil +} + +// 使用示例:增强版的GetCode方法 +func (e *SmsTextVerifiedEnhanced) GetCodeEnhanced(messageId string, typ int, userId int, service, serviceCode string, apiInfo *dto.SmsPlatformKeyQueueDto) (string, int) { + reservationType := "" + if typ == 0 { + reservationType = "verification" + } else { + reservationType = "renewable" + } + url := fmt.Sprintf("/api/pub/v2/sms?reservationId=%s&reservationType=%s", messageId, reservationType) + + var parsedCode string + var finalStatus int + + // 使用增强的请求方法 + status, err := e.AuthenticatedRequest(apiInfo, func(client *httphelper.HTTPClient) (int, error) { + resp := dto.TextVerifiedSmsResp{} + status, err := client.Get(url, nil, &resp) + + if err != nil { + return status, err + } + + // 处理响应数据... + if len(resp.Data) > 0 { + // 这里可以添加原有的验证码处理逻辑 + parsedCode = resp.Data[0].ParsedCode + } + + return http.StatusOK, nil + }) + + if err != nil { + e.Log.Errorf("获取验证码失败: %v", err) + finalStatus = statuscode.ServerError + } else { + finalStatus = status + } + + return parsedCode, finalStatus +} \ No newline at end of file diff --git a/app/admin/service/sms_text_verified_test.go b/app/admin/service/sms_text_verified_test.go index 9caf354..ac5d98f 100644 --- a/app/admin/service/sms_text_verified_test.go +++ b/app/admin/service/sms_text_verified_test.go @@ -1,6 +1,7 @@ package service import ( + "go-admin/common/global" "testing" "github.com/go-admin-team/go-admin-core/logger" @@ -13,7 +14,15 @@ func TestSmsTextVerifiedLogin(t *testing.T) { s.Orm = db s.Log = logger.NewHelper(logger.DefaultLogger) - token, err := s.Login() + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(s.Orm, s.Log) + apiInfo, err := smsPlatformKeyRedis.GetRoundRobinKey(global.SmsPlatformTextVerified) + + if err != nil { + s.Log.Errorf("获取API信息失败: %v", err) + t.Error("获取API信息失败", err) + } + + token, err := s.Login(apiInfo) if err != nil { t.Errorf("Login failed: %v", err) } else { @@ -27,9 +36,16 @@ func TestSmsTextVerifiedGetServices(t *testing.T) { s := SmsTextVerified{} s.Orm = db s.Log = logger.NewHelper(logger.DefaultLogger) + smsPlatformKeyRedis := NewSmsPlatformKeyRedis(s.Orm, s.Log) + apiInfo, err := smsPlatformKeyRedis.GetRoundRobinKey(global.SmsPlatformTextVerified) + + if err != nil { + s.Log.Errorf("获取API信息失败: %v", err) + t.Error("获取API信息失败", err) + } // Now, test GetServices with the valid token - servicesResp, err := s.GetServices() + servicesResp, err := s.GetServices(apiInfo) if err != nil { t.Errorf("GetServices failed: %v", err) } else { diff --git a/cmd/api/server.go b/cmd/api/server.go index b45edb5..7b0417e 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -223,4 +223,12 @@ func initBusinesses() { memberApiService.Log = cliProxyService.Log memberApiService.InitApis() + + // 创建SMS平台密钥服务实例并初始化Redis缓存 + smsPlatformKey := service.SmsPlatformKey{} + smsPlatformKey.Orm = cliProxyService.Orm + smsPlatformKey.Log = cliProxyService.Log + + // 初始化Redis缓存队列 + smsPlatformKey.InitQueque() } diff --git a/cmd/proxy-server b/cmd/proxy-server deleted file mode 100644 index 3d86bd7628414b5fdce8c2e640718057fdd61b94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 93604 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 0 { + cd = cooldown[0] + } + + return &GenericQueue[T]{ + data: make([]T, capacity), + capacity: capacity, + lastUsed: make(map[int]time.Time), + cooldown: cd, + comparer: comparer, + } +} + +// Add 添加元素到队列 +// item: 要添加的元素 +// 返回: 是否添加成功 +func (q *GenericQueue[T]) Add(item T) bool { + q.mutex.Lock() + defer q.mutex.Unlock() + + if q.size >= q.capacity { + return false // 队列已满 + } + + // 如果提供了比较函数,检查是否已存在相同的元素 + if q.comparer != nil { + for i := 0; i < q.size; i++ { + if q.comparer(q.data[i], item) { + return false // 已存在 + } + } + } + + q.data[q.size] = item + q.size++ + return true +} + +// Remove 从队列中移除指定的元素 +// item: 要移除的元素 +// 返回: 是否移除成功 +func (q *GenericQueue[T]) Remove(item T) bool { + q.mutex.Lock() + defer q.mutex.Unlock() + + if q.comparer == nil { + return false // 没有比较函数无法移除 + } + + for i := 0; i < q.size; i++ { + if q.comparer(q.data[i], item) { + // 将后面的元素前移 + for j := i; j < q.size-1; j++ { + q.data[j] = q.data[j+1] + } + q.size-- + + // 调整current指针 + if q.current >= q.size && q.size > 0 { + q.current = 0 + } + + // 清理lastUsed记录 + delete(q.lastUsed, i) + return true + } + } + return false +} + +// GetNext 获取下一个可用的元素(轮询方式) +// 返回: 元素和是否获取成功 +func (q *GenericQueue[T]) GetNext() (T, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + + var zero T + if q.size == 0 { + return zero, false // 队列为空 + } + + // 如果没有设置冷却时间,直接返回下一个元素 + if q.cooldown == 0 { + item := q.data[q.current] + q.lastUsed[q.current] = time.Now() + q.current = (q.current + 1) % q.size + return item, true + } + + // 寻找可用的元素(考虑冷却时间) + startPos := q.current + for { + lastUsed, exists := q.lastUsed[q.current] + + // 如果元素从未使用过,或者已过冷却时间 + if !exists || time.Since(lastUsed) >= q.cooldown { + item := q.data[q.current] + q.lastUsed[q.current] = time.Now() + q.current = (q.current + 1) % q.size + return item, true + } + + q.current = (q.current + 1) % q.size + + // 如果遍历了一圈都没找到可用的元素 + if q.current == startPos { + // 返回当前元素,忽略冷却时间 + item := q.data[q.current] + q.lastUsed[q.current] = time.Now() + q.current = (q.current + 1) % q.size + return item, true + } + } +} + +// GetRandom 随机获取一个元素 +// 返回: 元素和是否获取成功 +func (q *GenericQueue[T]) GetRandom() (T, bool) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var zero T + if q.size == 0 { + return zero, false + } + + // 使用当前时间作为随机种子 + index := int(time.Now().UnixNano()) % q.size + item := q.data[index] + q.lastUsed[index] = time.Now() + return item, true +} + +// GetAll 获取所有元素的副本 +// 返回: 元素切片 +func (q *GenericQueue[T]) GetAll() []T { + q.mutex.RLock() + defer q.mutex.RUnlock() + + items := make([]T, q.size) + copy(items, q.data[:q.size]) + return items +} + +// Size 获取队列中的元素数量 +// 返回: 元素数量 +func (q *GenericQueue[T]) Size() int { + q.mutex.RLock() + defer q.mutex.RUnlock() + return q.size +} + +// IsEmpty 检查队列是否为空 +// 返回: 是否为空 +func (q *GenericQueue[T]) IsEmpty() bool { + q.mutex.RLock() + defer q.mutex.RUnlock() + return q.size == 0 +} + +// IsFull 检查队列是否已满 +// 返回: 是否已满 +func (q *GenericQueue[T]) IsFull() bool { + q.mutex.RLock() + defer q.mutex.RUnlock() + return q.size >= q.capacity +} + +// Clear 清空队列 +func (q *GenericQueue[T]) Clear() { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.size = 0 + q.current = 0 + q.lastUsed = make(map[int]time.Time) +} + +// SetCooldown 设置元素使用冷却时间 +// cooldown: 冷却时间 +func (q *GenericQueue[T]) SetCooldown(cooldown time.Duration) { + q.mutex.Lock() + defer q.mutex.Unlock() + q.cooldown = cooldown +} + +// GetUsageInfo 获取元素使用信息 +// 返回: 位置使用时间映射 +func (q *GenericQueue[T]) GetUsageInfo() map[int]time.Time { + q.mutex.RLock() + defer q.mutex.RUnlock() + + usage := make(map[int]time.Time) + for k, v := range q.lastUsed { + usage[k] = v + } + return usage +} + +// BatchAdd 批量添加元素 +// items: 要添加的元素切片 +// 返回: 成功添加的数量 +func (q *GenericQueue[T]) BatchAdd(items []T) int { + count := 0 + for _, item := range items { + if q.Add(item) { + count++ + } + } + return count +} + +// Replace 替换所有元素 +// items: 新的元素切片 +// 返回: 是否替换成功 +func (q *GenericQueue[T]) Replace(items []T) bool { + if len(items) > q.capacity { + return false + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + q.size = 0 + q.current = 0 + q.lastUsed = make(map[int]time.Time) + + for _, item := range items { + q.data[q.size] = item + q.size++ + } + + return true +} + +// ReplaceItem 替换指定的单个元素 +// oldItem: 要被替换的元素 +// newItem: 新的元素 +// 返回: 是否替换成功 +func (q *GenericQueue[T]) ReplaceItem(oldItem, newItem T) bool { + q.mutex.Lock() + defer q.mutex.Unlock() + + if q.comparer == nil { + return false // 没有比较函数无法查找元素 + } + + for i := 0; i < q.size; i++ { + if q.comparer(q.data[i], oldItem) { + q.data[i] = newItem + return true + } + } + return false // 未找到要替换的元素 +} + +// Enqueue 入队操作(队列尾部添加元素) +// item: 要添加的元素 +// 返回: 是否添加成功 +func (q *GenericQueue[T]) Enqueue(item T) bool { + return q.Add(item) +} + +// Dequeue 出队操作(从队列头部移除并返回元素) +// 返回: 元素和是否成功 +func (q *GenericQueue[T]) Dequeue() (T, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + + var zero T + if q.size == 0 { + return zero, false // 队列为空 + } + + // 获取队列头部元素 + item := q.data[0] + + // 将后面的元素前移 + for i := 0; i < q.size-1; i++ { + q.data[i] = q.data[i+1] + } + q.size-- + + // 调整current指针 + if q.current > 0 { + q.current-- + } + if q.current >= q.size && q.size > 0 { + q.current = 0 + } + + // 重新映射lastUsed(因为索引发生了变化) + newLastUsed := make(map[int]time.Time) + for index, lastTime := range q.lastUsed { + if index > 0 { + newLastUsed[index-1] = lastTime + } + } + q.lastUsed = newLastUsed + + return item, true +} + +// Peek 查看队列头部元素(不移除) +// 返回: 元素和是否成功 +func (q *GenericQueue[T]) Peek() (T, bool) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var zero T + if q.size == 0 { + return zero, false // 队列为空 + } + + return q.data[0], true +} + +// PeekLast 查看队列尾部元素(不移除) +// 返回: 元素和是否成功 +func (q *GenericQueue[T]) PeekLast() (T, bool) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var zero T + if q.size == 0 { + return zero, false // 队列为空 + } + + return q.data[q.size-1], true +} + +// ApiKeyInfo API密钥信息结构体 +type ApiKeyInfo struct { + Key string `json:"key"` // API密钥 + Name string `json:"name"` // 密钥名称 + Weight int `json:"weight"` // 权重 + Enabled bool `json:"enabled"` // 是否启用 + Metadata map[string]string `json:"metadata"` // 元数据 +}