diff --git a/.gitignore b/.gitignore index 24cb16d..aeffba7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ cmd/migrate/migration/version-local/* # go sum go.sum config/settings.deva.yml +/aggregate_translate_server diff --git a/aggregate_translate_server b/aggregate_translate_server deleted file mode 100644 index 699132c..0000000 Binary files a/aggregate_translate_server and /dev/null differ diff --git a/app/admin/apis/tm_member_platform.go b/app/admin/apis/tm_member_platform.go index c23dfb1..552b6ca 100644 --- a/app/admin/apis/tm_member_platform.go +++ b/app/admin/apis/tm_member_platform.go @@ -222,3 +222,29 @@ func (e TmMemberPlatform) GetStatistic(c *gin.Context) { e.OK(data, "获取数据成功") } + +// 修改用户-翻译通道字符消耗 +func (e TmMemberPlatform) ChangeChars(c *gin.Context) { + s := service.TmMemberPlatform{} + req := dto.TmMemberPlatformChangeCharsReq{} + 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.ChangeChars(&req, p) + if err != nil { + e.Error(500, err, fmt.Sprintf("修改用户-翻译通道字符消耗失败,\r\n失败信息 %s", err.Error())) + return + } + + e.OK(nil, "修改成功") +} diff --git a/app/admin/apis/tm_recharge_log.go b/app/admin/apis/tm_recharge_log.go new file mode 100644 index 0000000..e9df7f1 --- /dev/null +++ b/app/admin/apis/tm_recharge_log.go @@ -0,0 +1,131 @@ +package apis + +import ( + "go-admin/app/admin/service" + "go-admin/app/admin/service/dto" + "go-admin/common/actions" + "go-admin/common/statuscode" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" +) + +type TmRechargeLog struct { + api.Api +} + +// GetPage 分页查询 +func (e TmRechargeLog) GetPage(c *gin.Context) { + s := service.TmRechargeLog{} + req := dto.TmRechargeLogGetPageReq{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + + if err != nil { + e.Error(500, err, "") + return + } + + p := actions.GetPermissionFromContext(c) + datas := make([]dto.TmRechargeLogResp, 0) + var count int64 + + err = s.GetPage(&req, p, &datas, &count) + + if err != nil { + e.Error(500, err, "") + return + } + + e.PageOK(datas, int(count), req.GetPageIndex(), req.GetPageSize(), "") +} + +// 创建充值订单 +func (e TmRechargeLog) CreateOrder(c *gin.Context) { + req := dto.TmRechargeCreateOrderReq{} + s := service.TmRechargeLog{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + + if err != nil { + e.Error(500, nil, statuscode.ErrorMessage[statuscode.ServerError]) + return + } + + apiKey := c.GetString("apiKey") + + code := s.CreateOrder(&req, apiKey) + if code != statuscode.Success { + e.OK(code, statuscode.ErrorMessage[code]) + return + } + + e.OK(nil, "success") +} + +// 后台充值 +func (e TmRechargeLog) ManagerRecharge(c *gin.Context) { + req := dto.TmRechargeCreateOrderReq{} + s := service.TmRechargeLog{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req). + MakeService(&s.Service). + Errors + + if err != nil { + e.Error(500, err, "") + return + } + + if err := req.Validate(); err != nil { + e.Error(500, err, "") + return + } + + req.SetCreateBy(user.GetUserId(c)) + p := actions.GetPermissionFromContext(c) + + err = s.ManagerRecharge(&req, p) + if err != nil { + e.Error(500, err, "") + return + } + + e.OK(nil, "充值成功") +} + +// 获取即将过期充值记录 +func (e TmMember) GetMemberAdvent(c *gin.Context) { + s := service.TmRechargeLog{} + req := dto.TmRechargeLogFrontReq{} + err := e.MakeContext(c). + MakeOrm(). + Bind(&req, binding.Query, binding.Form). + MakeService(&s.Service). + Errors + + if err != nil { + e.Logger.Errorf("获取即将过期充值记录失败:", err) + e.Error(statuscode.ServerError, nil, statuscode.ErrorMessage[statuscode.ServerError]) + } + + userId := user.GetUserId(c) + datas := []dto.TmRechargeLogFrontResp{} + + code := s.GetMemberAdvent(&req, &datas, userId) + if code != statuscode.Success { + e.Error(code, nil, statuscode.ErrorMessage[code]) + return + } + + e.OK(datas, "success") +} diff --git a/app/admin/apis/translate.go b/app/admin/apis/translate.go index 58c7677..ef55a7c 100644 --- a/app/admin/apis/translate.go +++ b/app/admin/apis/translate.go @@ -42,7 +42,7 @@ func (e *Translate) Translate(c *gin.Context) { if code != statuscode.Success { e.Logger.Error(err) - e.Error(code, nil, statuscode.ErrorMessage[code]) + e.OK(code, statuscode.ErrorMessage[code]) return } diff --git a/app/admin/models/tm_member_platform.go b/app/admin/models/tm_member_platform.go index 7ccee49..f59c497 100644 --- a/app/admin/models/tm_member_platform.go +++ b/app/admin/models/tm_member_platform.go @@ -10,6 +10,7 @@ type TmMemberPlatform struct { MemberId int `json:"memberId" gorm:"type:bigint;comment:用户id"` RemainingCharacter int `json:"remainingCharacter" gorm:"type:bigint;comment:剩余字符数"` TotalCharacter int `json:"totalCharacter" gorm:"type:bigint;comment:总字符数"` + UseCharacter int `json:"useCharacter" gorm:"type:bigint;comment:已用字符数"` PlatformId int `json:"platformId" gorm:"type:bigint;comment:平台id"` PlatformKey string `json:"platformKey" gorm:"type:varchar(50);comment:平台key"` Status int `json:"status" gorm:"type:tinyint;comment:状态 1-启用 2-禁用"` diff --git a/app/admin/models/tm_recharge_log.go b/app/admin/models/tm_recharge_log.go index 051db0b..cf2e072 100644 --- a/app/admin/models/tm_recharge_log.go +++ b/app/admin/models/tm_recharge_log.go @@ -1,14 +1,28 @@ package models -import "go-admin/common/models" +import ( + "go-admin/common/models" + "time" + + "github.com/shopspring/decimal" +) type TmRechargeLog struct { models.Model - UserId int `json:"userId" gorm:"column:user_id;type:bigint;not null;comment:用户id"` - MemberId int `json:"memberId" gorm:"column:member_id;type:bigint;not null;comment:翻译用户id"` - Status int `json:"status" gorm:"column:status;type:tinyint;not null;comment:状态 1-正常 2-作废"` - TotalChars int `json:"totalChars" gorm:"column:total_chars;type:bigint;not null;comment:充值字符数"` + Type int `json:"type" gorm:"column:type;type:tinyint;not null;comment:类型 1-充值 2-后台充值 3-赠送"` + OrderNo string `json:"orderNo" gorm:"column:order_no;type:varchar(36);not null;comment:订单号"` + UserId int `json:"userId" gorm:"column:user_id;type:bigint;not null;comment:用户id"` + MemberId int `json:"memberId" gorm:"column:member_id;type:bigint;not null;comment:翻译用户id"` + PlatformId int `json:"platformId" gorm:"column:platform_id;type:bigint;not null;comment:平台id"` + Status int `json:"status" gorm:"column:status;type:tinyint;not null;comment:状态 1-待支付 2-已支付 3-已取消 4-申请退款 5-已退款 6-已过期"` + TotalChars int `json:"totalChars" gorm:"column:total_chars;type:bigint;not null;comment:充值字符数"` + TxHash string `json:"txHash" gorm:"column:tx_hash;type:varchar(64);comment:交易hash"` + Amount decimal.Decimal `json:"amount" gorm:"column:amount;type:decimal(10,6);not null;comment:充值金额U"` + ReceiveChannel string `json:"receiveChannel" gorm:"column:receive_channel;type:varchar(100);not null;comment:充值渠道"` + ReceiveAddress string `json:"receiveAddress" gorm:"column:receive_address;type:varchar(100);not null;comment:充值地址"` + PayTime *time.Time `json:"payTime" gorm:"column:pay_time;type:datetime;comment:支付时间"` + ExpireAt time.Time `json:"expireAt" gorm:"column:expire_at;type:datetime;not null;comment:过期时间"` models.ModelTime models.ControlBy } diff --git a/app/admin/router/tm_member.go b/app/admin/router/tm_member.go index 64721d5..0168ae2 100644 --- a/app/admin/router/tm_member.go +++ b/app/admin/router/tm_member.go @@ -16,6 +16,7 @@ func init() { // registerTmMemberRouter func registerTmMemberRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { api := apis.TmMember{} + rechargeApi := apis.TmRechargeLog{} r := v1.Group("/tm-member").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) { r.GET("", actions.PermissionAction(), api.GetPage) @@ -24,7 +25,8 @@ func registerTmMemberRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl r.PUT("/:id", actions.PermissionAction(), api.Update) r.DELETE("", api.Delete) - r.POST("recharge", actions.PermissionAction(), api.Recharge) //字符充值 + // r.POST("recharge", actions.PermissionAction(), api.Recharge) //字符充值 + r.POST("manager-recharge", actions.PermissionAction(), rechargeApi.ManagerRecharge) r.PUT("status", actions.PermissionAction(), api.ChangeStatus) //状态变更 } @@ -32,5 +34,6 @@ func registerTmMemberRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddl { r2.GET("/api-key", api.GetMyApiKey) r2.GET("platforms", api.GetPlatforms) + r2.GET("member-advent", api.GetMemberAdvent) //获取用户即将过期的充值信息 } } diff --git a/app/admin/router/tm_member_platform.go b/app/admin/router/tm_member_platform.go index e430789..57d6d05 100644 --- a/app/admin/router/tm_member_platform.go +++ b/app/admin/router/tm_member_platform.go @@ -23,6 +23,7 @@ func registerTmMemberPlatformRouter(v1 *gin.RouterGroup, authMiddleware *jwt.Gin r.POST("", api.Insert) r.PUT("/:id", actions.PermissionAction(), api.Update) r.DELETE("", api.Delete) + r.POST("/change-chars", actions.PermissionAction(), api.ChangeChars) } f2 := v1.Group("/tm-member-platform").Use(authMiddleware.MiddlewareFunc()) diff --git a/app/admin/service/dto/tm_member.go b/app/admin/service/dto/tm_member.go index 0504c38..503661b 100644 --- a/app/admin/service/dto/tm_member.go +++ b/app/admin/service/dto/tm_member.go @@ -120,21 +120,24 @@ func (s *TmMemberDeleteReq) GetId() interface{} { } type TmMemberResp struct { - Id int `json:"id"` - UserId int `json:"userId"` - NickName string `json:"nickName"` - UserStatus int `json:"userStatus"` - Status int `json:"status"` - ApiKey string `json:"apiKey"` - TotalChars int `json:"totalChars"` - RemainChars int `json:"remainChars"` - CreatedAt time.Time `json:"createdAt"` - Platforms []TmMemberPlatformResp `json:"platforms"` + Id int `json:"id"` + UserId int `json:"userId"` + NickName string `json:"nickName"` + UserStatus int `json:"userStatus"` + Status int `json:"status"` + ApiKey string `json:"apiKey"` + TotalChars int `json:"totalChars"` + RemainChars int `json:"remainChars"` + CreatedAt time.Time `json:"createdAt"` + Platforms []TmMemberPlatformResp `json:"platforms"` + UsedPlatform []TmMemberPlatformResp `json:"usedPlatform"` } type TmMemberPlatformResp struct { - Name string `json:"name"` - RemainChars int `json:"remainChars"` + PlatformId int `json:"platformId"` + MemberId int `json:"memberId"` + Name string `json:"name"` + TotalChars int `json:"totalChars"` } type TmMemberRechargeReq struct { @@ -175,9 +178,11 @@ func (e *TmMemberChangeStatusReq) Validate() error { type TmMemberPlatformFrontedResp struct { Id int `json:"id"` + PlatformId int `json:"platformId"` Name string `json:"name"` RemainChars int `json:"remainChars"` TotalChars int `json:"totalChars"` + UseChars int `json:"useChars"` Price int `json:"price"` ApiKey string `json:"apiKey"` } diff --git a/app/admin/service/dto/tm_member_daily_usage.go b/app/admin/service/dto/tm_member_daily_usage.go index 49d8dbd..0d89d0d 100644 --- a/app/admin/service/dto/tm_member_daily_usage.go +++ b/app/admin/service/dto/tm_member_daily_usage.go @@ -99,3 +99,10 @@ type TmMemberDailyUsageDeleteReq struct { func (s *TmMemberDailyUsageDeleteReq) GetId() interface{} { return s.Ids } + +type TmMemberPlatformGroupData struct { + MemberId int `json:"memberId" comment:"用户id"` + PlatformId int `json:"platformId" comment:"平台id"` + // PlatformKey string `json:"platformKey" comment:"平台key"` + Total int `json:"total" comment:"总使用量"` +} diff --git a/app/admin/service/dto/tm_member_platform.go b/app/admin/service/dto/tm_member_platform.go index a51afe7..1b5c684 100644 --- a/app/admin/service/dto/tm_member_platform.go +++ b/app/admin/service/dto/tm_member_platform.go @@ -109,3 +109,11 @@ type TmMemberPlatformStatisticItemResp struct { PlatformId int `json:"platformId"` Data []int `json:"data" comment:"数据"` } + +type TmMemberPlatformChangeCharsReq struct { + Type int `json:"type" comment:"1-可用字符,2-已使用字符"` + MemberId int `json:"memberId" comment:"用户id"` + PlatformId int `json:"platformId" comment:"平台id"` + ChangeNum int `json:"changeNum" comment:"变更数量"` + common.ControlBy +} diff --git a/app/admin/service/dto/tm_recharge_log.go b/app/admin/service/dto/tm_recharge_log.go new file mode 100644 index 0000000..f7b24ac --- /dev/null +++ b/app/admin/service/dto/tm_recharge_log.go @@ -0,0 +1,165 @@ +package dto + +import ( + "errors" + "go-admin/app/admin/models" + "go-admin/common/dto" + common "go-admin/common/models" + "time" + + "github.com/shopspring/decimal" +) + +type TmRechargeLogGetPageReq struct { + dto.Pagination `search:"-"` + Type int `form:"type" search:"type:exact;column:type;table:tm_recharge_log"` + Status int `form:"status" search:"type:exact;column:status;table:tm_recharge_log"` + PlatformId int `form:"platformId" search:"type:exact;column:platform_id;table:tm_recharge_log"` + TmRechargeLogOrder +} + +type TmRechargeLogOrder struct { + Id string `form:"idOrder" search:"type:order;column:id;table:tm_platform"` + PayTime string `form:"payTimeOrder" search:"type:order;column:pay_time;table:tm_platform"` +} + +func (m *TmRechargeLogGetPageReq) GetNeedSearch() interface{} { + return *m +} + +type TmRechargeLogInsertReq struct { + Id int `json:"-" comment:"平台id"` + MemberId int `json:"memberId" comment:"会员id"` + PlatformId int `json:"platformId" comment:"平台id"` + Amount decimal.Decimal `json:"amount" comment:"充值金额"` + TotalChars int `json:"totalChars" comment:"总字数"` + ReceiveChannel string `json:"receiveChannel" comment:"充值渠道"` + Status int `json:"status" comment:"充值状态"` + + common.ControlBy +} + +func (s *TmRechargeLogInsertReq) Generate(model *models.TmPlatform) { + if s.Id == 0 { + model.Model = common.Model{Id: s.Id} + } + + model.CreateBy = s.CreateBy // 添加这而,需要记录是被谁创建的 +} + +func (s *TmRechargeLogInsertReq) GetId() interface{} { + return s.Id +} + +type TmRechargeLogListResp struct { + Id int `json:"id"` + ShowName string `json:"showName"` + Code string `json:"code"` + Price decimal.Decimal `json:"price"` +} + +// TmPlatformGetReq 功能获取请求参数 +type TmRechargeLogGetReq struct { + Id int `uri:"id"` +} + +func (s *TmRechargeLogGetReq) GetId() interface{} { + return s.Id +} + +// TmPlatformDeleteReq 功能删除请求参数 +type TmRechargeLogDeleteReq struct { + Ids []int `json:"ids"` +} + +func (s *TmRechargeLogDeleteReq) GetId() interface{} { + return s.Ids +} + +type TmRechargeLogResp struct { + Id int `json:"id"` + + OrderNo string `json:"orderNo"` + Type int `json:"type"` + MemberId int `json:"memberId"` + UserId int `json:"userId"` + PlatformId int `json:"platformId"` + Amount decimal.Decimal `json:"amount"` + TotalChars int `json:"totalChars"` + ReceiveChannel string `json:"receiveChannel"` + Status int `json:"status"` + TxHash string `json:"txHash"` + PayTime string `json:"payTime"` + CreatedAt string `json:"createdAt"` +} + +// 用户充值订单创建请求参数 +type TmRechargeCreateOrderReq struct { + Type int `json:"type" comment:"充值类型 1-用户充值 2-平台充值"` + MemberId int `json:"memberId" comment:"会员id"` + UserId int `json:"userId" comment:"用户id"` + PlatformId int `json:"platformId" comment:"平台id"` + TotalChars int `json:"totalChars" comment:"总字数"` + ReceiveChannel string `json:"receiveChannel" comment:"充值渠道"` + ReceiveAddress string `json:"receiveAddress" comment:"充值地址"` + TxHash string `json:"txHash" comment:"交易hash"` + Amount decimal.Decimal `json:"amount" comment:"充值金额"` + ExpireDays int `json:"expireDays" comment:"过期天数"` + common.ControlBy +} + +func (e *TmRechargeCreateOrderReq) Validate() error { + if e.ExpireDays <= 0 { + return errors.New("过期天数必须大于0") + } + + if e.MemberId <= 0 { + return errors.New("会员不存在") + } + + if e.PlatformId <= 0 { + return errors.New("平台不存在") + } + + return nil +} + +func (e *TmRechargeCreateOrderReq) Generate(model *models.TmRechargeLog) { + if model == nil { + model = &models.TmRechargeLog{} + } + + model.PlatformId = e.PlatformId + model.MemberId = e.MemberId + model.UserId = e.UserId + model.Type = e.Type + model.TotalChars = e.TotalChars * 10000 + model.Amount = e.Amount + model.Status = 1 + model.ReceiveChannel = e.ReceiveChannel + model.ReceiveAddress = e.ReceiveAddress + model.TxHash = e.TxHash + model.CreateBy = e.CreateBy + model.CreatedAt = time.Now() +} + +// 用户充值订单创建参数 +type TmRechargeLogInsertOrUpdateReq struct { + MemberId int `json:"memberId" comment:"会员id"` + PlatformId int `json:"platformId" comment:"平台id"` + PlatformKey string `json:"platformKey" comment:"平台key"` + RemainCharater int `json:"remainChars" comment:"剩余字数"` +} + +type TmRechargeLogFrontReq struct { + PlatformId int `json:"platformId" query:"platformId" form:"platformId" comment:"平台id"` +} + +type TmRechargeLogFrontResp struct { + Id int `json:"id"` + OrderNo string `json:"orderNo"` + PlatformName string `json:"platformName"` + ExpireUnix int64 `json:"expireUnix"` + TotalCharater int `json:"totalCharater"` + RemainCharater int `json:"remainCharater"` +} diff --git a/app/admin/service/quota_manager/lua/deduct.lua b/app/admin/service/quota_manager/lua/deduct.lua new file mode 100644 index 0000000..1ac7d3f --- /dev/null +++ b/app/admin/service/quota_manager/lua/deduct.lua @@ -0,0 +1,57 @@ +-- KEYS[1] = tm_member_remain_count:{key}:{platformCode} +-- ARGV[1] = 当前时间戳(秒) +-- ARGV[2] = 扣减数量 + +local zset_key = KEYS[1] +local now = tonumber(ARGV[1]) +local deduct = tonumber(ARGV[2]) +local remain = deduct +local result = {} + + +-- 获取所有过期的 quota_id +local expired_quota_ids = redis.call("ZRANGEBYSCORE", zset_key, "-inf", now) +for _, quota_id in ipairs(expired_quota_ids) do + redis.call("DEL", "quota:" .. quota_id) +end + +-- 清理过期额度 +redis.call("ZREMRANGEBYSCORE", zset_key, "-inf", now) + +local quota_ids = redis.call("ZRANGEBYSCORE", zset_key, now, "+inf") + +for _, quota_id in ipairs(quota_ids) do + local quota_key = "quota:" .. quota_id + local amount = tonumber(redis.call("HGET", quota_key, "amount") or "0") + + if amount > 0 then + local used = 0 + if amount >= remain then + used = remain + redis.call("HINCRBY", quota_key, "amount", -remain) + remain = 0 + else + used = amount + redis.call("HINCRBY", quota_key, "amount", -amount) + remain = remain - amount + end + + table.insert(result, quota_id .. ":" .. used) + + local new_amount = tonumber(redis.call("HGET", quota_key, "amount") or "0") + if new_amount <= 0 then + redis.call("DEL", quota_key) + redis.call("ZREM", zset_key, quota_id) + end + + if remain == 0 then + break + end + end +end + +if remain > 0 then + return {} +end + +return result diff --git a/app/admin/service/quota_manager/lua/refund.lua b/app/admin/service/quota_manager/lua/refund.lua new file mode 100644 index 0000000..8ffa9f9 --- /dev/null +++ b/app/admin/service/quota_manager/lua/refund.lua @@ -0,0 +1,21 @@ +-- KEYS[1] = tm_member_remain_count:{key}:{platformCode} +-- ARGV = {quota_id_1, amount_1, expire_1, quota_id_2, amount_2, expire_2, ...} + +local zset_key = KEYS[1] +local len = table.getn(ARGV) + +for i = 1, len, 3 do + local quota_id = ARGV[i] + local amount = tonumber(ARGV[i+1]) + local expire = tonumber(ARGV[i+2]) + local quota_key = "quota:" .. quota_id + + if redis.call("EXISTS", quota_key) == 0 then + redis.call("HSET", quota_key, "amount", amount) + redis.call("ZADD", zset_key, expire, quota_id) + else + redis.call("HINCRBY", quota_key, "amount", amount) + end +end + +return 1 diff --git a/app/admin/service/quota_manager/quota_manager.go b/app/admin/service/quota_manager/quota_manager.go new file mode 100644 index 0000000..82442c9 --- /dev/null +++ b/app/admin/service/quota_manager/quota_manager.go @@ -0,0 +1,229 @@ +package quota_manager + +import ( + "context" + "fmt" + rediskey "go-admin/common/redis_key" + "strconv" + "strings" + "time" + + "github.com/go-redis/redis/v8" +) + +type QuotaUsage struct { + QuotaID string + Used int64 + Expire int64 +} + +type QuotaRecharge struct { + QuotaID string + Amount int64 + ExpireAt int64 +} + +type QuotaManager struct { + rdb *redis.Client + deductLua *redis.Script + refundLua *redis.Script + zsetKeyPrefix string // "tm_member_remain_count" +} + +func NewQuotaManager(rdb *redis.Client) *QuotaManager { + deductScript := ` +-- Lua 脚本内容复制 deduct.lua +local zset_key = KEYS[1] +local now = tonumber(ARGV[1]) +local deduct = tonumber(ARGV[2]) +local remain = deduct +local result = {} + +local expired_quota_ids = redis.call("ZRANGEBYSCORE", zset_key, "-inf", now) +for _, quota_id in ipairs(expired_quota_ids) do + redis.call("DEL", "quota:" .. quota_id) +end + +redis.call("ZREMRANGEBYSCORE", zset_key, "-inf", now) + +local quota_ids = redis.call("ZRANGEBYSCORE", zset_key, now, "+inf") + +for _, quota_id in ipairs(quota_ids) do + local quota_key = "quota:" .. quota_id + local amount = tonumber(redis.call("HGET", quota_key, "amount") or "0") + + if amount > 0 then + local used = 0 + if amount >= remain then + used = remain + redis.call("HINCRBY", quota_key, "amount", -remain) + remain = 0 + else + used = amount + redis.call("HINCRBY", quota_key, "amount", -amount) + remain = remain - amount + end + + table.insert(result, quota_id .. ":" .. used) + + local new_amount = tonumber(redis.call("HGET", quota_key, "amount") or "0") + if new_amount <= 0 then + redis.call("DEL", quota_key) + redis.call("ZREM", zset_key, quota_id) + end + + if remain == 0 then + break + end + end +end + +if remain > 0 then + return {} +end + +return result +` + refundScript := ` +-- Lua 脚本内容复制 refund.lua +local zset_key = KEYS[1] +local len = table.getn(ARGV) + +for i = 1, len, 3 do + local quota_id = ARGV[i] + local amount = tonumber(ARGV[i+1]) + local expire = tonumber(ARGV[i+2]) + local quota_key = "quota:" .. quota_id + + if redis.call("EXISTS", quota_key) == 0 then + redis.call("HSET", quota_key, "amount", amount) + redis.call("ZADD", zset_key, expire, quota_id) + else + redis.call("HINCRBY", quota_key, "amount", amount) + end +end + +return 1 +` + + return &QuotaManager{ + rdb: rdb, + deductLua: redis.NewScript(deductScript), + refundLua: redis.NewScript(refundScript), + zsetKeyPrefix: rediskey.TM_MEMBER_REMAIN_COUNT_PURE, + } +} + +func (q *QuotaManager) zsetKey(userKey, platformCode string) string { + return fmt.Sprintf("%s:%s:%s", q.zsetKeyPrefix, userKey, platformCode) +} + +// 充值写入 +func (q *QuotaManager) AddQuota(ctx context.Context, userKey, platformCode string, recharge QuotaRecharge) error { + zsetKey := q.zsetKey(userKey, platformCode) + quotaKey := fmt.Sprintf("quota:%s", recharge.QuotaID) + + err := q.rdb.HSet(ctx, quotaKey, map[string]interface{}{ + "amount": recharge.Amount, + }).Err() + if err != nil { + return err + } + + err = q.rdb.ZAdd(ctx, zsetKey, &redis.Z{ + Score: float64(recharge.ExpireAt), + Member: recharge.QuotaID, + }).Err() + return err +} + +// 扣减 +func (q *QuotaManager) Deduct(ctx context.Context, userKey, platformCode string, deductCount int64) ([]QuotaUsage, error) { + zsetKey := q.zsetKey(userKey, platformCode) + now := time.Now().Unix() + + res, err := q.deductLua.Run(ctx, q.rdb, []string{zsetKey}, now, deductCount).Result() + if err != nil { + return nil, err + } + + arr, ok := res.([]interface{}) + if !ok || len(arr) == 0 { + return nil, fmt.Errorf("insufficient quota or invalid response") + } + + var usages []QuotaUsage + for _, v := range arr { + s, ok := v.(string) + if !ok { + continue + } + parts := strings.Split(s, ":") + if len(parts) != 2 { + continue + } + used, _ := strconv.ParseInt(parts[1], 10, 64) + usages = append(usages, QuotaUsage{ + QuotaID: parts[0], + Used: used, + }) + } + + for i := range usages { + score, err := q.rdb.ZScore(ctx, zsetKey, usages[i].QuotaID).Result() + if err == nil { + usages[i].Expire = int64(score) + } + } + return usages, nil +} + +// 回滚 +func (q *QuotaManager) Refund(ctx context.Context, userKey, platformCode string, usages []QuotaUsage) error { + zsetKey := q.zsetKey(userKey, platformCode) + + var args []interface{} + for _, u := range usages { + args = append(args, u.QuotaID, u.Used, u.Expire) + } + + _, err := q.refundLua.Run(ctx, q.rdb, []string{zsetKey}, args...).Result() + return err +} + +func (q *QuotaManager) GetTotalRemainingQuota(ctx context.Context, apiKey, platformCode string) (int, error) { + zsetKey := q.zsetKey(apiKey, platformCode) + + now := time.Now().Unix() + total := 0 + + // 获取全部 quotaID 和过期时间 + zsetEntries, err := q.rdb.ZRangeWithScores(ctx, zsetKey, 0, -1).Result() + if err != nil { + return 0, err + } + + for _, entry := range zsetEntries { + expireAt := int64(entry.Score) + if expireAt <= now { + continue // 跳过已过期的 quota + } + + quotaID := fmt.Sprintf("%v", entry.Member) + quotaKey := fmt.Sprintf("quota:%s", quotaID) + + amountStr, err := q.rdb.HGet(ctx, quotaKey, "amount").Result() + if err != nil || amountStr == "" { + continue // 不存在或异常 + } + + amount, err := strconv.Atoi(amountStr) + if err != nil { + continue + } + + total += amount + } + + return total, nil +} diff --git a/app/admin/service/quota_manager/quota_manager_test.go b/app/admin/service/quota_manager/quota_manager_test.go new file mode 100644 index 0000000..6f1647f --- /dev/null +++ b/app/admin/service/quota_manager/quota_manager_test.go @@ -0,0 +1,80 @@ +package quota_manager + +import ( + "context" + "go-admin/utils/redishelper" + "os" + "strconv" + "testing" + "time" +) + +var ( + redisAddr = "localhost:6379" + redisPwd = "" + redisDB = 1 +) + +func setupRedis() { + redishelper.InitDefaultRedis(redisAddr, redisPwd, redisDB) + redishelper.InitLockRedisConn(redisAddr, redisPwd, strconv.Itoa(redisDB)) +} + +func TestQuotaManager_AddDeductRefund(t *testing.T) { + ctx := context.Background() + setupRedis() + qmgr := NewQuotaManager(redishelper.DefaultRedis.GetClient()) + + userKey := "Bw4iSj9Y90ix0e05GrMFp6EuFFTIbE9j" + platformCode := "deepl_free" + quotaID := "testquota1" + expireAt := time.Now().Add(1 * time.Hour).Unix() + amount := int64(1000) + + // 充值写入 + err := qmgr.AddQuota(ctx, userKey, platformCode, QuotaRecharge{ + QuotaID: quotaID, + Amount: amount, + ExpireAt: expireAt, + }) + if err != nil { + t.Fatalf("AddQuota failed: %v", err) + } + + // 扣减500 + usages, err := qmgr.Deduct(ctx, userKey, platformCode, 500) + if err != nil { + t.Fatalf("Deduct failed: %v", err) + } + if len(usages) == 0 { + t.Fatal("Deduct returned empty usage") + } + + if usages[0].Used != 500 { + t.Errorf("Expected Used=500, got %d", usages[0].Used) + } + + // 回滚500 + err = qmgr.Refund(ctx, userKey, platformCode, usages) + if err != nil { + t.Fatalf("Refund failed: %v", err) + } + + // 再次扣减1000,确认余额充足 + usages, err = qmgr.Deduct(ctx, userKey, platformCode, 1000) + if err != nil { + t.Fatalf("Second Deduct failed: %v", err) + } +} + +// 设置过期时间 +func TestQuotaManager_Deduct_Refund_WithExpire(t *testing.T) { + setupRedis() + if err := redishelper.DefaultRedis.ZUpdateScore("tm_member_remain_count:MiyJrgfh3gYhwyDO43fdhHNswm4CeAfn:deepl", 1751436958, "573610499514565142"); err != nil { + t.Fatalf("ZUpdateScore failed: %v", err) + } +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/app/admin/service/sys_user.go b/app/admin/service/sys_user.go index 9cf9d71..280e502 100644 --- a/app/admin/service/sys_user.go +++ b/app/admin/service/sys_user.go @@ -82,7 +82,7 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error { e.Orm.Model(role).Where("role_id = ?", c.RoleId).Find(&role) if role.RoleId == 0 { - err = errors.New("角色不存在") + return errors.New("角色不存在") } err = e.Orm.Model(&data).Where("username = ?", c.Username).Count(&i).Error diff --git a/app/admin/service/tm_member.go b/app/admin/service/tm_member.go index ef66454..be8f747 100644 --- a/app/admin/service/tm_member.go +++ b/app/admin/service/tm_member.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strconv" @@ -9,12 +10,12 @@ import ( "github.com/bytedance/sonic" "github.com/go-admin-team/go-admin-core/sdk/service" - "github.com/go-redis/redis/v8" "github.com/jinzhu/copier" "gorm.io/gorm" "go-admin/app/admin/models" "go-admin/app/admin/service/dto" + "go-admin/app/admin/service/quota_manager" "go-admin/common/actions" cDto "go-admin/common/dto" rediskey "go-admin/common/redis_key" @@ -31,6 +32,7 @@ func (e TmMember) GetUserPlatforms(userId int, resp *[]dto.TmMemberPlatformFront var memberAccount models.TmMember if err := e.Orm.Model(&memberAccount). + Where("user_id=?", userId). Find(&memberAccount).Error; err != nil { return err } @@ -51,8 +53,10 @@ func (e TmMember) GetUserPlatforms(userId int, resp *[]dto.TmMemberPlatformFront dataItem.ApiKey = memberAccount.ApiKey dataItem.Name = platform.ShowName + dataItem.PlatformId = platform.Id dataItem.Price = int(platform.Price.IntPart()) - dataItem.RemainChars, _ = e.GetRemainCount(item.PlatformKey, dataItem.ApiKey) + dataItem.RemainChars = item.RemainingCharacter + dataItem.UseChars = item.UseCharacter *resp = append(*resp, dataItem) } @@ -82,25 +86,30 @@ func (e *TmMember) GetPage(c *dto.TmMemberGetPageReq, p *actions.DataPermission, } userIds := []int{} + memberIds := []int{} for _, item := range datas { if !utility.ContainsInt(userIds, item.UserId) { userIds = append(userIds, item.UserId) } + + if !utility.ContainsInt(memberIds, item.Id) { + memberIds = append(memberIds, item.Id) + } } + memberPlatformService := TmMemberPlatform{Service: e.Service} platformService := TmPlatform{Service: e.Service} userService := SysUser{Service: e.Service} users, _ := userService.GetByIds(userIds) activeList, _ := platformService.GetActiveList() + memberPlatforms, _ := memberPlatformService.GetMemberList(memberIds) for _, item := range datas { dataItem := dto.TmMemberResp{} copier.Copy(&dataItem, &item) - // count, _ := e.GetRemainCount(,dataItem.ApiKey) dataItem.ApiKey = utility.DesensitizeGeneric(dataItem.ApiKey, 2, 2, '*') - // dataItem.RemainChars = count for _, user := range users { if user.UserId == item.UserId { @@ -108,14 +117,33 @@ func (e *TmMember) GetPage(c *dto.TmMemberGetPageReq, p *actions.DataPermission, } } + //剩余字符 for _, platform := range activeList { platformItem := dto.TmMemberPlatformResp{} + platformItem.PlatformId = platform.Id + platformItem.MemberId = item.Id platformItem.Name = platform.Name - platformItem.RemainChars, _ = e.GetRemainCount(platform.Code, item.ApiKey) + platformItem.TotalChars, _ = e.GetRemainCount(platform.Code, item.ApiKey) dataItem.Platforms = append(dataItem.Platforms, platformItem) } + //已用字符 + for _, platform := range activeList { + platformItem := dto.TmMemberPlatformResp{} + platformItem.PlatformId = platform.Id + platformItem.MemberId = item.Id + platformItem.Name = platform.Name + + for _, memberPlatform := range memberPlatforms { + if memberPlatform.PlatformId == platform.Id && memberPlatform.MemberId == item.Id { + platformItem.TotalChars = memberPlatform.UseCharacter + } + } + + dataItem.UsedPlatform = append(dataItem.UsedPlatform, platformItem) + } + *list = append(*list, dataItem) } @@ -190,13 +218,11 @@ func (e *TmMember) SaveAllCache() error { } for _, item := range list { key := fmt.Sprintf(rediskey.TM_MEMBER_BY_KEY, item.ApiKey) - // remainKey := fmt.Sprintf(rediskey.TM_MEMBER_REMAIN_COUNT, item.ApiKey) val, err := sonic.MarshalString(item) if err != nil { return err } redishelper.DefaultRedis.SetString(key, val) - // redishelper.DefaultRedis.SetString(remainKey, strconv.Itoa(item.RemainChars)) } return nil } @@ -262,64 +288,101 @@ func (e *TmMember) RemoveByUserIds(userIds []int) error { return nil } -// SyncMemberRemain 同步剩余字符 +// SyncMemberRemain 同步剩余字符(ZSET 多笔充值版) func (e *TmMember) SyncMemberRemain() error { - scanKeys, err := redishelper.DefaultRedis.ScanKeys(fmt.Sprintf("%s*", rediskey.TM_MEMBER_REMAIN_COUNT_PURE)) - + scanKeys, err := redishelper.DefaultRedis.ScanKeys(fmt.Sprintf("%s:*:*", rediskey.TM_MEMBER_REMAIN_COUNT_PURE)) if err != nil { return err } members := e.getCacheMembers() + dailyUsageService := TmMemberDailyUsage{Service: e.Service} datas := make([]models.TmMemberPlatform, 0) + now := time.Now().Unix() for _, key := range scanKeys { items := strings.Split(key, ":") - apiKey := items[len(items)-2] - platform := items[len(items)-1] - val, err := redishelper.DefaultRedis.GetString(key) - remainCount, err1 := strconv.Atoi(val) - - if err != nil || err1 != nil { - e.Log.Errorf("TmMemberService SyncMemberRemain GetString error:%s \r\n err1:%s \r\n", err, err1) + if len(items) < 3 { continue } + apiKey := items[len(items)-2] + platform := items[len(items)-1] + + // 清除过期的 quotaID(ZSET + Hash) + expiredIDs, err := redishelper.DefaultRedis.ZRangeByScore(key, "-inf", strconv.FormatInt(now, 10)) + if err == nil && len(expiredIDs) > 0 { + for _, quotaID := range expiredIDs { + redishelper.DefaultRedis.DeleteString(fmt.Sprintf("quota:%s", quotaID)) + } + redishelper.DefaultRedis.ZRemValues(key, expiredIDs...) + } + + // 从 ZSET 获取所有 quota ID(不清理过期,数据库中保留的是理论值) + zsetEntries, err := redishelper.DefaultRedis.GetRevRangeScoresSortSet(key) + if err != nil { + e.Log.Errorf("TmMemberService SyncMemberRemain ZRANGE failed for key %s: %v", key, err) + continue + } + + totalRemain := 0 + for _, entry := range zsetEntries { + quotaID, ok := entry.Member.(string) + if !ok { + continue + } + + quotaKey := fmt.Sprintf("quota:%s", quotaID) + amountStr, err := redishelper.DefaultRedis.HGetField(quotaKey, "amount") + if err != nil || amountStr == "" { + continue + } + + amount, err := strconv.Atoi(amountStr) + if err != nil { + continue + } + + totalRemain += amount + } + if member := members[apiKey]; member.Id > 0 { - item := models.TmMemberPlatform{} - item.Id = member.Id - item.RemainingCharacter = remainCount - item.PlatformKey = platform - datas = append(datas, item) + memberPlatform := models.TmMemberPlatform{ + PlatformKey: platform, + RemainingCharacter: totalRemain, + } + memberPlatform.Id = member.Id + datas = append(datas, memberPlatform) } } + // 批量更新数据库(分批,每批最多 1000 条) arrayData := utility.SplitSlice(datas, 1000) for _, dataBatch := range arrayData { - - // 遍历当前批次的所有记录,为每条记录单独执行 UPDATE for _, record := range dataBatch { stmt := ` - UPDATE tm_member_platform - SET - remaining_character = ?, - updated_at = NOW() - WHERE platform_key = ? AND member_id = ?; - ` + UPDATE tm_member_platform + SET + remaining_character = ?, + updated_at = NOW() + WHERE platform_key = ? AND member_id = ?; + ` args := []interface{}{ record.RemainingCharacter, record.PlatformKey, record.Id, } - // 执行单个 UPDATE 语句 if err := e.Orm.Exec(stmt, args...).Error; err != nil { - // 记录错误,但继续处理批次中的其他记录 - e.Log.Errorf("TmMemberService SyncMemberRemain single Exec for PlatformKey %s, MemberID %d error: %s \r\n", record.PlatformKey, record.Id, err) + e.Log.Errorf("TmMemberService SyncMemberRemain single Exec for PlatformKey %s, MemberID %d error: %s \r\n", + record.PlatformKey, record.Id, err) } } } + // 同步每日使用量(不变) + dailyUsageService.SyncTotalUse() + return nil } @@ -444,24 +507,6 @@ func (e *TmMember) loadSyncData(keys []string, members *map[string]models.TmMemb } } -// func (e *TmMember) GetMyApiKey(userId int) (dto.TranslateUserInfoResp, error) { -// var data models.TmMember -// resp := dto.TranslateUserInfoResp{} -// if err := e.Orm.Model(&data).Where("user_id = ?", userId).First(&data).Error; err != nil { -// e.Log.Errorf("TmMemberService GetMyApiKey error:%s \r\n", err) -// return resp, nil -// } - -// var err error -// resp.UserApiKey = data.ApiKey -// resp.RemainChars, err = e.GetRemainCount(data.ApiKey) - -// if err != nil { -// e.Log.Errorf("转换类型失败,error:%v", err) -// } -// return resp, nil -// } - // GetTranslateStatistic 获取翻译统计 func (e *TmMember) GetTranslateStatistic(userId int, list *[]dto.TranslateStatisticResp) error { endDate := time.Now().Format("2006-01-02") @@ -535,31 +580,24 @@ func (e *TmMember) Recharge(req *dto.TmMemberRechargeReq, p *actions.DataPermiss // 获取可用字符数 func (e *TmMember) GetRemainCount(platformKey, apiKey string) (int, error) { - key := fmt.Sprintf(rediskey.TM_MEMBER_REMAIN_COUNT, apiKey, platformKey) - val, err := redishelper.DefaultRedis.GetString(key) - result := 0 + ctx := context.Background() + quotaManager := quota_manager.NewQuotaManager(redishelper.DefaultRedis.GetClient()) + result, err := quotaManager.GetTotalRemainingQuota(ctx, apiKey, platformKey) - if err != nil && !errors.Is(err, redis.Nil) { + if err != nil { return 0, err } - if val != "" { - result, err = strconv.Atoi(val) - if err != nil { - return 0, err - } - } + // if result == 0 { + // var data models.TmMember - if result == 0 { - var data models.TmMember + // if err := e.Orm.Model(&data).Where("api_key = ?", apiKey).First(&data).Error; err != nil { + // return 0, err + // } - if err := e.Orm.Model(&data).Where("api_key = ?", apiKey).First(&data).Error; err != nil { - return 0, err - } - - result = data.RemainChars - redishelper.DefaultRedis.SetString(key, strconv.Itoa(result)) - } + // result = data.RemainChars + // // redishelper.DefaultRedis.SetString(key, strconv.Itoa(result)) + // } return result, nil } @@ -578,6 +616,55 @@ func (e *TmMember) DecrBy(platformKey, apiKey string, totalChars int) error { return redishelper.DefaultRedis.DecrBy(remainCountKey, int64(totalChars)).Err() } +// 增加字符 +func (e *TmMember) IncyByQuote(ctx context.Context, platformKey, apiKey string, totalChars int, orderNo string, expireAt time.Time) error { + quotaManager := quota_manager.NewQuotaManager(redishelper.DefaultRedis.GetClient()) + rechargeData := quota_manager.QuotaRecharge{ + QuotaID: orderNo, + Amount: int64(totalChars), + ExpireAt: expireAt.Unix(), + } + + if err := quotaManager.AddQuota(ctx, apiKey, platformKey, rechargeData); err != nil { + return err + } + + return nil +} + +// 扣除字符 +func (e *TmMember) DecrByQuote(ctx context.Context, platformKey, apiKey string, totalChars int) ([]quota_manager.QuotaUsage, error) { + quoteManage := quota_manager.NewQuotaManager(redishelper.DefaultRedis.GetClient()) + + datas, err := quoteManage.Deduct(ctx, apiKey, platformKey, int64(totalChars)) + + if err != nil { + return nil, err + } + + return datas, nil +} + +// 回滚扣除字符 +// apiKey 用户翻译密钥 +// platformKey 平台标识 +// datas 扣除的字符数据 +func (e *TmMember) RefundQuote(ctx context.Context, apiKey, platformKey string, datas *[]quota_manager.QuotaUsage) error { + quoteManage := quota_manager.NewQuotaManager(redishelper.DefaultRedis.GetClient()) + if err := quoteManage.Refund(ctx, apiKey, platformKey, *datas); err != nil { + + } + + return nil +} + +// 设置可用字符数 +func (e *TmMember) SetChars(platformKey, apiKey string, totalChars int) error { + remainCountKey := fmt.Sprintf(rediskey.TM_MEMBER_REMAIN_COUNT, apiKey, platformKey) + + return redishelper.DefaultRedis.SetString(remainCountKey, strconv.Itoa(totalChars)) +} + // 根据id获取数据 func (e *TmMember) GetById(id int, data *models.TmMember) error { if err := e.Orm.Model(data).Where("id = ?", id).First(data).Error; err != nil { @@ -586,6 +673,15 @@ func (e *TmMember) GetById(id int, data *models.TmMember) error { return nil } +// 根据userId 获取数据 +func (e *TmMember) GetByUserId(userId int, data *models.TmMember) error { + if err := e.Orm.Model(data).Where("user_id = ?", userId).First(data).Error; err != nil { + return err + } + + return nil +} + // 修改翻译用户状态 func (e TmMember) ChangeStatus(req *dto.TmMemberChangeStatusReq, p *actions.DataPermission) error { var err error @@ -638,7 +734,6 @@ func (e *TmMember) SyncInsert(req *dto.TmMemberSyncInsertReq, entity *models.TmM platformService := TmPlatform{Service: e.Service} activePlatforms, _ := platformService.GetActiveList() TmMemberPlatforms := make([]models.TmMemberPlatform, 0) - copier.Copy(entity, req) apiKey, err := utility.GenerateBase62Key(32) @@ -661,8 +756,8 @@ func (e *TmMember) SyncInsert(req *dto.TmMemberSyncInsertReq, entity *models.TmM PlatformId: platform.Id, PlatformKey: platform.Code, Status: 1, - TotalCharacter: 10000, - RemainingCharacter: 10000, + TotalCharacter: 0, + RemainingCharacter: 0, } TmMemberPlatforms = append(TmMemberPlatforms, item) @@ -672,9 +767,10 @@ func (e *TmMember) SyncInsert(req *dto.TmMemberSyncInsertReq, entity *models.TmM return err } - for _, platform := range TmMemberPlatforms { - e.IncrBy(platform.PlatformKey, entity.ApiKey, platform.RemainingCharacter) - } + // for _, platform := range TmMemberPlatforms { + // // e.IncrBy(platform.PlatformKey, entity.ApiKey, platform.RemainingCharacter) + // e.IncyByQuote(ctx,platform.PlatformKey,entity.ApiKey,platform.TotalCharacter,,) + // } return nil } diff --git a/app/admin/service/tm_member_daily_usage.go b/app/admin/service/tm_member_daily_usage.go index 1009f54..e963c05 100644 --- a/app/admin/service/tm_member_daily_usage.go +++ b/app/admin/service/tm_member_daily_usage.go @@ -128,7 +128,7 @@ func (e *TmMemberDailyUsage) GetStatistic(userId int, resp *dto.TmMemberPlatform if err := e.Orm.Model(models.TmMemberDailyUsage{}). Joins("JOIN tm_member on tm_member.id=tm_member_daily_usage.member_id"). - Where("tm_member_daily_usage.date >= ? and tm_member_daily_usage.date <= ?", startTime, endTime).Find(&datas).Error; err != nil { + Where("tm_member.user_id =? and tm_member_daily_usage.date >= ? and tm_member_daily_usage.date <= ?", userId, startTime, endTime).Find(&datas).Error; err != nil { e.Log.Error("获取折线图数据失败", err) return nil } @@ -178,3 +178,24 @@ func (e *TmMemberDailyUsage) GetStatistic(userId int, resp *dto.TmMemberPlatform resp.Data = respDatas return nil } + +// 同步总使用量 +func (e *TmMemberDailyUsage) SyncTotalUse() error { + var datas []dto.TmMemberPlatformGroupData + var data models.TmMemberPlatform + + if err := e.Orm.Model(models.TmMemberDailyUsage{}). + Group("member_id, platform_id"). + Select("member_id, platform_id, sum(use_chars) as total"). + Find(&datas).Error; err != nil { + return err + } + + for _, item := range datas { + if err := e.Orm.Model(data).Where("member_id = ? and platform_id = ? ", item.MemberId, item.PlatformId).Update("use_character", item.Total).Error; err != nil { + continue + } + } + + return nil +} diff --git a/app/admin/service/tm_member_platform.go b/app/admin/service/tm_member_platform.go index f17c7bf..1e1f33a 100644 --- a/app/admin/service/tm_member_platform.go +++ b/app/admin/service/tm_member_platform.go @@ -16,6 +16,42 @@ type TmMemberPlatform struct { service.Service } +// 修改用户字符 +func (e TmMemberPlatform) ChangeChars(req *dto.TmMemberPlatformChangeCharsReq, p *actions.DataPermission) error { + var data models.TmMemberPlatform + + switch req.Type { + case 1: + member := models.TmMember{} + memberService := TmMember{Service: e.Service} + memberService.GetById(req.MemberId, &member) + + if member.Id == 0 { + return errors.New("用户不存在") + } + + platformService := TmPlatform{Service: e.Service} + platform, err := platformService.GetById(req.PlatformId) + + if err != nil { + return errors.New("平台不存在") + } + + if err := memberService.SetChars(platform.Code, member.ApiKey, req.ChangeNum); err != nil { + return errors.New("设置剩余字符失败") + } + + if err := e.Orm.Model(data).Where("member_id =? AND platform_id =?", req.MemberId, req.PlatformId).Update("remaining_character", req.ChangeNum).Error; err != nil { + e.Log.Errorf("TmMemberPlatformService ChangeChars error:%s \r\n", err) + } + case 2: + default: + return errors.New("修改类型错误") + } + + return nil +} + // GetPage 获取TmMemberPlatform列表 func (e *TmMemberPlatform) GetPage(c *dto.TmMemberPlatformGetPageReq, p *actions.DataPermission, list *[]models.TmMemberPlatform, count *int64) error { var err error @@ -107,3 +143,40 @@ func (e *TmMemberPlatform) Remove(d *dto.TmMemberPlatformDeleteReq, p *actions.D } return nil } + +// GetMemberList 获取用户列表 +func (e *TmMemberPlatform) GetMemberList(memberIds []int) ([]models.TmMemberPlatform, error) { + result := make([]models.TmMemberPlatform, 0) + err := e.Orm.Model(&models.TmMemberPlatform{}). + Where("member_id IN (?)", memberIds). + Find(&result).Error + if err != nil { + e.Log.Errorf("Service GetMemberList error:%s \r\n", err) + return nil, err + } + + return result, nil +} + +// InsertOrUpdateRemainChars 新增或更新用户剩余字符 +func (e *TmMemberPlatform) GetOrInsert(req *dto.TmRechargeLogInsertOrUpdateReq) (models.TmMemberPlatform, error) { + result := models.TmMemberPlatform{} + + e.Orm.Model(result).Where("member_id =? AND platform_id =?", req.MemberId, req.PlatformId).First(&result) + + if result.Id == 0 { + result.MemberId = req.MemberId + result.PlatformId = req.PlatformId + result.PlatformKey = req.PlatformKey + result.RemainingCharacter = 0 + result.TotalCharacter = 0 + result.Status = 1 + + if err := e.Orm.Save(&result).Error; err != nil { + e.Log.Errorf("Service InsertOrUpdateRemainChars error:%s \r\n", err) + return result, err + } + } + + return result, nil +} diff --git a/app/admin/service/tm_platform.go b/app/admin/service/tm_platform.go index 2b421cc..4fc3685 100644 --- a/app/admin/service/tm_platform.go +++ b/app/admin/service/tm_platform.go @@ -225,6 +225,17 @@ func (e *TmPlatform) GetByKey(code string) (*models.TmPlatform, error) { return &result, nil } +// 根据id 获取翻译平台信息 +func (e *TmPlatform) GetById(id int) (*models.TmPlatform, error) { + var result models.TmPlatform + err := e.Orm.Model(&result).Where("id = ?", id).Find(&result).Error + if err != nil { + e.Log.Errorf("db error:%s", err) + return nil, err + } + return &result, nil +} + func (e *TmPlatform) GetActiveList() ([]models.TmPlatform, error) { var list []models.TmPlatform err := e.Orm.Model(&models.TmPlatform{}).Find(&list).Error diff --git a/app/admin/service/tm_recharge_log.go b/app/admin/service/tm_recharge_log.go new file mode 100644 index 0000000..56c7a00 --- /dev/null +++ b/app/admin/service/tm_recharge_log.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "errors" + "fmt" + "go-admin/app/admin/models" + "go-admin/app/admin/service/dto" + "go-admin/app/admin/service/quota_manager" + "go-admin/common/actions" + cDto "go-admin/common/dto" + "go-admin/common/statuscode" + "go-admin/utils/redishelper" + "go-admin/utils/utility" + "sort" + "strconv" + "time" + + "github.com/go-admin-team/go-admin-core/sdk/service" + "gorm.io/gorm" +) + +type TmRechargeLog struct { + service.Service +} + +// 首页获取即将过期的数据 +func (e *TmRechargeLog) GetMemberAdvent(req *dto.TmRechargeLogFrontReq, resp *[]dto.TmRechargeLogFrontResp, userId int) int { + var datas []models.TmRechargeLog + + if err := e.Orm.Model(&models.TmRechargeLog{}). + Where("user_id =? and platform_id =? and expire_at > now() and status =2", userId, req.PlatformId). + Find(&datas).Error; err != nil { + e.Log.Errorf("TmRechargeLogService GetMemberAdvent error:%s \r\n", err) + return statuscode.ServerError + } + + for _, item := range datas { + respItem := dto.TmRechargeLogFrontResp{} + respItem.Id = item.Id + respItem.OrderNo = item.OrderNo + // respItem.PlatformName=item.PlatformName + respItem.TotalCharater = item.TotalChars + respItem.ExpireUnix = item.ExpireAt.Unix() + count, err := e.GetRemainByOrderNo(item.OrderNo) + + if err != nil { + e.Log.Errorf("TmRechargeLogService GetRemainByOrderNo error:%s \r\n", err) + continue + } + + respItem.RemainCharater = count + *resp = append(*resp, respItem) + } + + sort.Slice(*resp, func(i, j int) bool { + return (*resp)[i].ExpireUnix < (*resp)[j].ExpireUnix + }) + + return statuscode.Success +} + +// 后台充值 +func (e *TmRechargeLog) ManagerRecharge(req *dto.TmRechargeCreateOrderReq, p *actions.DataPermission) error { + ctx := context.Background() + var data models.TmRechargeLog + member, platform, memberPlatform, code := e.CreateOrderJudge(req) + if code != statuscode.Success { + return errors.New(statuscode.ErrorMessage[code]) + } + + req.Generate(&data) + now := time.Now() + data.OrderNo = utility.GenerateTraceID() + data.Type = 2 + data.Status = 2 + data.UserId = member.UserId + data.ExpireAt = time.Now().AddDate(0, 0, req.ExpireDays) + data.PayTime = &now + + rechargeData := quota_manager.QuotaRecharge{ + QuotaID: data.OrderNo, + Amount: int64(data.TotalChars), + ExpireAt: data.ExpireAt.Unix(), + } + qmgr := quota_manager.NewQuotaManager(redishelper.DefaultRedis.GetClient()) + + // 事务处理 + err := e.Orm.Transaction(func(tx *gorm.DB) error { + //写入充值记录 + if err1 := e.Orm.Create(&data).Error; err1 != nil { + return err1 + } + + //更新用户翻译可用字符 + if err1 := tx.Model(&models.TmMemberPlatform{}).Where("id =?", memberPlatform.Id).Update("remaining_character", data.TotalChars).Error; err1 != nil { + return err1 + } + + //写入可用字符 + if err1 := qmgr.AddQuota(ctx, member.ApiKey, platform.Code, rechargeData); err1 != nil { + return err1 + } + + return nil + }) + + return err +} + +// 新增充值校验 +func (e *TmRechargeLog) CreateOrderJudge(req *dto.TmRechargeCreateOrderReq) (models.TmMember, *models.TmPlatform, models.TmMemberPlatform, int) { + memberService := TmMember{Service: e.Service} + member := models.TmMember{} + if err := memberService.GetById(req.MemberId, &member); err != nil { + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.NotFindMember + } + + if member.ApiKey == "" { + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.NotFindApiKey + } + + platformService := TmPlatform{Service: e.Service} + platform, err := platformService.GetById(req.PlatformId) + + if err != nil { + e.Log.Errorf("获取平台信息失败:%s \r\n", err.Error()) + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.PlatformNotSupport + } + + if platform == nil || platform.Id == 0 { + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.PlatformNotSupport + } + + memberPlatformService := TmMemberPlatform{Service: e.Service} + memberPlatform, err := memberPlatformService.GetOrInsert(&dto.TmRechargeLogInsertOrUpdateReq{ + MemberId: req.MemberId, + PlatformId: req.PlatformId, + PlatformKey: platform.Code, + }) + + if err != nil { + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.ServerError + } + + if memberPlatform.Status == 2 { + return models.TmMember{}, nil, models.TmMemberPlatform{}, statuscode.MemberPlatformNotSupport + } + return member, platform, memberPlatform, statuscode.Success +} + +// 用户发起充值 +// return code +func (e TmRechargeLog) CreateOrder(req *dto.TmRechargeCreateOrderReq, apiKey string) int { + // ctx := context.Background() + // var data models.TmRechargeLog + // member, platform, memberPlatform, code := e.CreateOrderJudge(req) + + // if code != statuscode.Success { + // return code + // } + // req.Generate(&data) + // now := time.Now() + // data.OrderNo = utility.GenerateTraceID() + // data.Type = 1 + // data.Status = 1 + // data.UserId = member.UserId + // data.ExpireAt = time.Now().AddDate(0, 0, 30) + // data.PaymentTime = &now + + return statuscode.Success +} + +// 分页查询 +func (e *TmRechargeLog) GetPage(req *dto.TmRechargeLogGetPageReq, p *actions.DataPermission, datas *[]dto.TmRechargeLogResp, count *int64) error { + var err error + var data models.TmRechargeLog + var list []models.TmRechargeLog + + err = e.Orm.Model(&data). + Scopes( + cDto.MakeCondition(req.GetNeedSearch()), + cDto.Paginate(req.GetPageSize(), req.GetPageIndex()), + actions.Permission(data.TableName(), p), + ). + Find(list).Limit(-1).Offset(-1). + Count(count).Error + if err != nil { + e.Log.Errorf("TmPlatformService GetPage error:%s \r\n", err) + return err + } + return nil +} + +// 根据充值订单号 获取剩余可用字符 +func (e *TmRechargeLog) GetRemainByOrderNo(orderNo string) (int, error) { + key := fmt.Sprintf("quota:%s", orderNo) + val, err := redishelper.DefaultRedis.HGetField(key, "amount") + + if err != nil { + return 0, err + } + + return strconv.Atoi(val) +} diff --git a/app/admin/service/translator_service.go b/app/admin/service/translator_service.go index b0ced86..324e5b4 100644 --- a/app/admin/service/translator_service.go +++ b/app/admin/service/translator_service.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "fmt" "go-admin/app/admin/models" @@ -95,6 +96,7 @@ func (s *TranslatorService) RegisterProvider(name string, provider Translator) { // 翻译校验 // return statusCode func (s *TranslatorService) TranslateJudge(req *dto.TranslateReq, apiKey string) (result *dto.TranslateResult, respCode int) { + ctx := context.Background() tmMemberService := TmMember{Service: s.Service} tmPlatformAccount := TmPlatformAccount{Service: s.Service} memberInfo, err1 := tmMemberService.GetByKey(apiKey) @@ -126,16 +128,23 @@ func (s *TranslatorService) TranslateJudge(req *dto.TranslateReq, apiKey string) return } - result, err := Translator.providers[req.Platform].Translate(req.Text, req.SourceLang, req.TargetLang) + decyDatas, err := tmMemberService.DecrByQuote(ctx, req.Platform, apiKey, count) + + if err != nil { + s.Log.Errorf("翻译计数失败:%v", err) + return nil, statuscode.ServerError + } + + result, err = Translator.providers[req.Platform].Translate(req.Text, req.SourceLang, req.TargetLang) if err == nil { - err2 := tmMemberService.DecrBy(req.Platform, apiKey, count) + // err2 := tmMemberService.DecrBy(req.Platform, apiKey, count) - if err2 != nil { - s.Log.Errorf("翻译计数失败:%v", err2) - respCode = statuscode.ServerError - return - } + // if err2 != nil { + // s.Log.Errorf("翻译计数失败:%v", err2) + // respCode = statuscode.ServerError + // return + // } platformConfigInterface := Translator.config.ProviderConfigs[req.Platform] @@ -149,6 +158,8 @@ func (s *TranslatorService) TranslateJudge(req *dto.TranslateReq, apiKey string) //每日统计保留三天 redishelper.DefaultRedis.Expire(fmt.Sprintf(rediskey.TM_MEMBER_DAILY_COUNT, date, apiKey, req.Platform), 3*24*time.Hour) } else { + tmMemberService.RefundQuote(ctx, apiKey, req.Platform, &decyDatas) + code = statuscode.ServerError } diff --git a/cmd/api/server.go b/cmd/api/server.go index 20c81ce..fb00de7 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -35,6 +35,7 @@ import ( "go-admin/common/storage" ext "go-admin/config" "go-admin/utils/redishelper" + "go-admin/utils/utility" ) var ( @@ -177,6 +178,9 @@ func run() error { } func initCommon() { + //初始化雪花算法 + utility.InitSnowflake() + redishelper.InitDefaultRedis(config.CacheConfig.Redis.Addr, config.CacheConfig.Redis.Password, config.CacheConfig.Redis.DB) if err := redishelper.DefaultRedis.Ping(); err != nil { @@ -195,6 +199,7 @@ func initCommon() { os.Exit(-1) } + } var Router runtime.Router diff --git a/common/statuscode/status_1.go b/common/statuscode/status_1.go index d479bf0..aae6725 100644 --- a/common/statuscode/status_1.go +++ b/common/statuscode/status_1.go @@ -10,16 +10,19 @@ type Response struct { } var ErrorMessage = map[int]string{ - Success: "success", - Unauthorized: "unauthorized", - ServerError: "server error", - NotFound: "not found", - Forbidden: "forbidden", - InvalidParams: "invalid params", - InSufficRemainChar: "insufficent remain char", - PlatformNotSupport: "platform not support", - TransactionNotAvailable: "transaction not available", - ApiUnauthorized: "api unauthorized", + Success: "success", + Unauthorized: "unauthorized", + ServerError: "server error", + NotFound: "not found", + Forbidden: "forbidden", + InvalidParams: "invalid params", + InSufficRemainChar: "insufficent remain char", + PlatformNotSupport: "platform not support", + TransactionNotAvailable: "transaction not available", + ApiUnauthorized: "api unauthorized", + NotFindMember: "not find member", + NotFindApiKey: "not find api key", + MemberPlatformNotSupport: "member platform not support", } const ( @@ -37,4 +40,8 @@ const ( PlatformNotSupport = 20002 //平台不支持 TransactionNotAvailable = 20003 //翻译服务不可用 ApiUnauthorized = 20004 //api禁止访问 + + NotFindMember = 30001 //未找到用户 + NotFindApiKey = 30002 //未找到api key + MemberPlatformNotSupport = 30003 //用户平台不支持 ) diff --git a/go.mod b/go.mod index 7bd32cc..c1fddaa 100644 --- a/go.mod +++ b/go.mod @@ -126,6 +126,7 @@ require ( github.com/shamsher31/goimgext v1.0.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sony/sonyflake v1.2.1 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/pflag v1.0.3 // indirect github.com/tklauser/go-sysconf v0.3.9 // indirect diff --git a/utils/redishelper/redis_helper.go b/utils/redishelper/redis_helper.go index c1bb54b..bcba76e 100644 --- a/utils/redishelper/redis_helper.go +++ b/utils/redishelper/redis_helper.go @@ -51,6 +51,14 @@ func NewRedisHelper(addr, password string, db int) *RedisHelper { } } +func (r *RedisHelper) GetClient() *redis.Client { + return r.client +} + +func (r *RedisHelper) GetCtx() context.Context { + return r.ctx +} + // 测试连接 func (r *RedisHelper) Ping() error { return r.client.Ping(r.ctx).Err() @@ -100,6 +108,19 @@ func (r *RedisHelper) SetAdd(key, value string, expireTime time.Duration) error } } +// 更新zset score +func (r *RedisHelper) ZUpdateScore(key string, score float64, value string) error { + return r.client.ZAddArgs(r.ctx, key, redis.ZAddArgs{ + XX: true, // 只更新已存在 + Members: []redis.Z{ + { + Score: float64(score), + Member: value, + }, + }, + }).Err() +} + // 设置对象 func SetObjString[T any](r *RedisHelper, key string, value T) error { keyValue, err := sonic.Marshal(value) @@ -736,6 +757,28 @@ func (e *RedisHelper) GetRevRangeScoresSortSet(key string) ([]redis.Z, error) { return e.client.ZRevRangeWithScores(e.ctx, key, 0, -1).Result() } +// ZSET 中按 score 范围取出成员 +func (r *RedisHelper) ZRangeByScore(key string, min, max string) ([]string, error) { + ctx := context.Background() + return r.client.ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: min, + Max: max, + }).Result() +} + +// ZSET 中移除指定成员: +func (r *RedisHelper) ZRemValues(key string, members ...string) error { + ctx := context.Background() + + // 转换为 interface{} 类型参数 + vals := make([]interface{}, len(members)) + for i, m := range members { + vals[i] = m + } + + return r.client.ZRem(ctx, key, vals...).Err() +} + // 获取最后一条数据 func (e *RedisHelper) GetLastSortSet(key string) ([]redis.Z, error) { // 获取最后一个元素及其分数 diff --git a/utils/utility/id_helper.go b/utils/utility/id_helper.go index 4616231..113103a 100644 --- a/utils/utility/id_helper.go +++ b/utils/utility/id_helper.go @@ -1,6 +1,7 @@ package utility import ( + "strconv" "strings" "github.com/rs/xid" @@ -14,6 +15,7 @@ import ( "time" log "github.com/go-admin-team/go-admin-core/logger" + "github.com/sony/sonyflake" ) const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @@ -79,3 +81,20 @@ func GenerateBase62Key(length int) (string, error) { return b.String(), nil } + +var sf *sonyflake.Sonyflake + +func InitSnowflake() { + sf = sonyflake.NewSonyflake(sonyflake.Settings{}) + if sf == nil { + log.Fatalf("Failed to initialize sonyflake") + } +} + +func GenerateTraceID() string { + id, err := sf.NextID() + if err != nil { + log.Fatalf("Failed to generate ID: %v", err) + } + return strconv.FormatUint(id, 10) +}